Compare commits

..

40 Commits

Author SHA1 Message Date
Antigravity 0ac5176df6 add-search-and-top100
Build Institutional Trader / build-and-deploy (push) Successful in 3m9s Details
2026-06-30 00:30:53 +00:00
Antigravity f177252d6d trigger-build
Build Institutional Trader / build-and-deploy (push) Successful in 28s Details
2026-06-29 23:56:44 +00:00
Deep Koluguri e784bc46eb Add Reversal Screener tab to dashboard
Build Institutional Trader / build-and-deploy (push) Successful in 7m35s Details
2026-06-29 23:23:09 +00:00
Deep Koluguri 2b3355cddb merge: resolve conflicts between local production fixes and remote updates
Build Institutional Trader / build-and-deploy (push) Successful in 5m41s Details
2026-06-29 17:13:44 -04:00
Deep Koluguri fe48536e9b Update app.yaml with python sidecar
Build Institutional Trader / build-and-deploy (push) Successful in 2m39s Details
2026-06-29 11:48:26 -04:00
Deep Koluguri 03a7e73b37 Update Dockerfile for python
Build Institutional Trader / build-and-deploy (push) Has been cancelled Details
2026-06-29 11:42:47 -04:00
Deep Koluguri 5241f18052 style: Add explanatory tooltips to factor lens rows
Build Institutional Trader / build-and-deploy (push) Successful in 54s Details
2026-06-27 16:00:46 -04:00
Deep Koluguri 85e11dca88 feat: Expand universe to ~125 symbols covering EM and sectors, optimize fetching with concurrency and rate limit delays
Build Institutional Trader / build-and-deploy (push) Successful in 23s Details
2026-06-27 15:12:10 -04:00
Deep Koluguri 1278b5d4eb style: Move StockDetailPanel directly under the search bar for better UX
Build Institutional Trader / build-and-deploy (push) Successful in 50s Details
2026-06-27 15:02:34 -04:00
Deep Koluguri 85e9e523d9 fix: Replace Kaniko with standard docker build to avoid context mounting issues
Build Institutional Trader / build-and-deploy (push) Successful in 1m37s Details
2026-06-27 14:24:36 -04:00
Deep Koluguri 189d33e65e fix: Revert JOB_CONTAINER extraction now that zombies are cleared
Build Institutional Trader / build-and-deploy (push) Failing after 21s Details
2026-06-27 14:21:16 -04:00
Deep Koluguri 9b2420ed8d fix: Kaniko mounting wrong workspace due to race condition
Build Institutional Trader / build-and-deploy (push) Failing after 16s Details
2026-06-25 23:14:58 -04:00
Deep Koluguri ab1e3f28f6 feat: Add dynamic Fundamental Factor Lens to individual stock search view
Build Institutional Trader / build-and-deploy (push) Failing after 17s Details
2026-06-25 22:59:16 -04:00
Deep Koluguri ae21b0df90 chore: add --no-cache to kaniko pipeline
Build Institutional Trader / build-and-deploy (push) Failing after 16s Details
2026-06-25 22:44:13 -04:00
Deep Koluguri 7907fa30f2 feat: Add 1W, 1M, 1Y returns to macro dashboard
Build Institutional Trader / build-and-deploy (push) Successful in 2m9s Details
2026-06-25 22:36:15 -04:00
Deep Koluguri e43361fadd chore: cache bust
Build Institutional Trader / build-and-deploy (push) Successful in 2m3s Details
2026-06-25 22:29:20 -04:00
Deep Koluguri 6e323967a1 feat: swap generic mkts for specific regions (EWJ, FXI, INDA, VGK)
Build Institutional Trader / build-and-deploy (push) Successful in 1m59s Details
2026-06-25 21:31:51 -04:00
Deep Koluguri 9625979954 feat: Add Global Macro Indicators strip to dashboard
Build Institutional Trader / build-and-deploy (push) Successful in 2m9s Details
2026-06-25 21:04:43 -04:00
Deep Koluguri cd78058072 UI Honesty Pivot: Remove predictive claims, add methodology, descriptive flow
Build Institutional Trader / build-and-deploy (push) Successful in 2m8s Details
2026-06-25 20:32:15 -04:00
Deep Koluguri 2e2efaf530 fix(backend): restore calculateRocketScore to fix crash
Build Institutional Trader / build-and-deploy (push) Successful in 2m3s Details
2026-06-25 19:40:16 -04:00
Deep Koluguri 8677489043 fix(ui): tear out predictive swimlanes and replace with descriptive factor lens grid
Build Institutional Trader / build-and-deploy (push) Successful in 2m10s Details
2026-06-25 19:28:12 -04:00
Deep Koluguri a5e06b44b1 feat(factorlab): pivot to honest transparent research accelerator, implement factor pipeline, descriptive lens, and fundamental snapshotter
Build Institutional Trader / build-and-deploy (push) Successful in 2m9s Details
2026-06-25 00:29:34 -04:00
Deep Koluguri 4dfe340345 style: remove nested scrollbars from swimlanes, let main window scroll
Build Institutional Trader / build-and-deploy (push) Successful in 2m3s Details
2026-06-24 19:38:55 -04:00
Deep Koluguri dfae02c851 fix: use relative URLs for API requests to fix CSP block in production
Build Institutional Trader / build-and-deploy (push) Successful in 1m57s Details
2026-06-24 19:30:05 -04:00
Deep Koluguri 7c67786e80 fix: relax CORS middleware to prevent 500 errors when CORS_ORIGIN is missing
Build Institutional Trader / build-and-deploy (push) Successful in 1m58s Details
2026-06-24 19:25:10 -04:00
Deep Koluguri 43ba386da9 fix: CORP header for Cloudflare proxy
Build Institutional Trader / build-and-deploy (push) Has been cancelled Details
2026-06-24 19:23:18 -04:00
Deep Koluguri 7344d5a2b1 fix: static files before routes, fix CSP for assets and Cloudflare, SPA fallback safety
Build Institutional Trader / build-and-deploy (push) Successful in 1m57s Details
2026-06-24 19:17:43 -04:00
Deep Koluguri 78b408057c fix: health returns 200 for degraded, probe tolerant of optional services
Build Institutional Trader / build-and-deploy (push) Successful in 2m2s Details
2026-06-24 19:13:56 -04:00
Deep Koluguri 911693da88 feat: swimlane screener suitability scoring global ticker search remove side panels
Build Institutional Trader / build-and-deploy (push) Successful in 2m7s Details
2026-06-24 18:53:11 -04:00
Deep Koluguri f91534e976 feat: implement stock evaluation filters, fundamentals styling, and AI trading profiles
Build Institutional Trader / build-and-deploy (push) Successful in 2m1s Details
2026-06-22 23:19:38 -04:00
Deep Koluguri c981faf3a6 fix: resolve WebSocket path conflicts by manually routing HTTP upgrade events
Build Institutional Trader / build-and-deploy (push) Successful in 2m1s Details
2026-06-22 22:10:53 -04:00
Deep Koluguri 2ba6425eed fix: trust proxy for express-rate-limit to prevent crash behind APISIX
Build Institutional Trader / build-and-deploy (push) Successful in 2m4s Details
2026-06-22 22:03:32 -04:00
Deep Koluguri a0c54feee5 fix: import getApiUrl in AlertsFeed to fix reference error
Build Institutional Trader / build-and-deploy (push) Successful in 2m6s Details
2026-06-22 21:54:17 -04:00
Deep Koluguri b3373d6d85 fix: explicitly allow wss in CSP connectSrc
Build Institutional Trader / build-and-deploy (push) Successful in 2m13s Details
2026-06-22 19:47:00 -04:00
Deep Koluguri 2dba57191b fix: change hardcoded localhost URLs to relative URLs so frontend works on production domain
Build Institutional Trader / build-and-deploy (push) Successful in 2m10s Details
2026-06-22 19:43:18 -04:00
Deep Koluguri cd83c18555 fix: update CSP for Cloudflare and set CORS_ORIGIN to fix 500 errors
Build Institutional Trader / build-and-deploy (push) Successful in 2m8s Details
2026-06-22 19:40:24 -04:00
Deep Koluguri 76c24aaec1 fix: disable python service health check to prevent pod crashes
Build Institutional Trader / build-and-deploy (push) Has been cancelled Details
2026-06-22 19:38:15 -04:00
Deep Koluguri 4d00f978e2 feat: provision dedicated postgres database via CloudNativePG
Build Institutional Trader / build-and-deploy (push) Successful in 2m19s Details
2026-06-22 19:29:22 -04:00
Deep Koluguri f68b3e5b75 chore: trigger Kaniko build for institutional-trader
Build Institutional Trader / build-and-deploy (push) Successful in 2m11s Details
2026-06-22 17:24:39 -04:00
Deep Koluguri f6f35dba00 Update package-lock 2026-06-22 16:40:15 -04:00
113 changed files with 12834 additions and 958 deletions

View File

@ -10,14 +10,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
- name: Build and push (Kaniko)
- name: Build and push
run: |
JOB_CONTAINER=$(docker ps --format '{{.Names}}' | grep 'GITEA-ACTIONS-TASK' | head -1)
docker run --rm \
--volumes-from "$JOB_CONTAINER" \
gcr.io/kaniko-project/executor:latest \
--context=dir://"$GITHUB_WORKSPACE" \
--dockerfile="$GITHUB_WORKSPACE/Dockerfile" \
--destination=192.168.8.250:5000/market:latest \
--insecure \
--skip-tls-verify
docker build -t 192.168.8.250:5000/market:latest .
docker push 192.168.8.250:5000/market:latest

View File

@ -10,6 +10,12 @@ RUN npm run build
FROM node:18-alpine
WORKDIR /app/backend
# Install python and build dependencies
RUN apk add --no-cache python3 py3-pip python3-dev build-base && \
python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Install backend dependencies
COPY backend/package*.json ./
RUN npm install --production
@ -17,6 +23,9 @@ RUN npm install --production
# Copy backend source
COPY backend/ ./
# Install python dependencies
RUN pip install --no-cache-dir -r python_service/requirements.txt
# Copy built frontend to the public directory expected by server.js
# (__dirname is /app/backend/src, so ../../public is /app/public)
COPY --from=frontend-build /app/frontend/dist /app/public
@ -28,3 +37,7 @@ ENV PORT=3010
EXPOSE 3010
CMD ["node", "src/server.js"]
# Cache bust 20260625222919
# Cache bust 20260625223615

View File

@ -19,6 +19,8 @@
"express-rate-limit": "^7.1.5",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ml-logistic-regression": "^2.0.0",
"ml-matrix": "^6.13.0",
"node-cache": "^5.1.2",
"node-fetch": "^3.3.2",
"pg": "^8.11.3",
@ -933,6 +935,12 @@
"node": ">= 0.10"
}
},
"node_modules/is-any-array": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-3.0.0.tgz",
"integrity": "sha512-o4h+tylWykC4BD1vaejp6gDxoM13bwW8FGuNs4yIKpj8xbBJcRxJx8vZpq0dCr7ZDEfeKjmsi/euolKhX6f/ww==",
"license": "MIT"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -1074,6 +1082,54 @@
"node": "*"
}
},
"node_modules/ml-array-max": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-2.0.0.tgz",
"integrity": "sha512-QQZ4kENwpWmyNb98UXRDFXrmtIXuXtt1+bSbda/2KA85+F+rrJP8hZk6QOkCQXM2Th9mUDYdq/PNByPdT9ID4A==",
"license": "MIT",
"dependencies": {
"is-any-array": "^3.0.0"
}
},
"node_modules/ml-array-min": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ml-array-min/-/ml-array-min-2.0.0.tgz",
"integrity": "sha512-GRj6Ky6sW9vGL6yIjgsHmXZ9YgrdmcQ8nCxPqEGeKc6dkfYg1XDYxGFxADUjNuZyoCd5PUscWAS4N+cFaX6hFg==",
"license": "MIT",
"dependencies": {
"is-any-array": "^3.0.0"
}
},
"node_modules/ml-array-rescale": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ml-array-rescale/-/ml-array-rescale-2.0.0.tgz",
"integrity": "sha512-2GGtKfSno94/kIloWGvpp/U5Q5vLvLrza+SAaGsLeo6Xj4mEbA6Gqx+oTfZFkxnd1grT2X007HfJNs3T5BsiVg==",
"license": "MIT",
"dependencies": {
"is-any-array": "^3.0.0",
"ml-array-max": "^2.0.0",
"ml-array-min": "^2.0.0"
}
},
"node_modules/ml-logistic-regression": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ml-logistic-regression/-/ml-logistic-regression-2.0.0.tgz",
"integrity": "sha512-xHhB91ut8GRRbJyB1ZQfKsl1MHmE1PqMeRjxhks96M5BGvCbC9eEojf4KgRMKM2LxFblhVUcVzweAoPB48Nt0A==",
"license": "MIT",
"dependencies": {
"ml-matrix": "^6.5.0"
}
},
"node_modules/ml-matrix": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/ml-matrix/-/ml-matrix-6.13.0.tgz",
"integrity": "sha512-QpV0UTUkglg6vPUgThKGBEtit2ac6habSoZ33bwI9rU0UHZLqw6G3ukTIE8zWiUF3sjK8YAlhx/o/b9layzH8A==",
"license": "MIT",
"dependencies": {
"is-any-array": "^3.0.0",
"ml-array-rescale": "^2.0.0"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",

View File

@ -33,6 +33,8 @@
"express-rate-limit": "^7.1.5",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ml-logistic-regression": "^2.0.0",
"ml-matrix": "^6.13.0",
"node-cache": "^5.1.2",
"node-fetch": "^3.3.2",
"pg": "^8.11.3",

View File

@ -0,0 +1,35 @@
import { rawQuery } from '../src/db.js';
async function run() {
try {
await rawQuery(`
CREATE TABLE IF NOT EXISTS signals (
id SERIAL PRIMARY KEY,
ticker TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL,
lane TEXT NOT NULL,
score NUMERIC NOT NULL,
grade TEXT NOT NULL,
entry_price NUMERIC NOT NULL,
features JSONB NOT NULL,
regime JSONB NOT NULL,
data_age_sec INT,
resolved BOOLEAN DEFAULT FALSE,
intraday_move NUMERIC,
hit_2R_first BOOLEAN,
ret_5d NUMERIC,
ret_10d NUMERIC,
excess_3m NUMERIC,
excess_6m NUMERIC,
excess_12m NUMERIC
);
CREATE INDEX IF NOT EXISTS idx_signals_lane_grade ON signals(lane, grade, ts);
`);
console.log('Signals table initialized.');
process.exit(0);
} catch (err) {
console.error(err);
process.exit(1);
}
}
run();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
backend/src/backtest/cache/GS_2y.json vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
backend/src/backtest/cache/MA_2y.json vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
backend/src/backtest/cache/MU_2y.json vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
backend/src/backtest/cache/V_2y.json vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,16 @@
export const LANES = {
swing: {
maxHoldDays: 10,
targetAtr: 2.0,
stopAtr: 1.0,
entry: 'next_open',
burnIn: 200,
},
overnight: {
maxHoldDays: 1, // exit at close of day 1 (the entry day)
targetAtr: 1.0, // +1 ATR (reachable intraday)
stopAtr: 1.0, // -1 ATR
entry: 'next_open', // signal at close[t], enter open[t+1]
burnIn: 200,
}
};

View File

@ -0,0 +1,26 @@
/**
* Apply slippage and spread costs
* @param {number} price - The raw price (e.g. open/close)
* @param {string} side - 'buy' or 'sell'
* @param {Array} candles - Full candles array
* @param {number} idx - Index of the candle to compute ADV from
* @param {number} orderDollar - Estimated order size (default $5000)
*/
export function applyCosts(price, side, candles, idx, orderDollar = 5000) {
// Compute ADV from the 10 days prior to idx, or default to 1M if not enough data
let advDollar = 1000000;
if (idx >= 10) {
let volSum = 0;
for (let i = idx - 10; i < idx; i++) {
volSum += (candles[i].volume || 0) * (candles[i].close || 0);
}
if (volSum > 0) advDollar = volSum / 10;
}
// tightest spread ~ 0.02%, scaling up for less liquid
const spreadPct = Math.max(0.0002, 0.01 / Math.sqrt(advDollar));
// size-aware slippage
const slippagePct = 0.0005 * Math.sqrt(orderDollar / advDollar);
const drag = (spreadPct / 2) + slippagePct;
return side === 'buy' ? price * (1 + drag) : price * (1 - drag);
}

View File

@ -0,0 +1,96 @@
export function sma(candles, period) {
if (candles.length < period) return null;
const slice = candles.slice(-period);
return slice.reduce((a, c) => a + c.close, 0) / period;
}
export function atr14(candles, period = 14) {
if (!candles || candles.length <= period) return null;
const trs = [];
for (let i = 1; i < candles.length; i++) {
const { high, low } = candles[i];
const prevClose = candles[i - 1].close;
trs.push(Math.max(high - low, Math.abs(high - prevClose), Math.abs(low - prevClose)));
}
const recent = trs.slice(-period);
return recent.reduce((a, b) => a + b, 0) / recent.length;
}
export function rsi14(candles, period = 14) {
if (candles.length < period + 1) return null;
const recent = candles.slice(-period - 1);
let gains = 0, losses = 0;
for (let i = 1; i <= period; i++) {
const diff = recent[i].close - recent[i - 1].close;
if (diff >= 0) gains += diff;
else losses += Math.abs(diff);
}
if (losses === 0) return 100;
const rs = gains / losses;
return 100 - (100 / (1 + rs));
}
export function macd(candles, fast = 12, slow = 26, sig = 9) {
if (candles.length < slow + sig) return { macdLine: null, signalLine: null, hist: null };
const emaFast = sma(candles, fast); // simplified to SMA for backtest proxy
const emaSlow = sma(candles, slow);
const macdLine = emaFast - emaSlow;
// fake signal line for proxy (just using a small offset since we can't easily compute EMA of MACD over full history here without more state)
// Actually, let's just do a rough proxy
const signalLine = macdLine * 0.9;
return { macdLine, signalLine, hist: macdLine - signalLine };
}
export function slope(candles, period = 50, slopeDays = 5) {
if (candles.length < period + slopeDays) return null;
const currentSma = sma(candles, period);
const oldSma = sma(candles.slice(0, candles.length - slopeDays), period);
return (currentSma - oldSma) / oldSma; // % change in SMA
}
export function volume(candles, period) {
if (candles.length < period) return null;
return candles.slice(-period).reduce((a, c) => a + c.volume, 0) / period;
}
export function avgVolume(candles, period) {
return volume(candles, period);
}
export function regimeScore(spyCandles, vixClose) {
if (!spyCandles || spyCandles.length < 50) return 0;
const spySma20 = sma(spyCandles, 20);
const spySma50 = sma(spyCandles, 50);
const spyClose = spyCandles[spyCandles.length - 1].close;
if (spyClose > spySma20 && spyClose > spySma50 && vixClose < 20) return 1; // Risk-On
if (spyClose < spySma50 || vixClose > 25) return -1; // Risk-Off
return 0; // Neutral
}
/**
* Compute point-in-time features for the Swing lane.
* GUARANTEE: `candles` and `spyCandles` passed to this function must ALREADY
* be sliced to contain ONLY data up to and including the current day `t`.
*/
export function swingFeatures(candles, spyCandles, vix) {
const px = candles.at(-1).close;
const sma50 = sma(candles, 50);
const sma200 = sma(candles, 200);
const atr = atr14(candles);
const { macdLine, signalLine, hist } = macd(candles);
if (!sma50 || !sma200 || !atr || macdLine === null) return null;
return {
dist50Atr: (px - sma50) / atr,
dist200Atr: (px - sma200) / atr,
sma50Slope: slope(candles, 50, 5),
macdHistNorm: hist / atr,
macdCrossUp: macdLine > signalLine ? 1 : 0,
rsi: rsi14(candles),
atrPct: (atr / px) * 100,
rvol: volume(candles, 1) / avgVolume(candles, 50),
regime: regimeScore(spyCandles, vix)
};
}

View File

@ -0,0 +1,9 @@
export function fitGradeThresholds(trainProbs) {
const sorted = [...trainProbs].sort((a, b) => a - b);
const q = p => sorted[Math.floor(p * sorted.length)];
return { aMin: q(0.80), bMin: q(0.60), cMin: q(0.40) }; // A=top20%, B=top40%, C=top60%
}
export function toGrade(prob, t) {
return prob >= t.aMin ? 'A' : prob >= t.bMin ? 'B' : prob >= t.cMin ? 'C' : 'D';
}

View File

@ -0,0 +1,188 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { UNIVERSE } from '../services/stockUniverseService.js';
import { LANES } from './config.js';
import { simulateSwing } from './outcomeSim.js';
import { swingFeatures } from './features.js';
import { StandardScaler } from './model/scaler.js';
import { LogisticModel } from './model/logistic.js';
import { fitGradeThresholds, toGrade } from './grades.js';
import { generateResultsTable } from './report/resultsTable.js';
import { calibration } from './report/calibration.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CACHE_DIR = path.join(__dirname, 'cache');
const BACKTEST_RANGE = '2y';
const YF_BASE = 'https://query1.finance.yahoo.com';
const BURN_IN_DAYS = 200; // Need 200 days for 200-SMA
// Fetch historical data with caching
async function fetchHistoricalData(symbol) {
const cachePath = path.join(CACHE_DIR, `${symbol}_${BACKTEST_RANGE}.json`);
if (fs.existsSync(cachePath)) {
return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
}
console.log(`[Fetch] Downloading ${symbol} data...`);
const url = `${YF_BASE}/v8/finance/chart/${symbol}?interval=1d&range=${BACKTEST_RANGE}`;
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
if (!res.ok) return null;
const data = await res.json();
const result = data?.chart?.result?.[0];
if (result) fs.writeFileSync(cachePath, JSON.stringify(result));
return result;
}
function extractCandles(yfResult) {
const quotes = yfResult.indicators?.quote?.[0] || {};
const timestamps = yfResult.timestamp || [];
const candles = [];
for (let i = 0; i < timestamps.length; i++) {
if (quotes.high?.[i] != null && quotes.low?.[i] != null && quotes.close?.[i] != null && quotes.open?.[i] != null) {
candles.push({
ts: timestamps[i] * 1000,
date: new Date(timestamps[i] * 1000).toISOString().split('T')[0],
open: quotes.open[i],
high: quotes.high[i],
low: quotes.low[i],
close: quotes.close[i],
volume: quotes.volume?.[i] || 0
});
}
}
return candles;
}
async function runValidationGate() {
console.log(`\n======================================================`);
console.log(` PATH C (SWING) VALIDATION GATE`);
console.log(`======================================================\n`);
const cfg = LANES.swing;
const dataMap = new Map();
const symbols = ['SPY', '^VIX', ...UNIVERSE.map(s => s.symbol)];
// 0. Load Data
process.stdout.write("Loading historical data... ");
for (const sym of symbols) {
const raw = await fetchHistoricalData(sym);
if (raw) dataMap.set(sym, extractCandles(raw));
}
console.log("Done.");
const spyCandles = dataMap.get('SPY');
const vixCandles = dataMap.get('^VIX');
const allSignals = [];
// 1. Generate ALL signals over full window
process.stdout.write("Generating signals (simulating multi-day paths)... ");
for (let i = BURN_IN_DAYS; i < spyCandles.length - 1; i++) {
const currentSpyDate = spyCandles[i].date;
const currentVixDate = spyCandles[i].date; // approx
const spySlice = spyCandles.slice(0, i + 1);
// get VIX close
const vixSlice = vixCandles?.filter(c => c.date <= currentSpyDate);
const vixClose = vixSlice?.length ? vixSlice[vixSlice.length - 1].close : 15;
for (const u of UNIVERSE) {
const candles = dataMap.get(u.symbol);
if (!candles) continue;
const stockIdx = candles.findIndex(c => c.date === currentSpyDate);
if (stockIdx === -1) continue;
const stockSlice = candles.slice(0, stockIdx + 1);
// Compute point-in-time features (no lookahead!)
const features = swingFeatures(stockSlice, spySlice, vixClose);
if (!features) continue;
// Simulate outcome (multi-day) using the real entry index which is stockIdx + 1 (tomorrow's open)
const entryIdx = stockIdx + 1;
const result = simulateSwing(candles, entryIdx, cfg);
if (result) {
allSignals.push({
date: currentSpyDate, // The day the signal fired (market close)
symbol: u.symbol,
features,
outcome: result.outcome,
rMultiple: result.r
});
}
}
}
console.log(`Done. (${allSignals.length} signals generated)`);
if (allSignals.length === 0) {
console.error("No signals generated!");
return;
}
// 2. SPLIT: train = first 12 months, test = last 12 months
// We'll split roughly down the middle of the available dates
const uniqueDates = [...new Set(allSignals.map(s => s.date))].sort();
const splitDate = uniqueDates[Math.floor(uniqueDates.length / 2)];
const trainSet = allSignals.filter(s => s.date < splitDate);
const testSet = allSignals.filter(s => s.date >= splitDate);
console.log(`Split Date: ${splitDate}`);
console.log(`TRAIN Set: ${trainSet.length} signals`);
console.log(`TEST Set: ${testSet.length} signals`);
// 3. scaler.fit(train.features)
const scaler = new StandardScaler();
scaler.fit(trainSet.map(s => s.features));
// 4. model = logistic.fit(scaler.transform(train.features), train.win)
const model = new LogisticModel();
const trainFeaturesScaled = scaler.transformArray(trainSet.map(s => s.features));
const trainLabels = trainSet.map(s => s.outcome === 'WIN' ? 1 : 0);
const coeffs = model.fit(trainFeaturesScaled, trainLabels);
console.log(`\n=== MODEL FIT (Standardized Coefficients) ===`);
console.log(`Intercept: ${coeffs.intercept.toFixed(4)}`);
for (const k of Object.keys(coeffs)) {
if (k !== 'intercept') console.log(`${k.padEnd(15)} ${coeffs[k].toFixed(4)}`);
}
// 5. thresholds = fitGradeThresholds(model.predict(train))
const trainProbs = model.predict(trainFeaturesScaled);
const thresholds = fitGradeThresholds(trainProbs);
console.log(`\n=== GRADE THRESHOLDS (FROZEN ON TRAIN) ===`);
console.log(`Grade A min P(win): ${(thresholds.aMin*100).toFixed(1)}%`);
console.log(`Grade B min P(win): ${(thresholds.bMin*100).toFixed(1)}%`);
console.log(`Grade C min P(win): ${(thresholds.cMin*100).toFixed(1)}%`);
// ===================== NO TRAIN DATA PAST THIS POINT =====================
// 6. test.prob = model.predict(scaler.transform(test.features))
const testFeaturesScaled = scaler.transformArray(testSet.map(s => s.features));
const testProbs = model.predict(testFeaturesScaled);
for (let i = 0; i < testSet.length; i++) {
testSet[i].prob = testProbs[i];
testSet[i].grade = toGrade(testProbs[i], thresholds);
}
// 7. resultsTable(test)
generateResultsTable(testSet);
// 8. calibration(test)
calibration(testSet);
console.log(`\n======================================================`);
console.log(` VALIDATION GATE COMPLETE`);
console.log(`======================================================\n`);
}
runValidationGate().catch(console.error);

View File

@ -0,0 +1,188 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { UNIVERSE } from '../services/stockUniverseService.js';
import { LANES } from './config.js';
import { simulateSwing } from './outcomeSim.js';
import { swingFeatures } from './features.js';
import { StandardScaler } from './model/scaler.js';
import { LogisticModel } from './model/logistic.js';
import { fitGradeThresholds, toGrade } from './grades.js';
import { generateResultsTable } from './report/resultsTable.js';
import { calibration } from './report/calibration.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CACHE_DIR = path.join(__dirname, 'cache');
const BACKTEST_RANGE = '2y';
const YF_BASE = 'https://query1.finance.yahoo.com';
const BURN_IN_DAYS = 200; // Need 200 days for 200-SMA
// Fetch historical data with caching
async function fetchHistoricalData(symbol) {
const cachePath = path.join(CACHE_DIR, `${symbol}_${BACKTEST_RANGE}.json`);
if (fs.existsSync(cachePath)) {
return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
}
console.log(`[Fetch] Downloading ${symbol} data...`);
const url = `${YF_BASE}/v8/finance/chart/${symbol}?interval=1d&range=${BACKTEST_RANGE}`;
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
if (!res.ok) return null;
const data = await res.json();
const result = data?.chart?.result?.[0];
if (result) fs.writeFileSync(cachePath, JSON.stringify(result));
return result;
}
function extractCandles(yfResult) {
const quotes = yfResult.indicators?.quote?.[0] || {};
const timestamps = yfResult.timestamp || [];
const candles = [];
for (let i = 0; i < timestamps.length; i++) {
if (quotes.high?.[i] != null && quotes.low?.[i] != null && quotes.close?.[i] != null && quotes.open?.[i] != null) {
candles.push({
ts: timestamps[i] * 1000,
date: new Date(timestamps[i] * 1000).toISOString().split('T')[0],
open: quotes.open[i],
high: quotes.high[i],
low: quotes.low[i],
close: quotes.close[i],
volume: quotes.volume?.[i] || 0
});
}
}
return candles;
}
async function runValidationGate() {
console.log(`\n======================================================`);
console.log(` PATH B (OVERNIGHT) VALIDATION GATE`);
console.log(`======================================================\n`);
const cfg = LANES.overnight;
const dataMap = new Map();
const symbols = ['SPY', '^VIX', ...UNIVERSE.map(s => s.symbol)];
// 0. Load Data
process.stdout.write("Loading historical data... ");
for (const sym of symbols) {
const raw = await fetchHistoricalData(sym);
if (raw) dataMap.set(sym, extractCandles(raw));
}
console.log("Done.");
const spyCandles = dataMap.get('SPY');
const vixCandles = dataMap.get('^VIX');
const allSignals = [];
// 1. Generate ALL signals over full window
process.stdout.write("Generating signals (simulating multi-day paths)... ");
for (let i = BURN_IN_DAYS; i < spyCandles.length - 1; i++) {
const currentSpyDate = spyCandles[i].date;
const currentVixDate = spyCandles[i].date; // approx
const spySlice = spyCandles.slice(0, i + 1);
// get VIX close
const vixSlice = vixCandles?.filter(c => c.date <= currentSpyDate);
const vixClose = vixSlice?.length ? vixSlice[vixSlice.length - 1].close : 15;
for (const u of UNIVERSE) {
const candles = dataMap.get(u.symbol);
if (!candles) continue;
const stockIdx = candles.findIndex(c => c.date === currentSpyDate);
if (stockIdx === -1) continue;
const stockSlice = candles.slice(0, stockIdx + 1);
// Compute point-in-time features (no lookahead!)
const features = swingFeatures(stockSlice, spySlice, vixClose);
if (!features) continue;
// Simulate outcome (multi-day) using the real entry index which is stockIdx + 1 (tomorrow's open)
const entryIdx = stockIdx + 1;
const result = simulateSwing(candles, entryIdx, cfg);
if (result) {
allSignals.push({
date: currentSpyDate, // The day the signal fired (market close)
symbol: u.symbol,
features,
outcome: result.outcome,
rMultiple: result.r
});
}
}
}
console.log(`Done. (${allSignals.length} signals generated)`);
if (allSignals.length === 0) {
console.error("No signals generated!");
return;
}
// 2. SPLIT: train = first 12 months, test = last 12 months
// We'll split roughly down the middle of the available dates
const uniqueDates = [...new Set(allSignals.map(s => s.date))].sort();
const splitDate = uniqueDates[Math.floor(uniqueDates.length / 2)];
const trainSet = allSignals.filter(s => s.date < splitDate);
const testSet = allSignals.filter(s => s.date >= splitDate);
console.log(`Split Date: ${splitDate}`);
console.log(`TRAIN Set: ${trainSet.length} signals`);
console.log(`TEST Set: ${testSet.length} signals`);
// 3. scaler.fit(train.features)
const scaler = new StandardScaler();
scaler.fit(trainSet.map(s => s.features));
// 4. model = logistic.fit(scaler.transform(train.features), train.win)
const model = new LogisticModel();
const trainFeaturesScaled = scaler.transformArray(trainSet.map(s => s.features));
const trainLabels = trainSet.map(s => s.outcome === 'WIN' ? 1 : 0);
const coeffs = model.fit(trainFeaturesScaled, trainLabels);
console.log(`\n=== MODEL FIT (Standardized Coefficients) ===`);
console.log(`Intercept: ${coeffs.intercept.toFixed(4)}`);
for (const k of Object.keys(coeffs)) {
if (k !== 'intercept') console.log(`${k.padEnd(15)} ${coeffs[k].toFixed(4)}`);
}
// 5. thresholds = fitGradeThresholds(model.predict(train))
const trainProbs = model.predict(trainFeaturesScaled);
const thresholds = fitGradeThresholds(trainProbs);
console.log(`\n=== GRADE THRESHOLDS (FROZEN ON TRAIN) ===`);
console.log(`Grade A min P(win): ${(thresholds.aMin*100).toFixed(1)}%`);
console.log(`Grade B min P(win): ${(thresholds.bMin*100).toFixed(1)}%`);
console.log(`Grade C min P(win): ${(thresholds.cMin*100).toFixed(1)}%`);
// ===================== NO TRAIN DATA PAST THIS POINT =====================
// 6. test.prob = model.predict(scaler.transform(test.features))
const testFeaturesScaled = scaler.transformArray(testSet.map(s => s.features));
const testProbs = model.predict(testFeaturesScaled);
for (let i = 0; i < testSet.length; i++) {
testSet[i].prob = testProbs[i];
testSet[i].grade = toGrade(testProbs[i], thresholds);
}
// 7. resultsTable(test)
generateResultsTable(testSet);
// 8. calibration(test)
calibration(testSet);
console.log(`\n======================================================`);
console.log(` VALIDATION GATE COMPLETE`);
console.log(`======================================================\n`);
}
runValidationGate().catch(console.error);

View File

@ -0,0 +1,153 @@
import fs from 'fs';
function sigmoid(z) {
// Cap z to avoid overflow
if (z > 20) return 1.0;
if (z < -20) return 0.0;
return 1 / (1 + Math.exp(-z));
}
function computeLogLoss(features, labels, weights) {
let loss = 0;
for (let i = 0; i < features.length; i++) {
const f = features[i];
let z = weights[0];
for (let j = 0; j < f.length; j++) z += weights[j + 1] * f[j];
const p = sigmoid(z);
// clip p to avoid log(0)
const pSafe = Math.max(1e-15, Math.min(1 - 1e-15, p));
loss += -labels[i] * Math.log(pSafe) - (1 - labels[i]) * Math.log(1 - pSafe);
}
return loss / features.length;
}
export class LogisticModel {
constructor() {
this.weights = null;
this.featureKeys = [];
}
// Internal full-batch gradient descent
_train(X, Y, lambda, learningRate, maxSteps, tol) {
const numFeatures = X[0].length;
let w = new Array(numFeatures + 1).fill(0.0);
const N = X.length;
let prevLoss = Infinity;
for (let step = 0; step < maxSteps; step++) {
const grad = new Array(numFeatures + 1).fill(0.0);
for (let i = 0; i < N; i++) {
let z = w[0];
for (let j = 0; j < numFeatures; j++) z += w[j + 1] * X[i][j];
const p = sigmoid(z);
const err = p - Y[i];
grad[0] += err;
for (let j = 0; j < numFeatures; j++) {
grad[j + 1] += err * X[i][j];
}
}
// Add L2 penalty (skip intercept grad[0])
for (let j = 0; j < numFeatures; j++) {
grad[j + 1] += lambda * w[j + 1];
}
// Update weights
let maxDelta = 0;
for (let j = 0; j < w.length; j++) {
const update = (learningRate * grad[j]) / N;
w[j] -= update;
if (Math.abs(update) > maxDelta) maxDelta = Math.abs(update);
}
if (maxDelta < tol) {
// Converged
break;
}
}
return w;
}
fit(featuresArray, labels) {
if (!featuresArray.length) return;
this.featureKeys = Object.keys(featuresArray[0]);
// Convert to 2D array
const X = featuresArray.map(f => this.featureKeys.map(k => f[k]));
const Y = labels;
const N = X.length;
// 1. Carve a 80/20 validation slice from TRAIN to tune lambda
const splitIdx = Math.floor(N * 0.8);
const X_train = X.slice(0, splitIdx);
const Y_train = Y.slice(0, splitIdx);
const X_val = X.slice(splitIdx);
const Y_val = Y.slice(splitIdx);
// 2. Tune lambda
const lambdas = [0.01, 0.1, 1.0, 10.0, 100.0, 500.0, 1000.0];
let bestLambda = 0;
let bestLoss = Infinity;
console.log(`\nTuning L2 Regularization (Lambda) on Validation Slice...`);
for (const l of lambdas) {
const w = this._train(X_train, Y_train, l, 0.5, 2000, 1e-5);
const loss = computeLogLoss(X_val, Y_val, w);
console.log(` Lambda ${l.toString().padEnd(6)} -> LogLoss: ${loss.toFixed(4)}`);
if (loss < bestLoss) {
bestLoss = loss;
bestLambda = l;
}
}
console.log(`Selected Best Lambda: ${bestLambda}`);
// 3. Refit on FULL TRAIN set using best lambda
this.weights = this._train(X, Y, bestLambda, 0.5, 5000, 1e-5);
return this.getCoefficients();
}
predict(featuresArray) {
if (!this.weights) throw new Error("Model not trained");
const probs = [];
for (let i = 0; i < featuresArray.length; i++) {
const f = featuresArray[i];
let z = this.weights[0]; // intercept
for (let j = 0; j < this.featureKeys.length; j++) {
z += this.weights[j + 1] * f[this.featureKeys[j]];
}
probs.push(sigmoid(z));
}
return probs;
}
getCoefficients() {
if (!this.weights) return {};
const coeffs = { intercept: this.weights[0] };
for (let i = 0; i < this.featureKeys.length; i++) {
coeffs[this.featureKeys[i]] = this.weights[i + 1];
}
return coeffs;
}
save(filepath) {
fs.writeFileSync(filepath, JSON.stringify({
featureKeys: this.featureKeys,
weights: this.weights
}, null, 2));
}
load(filepath) {
if (!fs.existsSync(filepath)) return;
const data = JSON.parse(fs.readFileSync(filepath, 'utf8'));
this.featureKeys = data.featureKeys;
this.weights = data.weights;
}
}

View File

@ -0,0 +1,51 @@
import fs from 'fs';
import path from 'path';
export class StandardScaler {
constructor() {
this.means = {};
this.stds = {};
}
fit(featuresArray) {
if (!featuresArray.length) return;
// Get all feature keys
const keys = Object.keys(featuresArray[0]);
for (const key of keys) {
const values = featuresArray.map(f => f[key]);
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
const variance = values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / values.length;
const std = Math.sqrt(variance);
this.means[key] = mean;
this.stds[key] = std || 1; // Avoid divide by 0
}
}
transform(features) {
const scaled = {};
for (const key of Object.keys(this.means)) {
if (features[key] === undefined) continue;
scaled[key] = (features[key] - this.means[key]) / this.stds[key];
}
return scaled;
}
transformArray(featuresArray) {
return featuresArray.map(f => this.transform(f));
}
save(filepath) {
fs.writeFileSync(filepath, JSON.stringify({ means: this.means, stds: this.stds }, null, 2));
}
load(filepath) {
if (!fs.existsSync(filepath)) return;
const data = JSON.parse(fs.readFileSync(filepath, 'utf8'));
this.means = data.means;
this.stds = data.stds;
}
}

View File

@ -0,0 +1,36 @@
{
"name": "LogisticRegression",
"numSteps": 1000,
"learningRate": 0.05,
"numberClasses": 2,
"classifiers": [
{
"numSteps": 1000,
"learningRate": 0.05,
"weights": [
[
-94.4420175451543,
-5.766427757134977,
-74.50180063587948,
-66.23257473781456,
-1817.0926888106162,
-166.35311896181236
]
]
},
{
"numSteps": 1000,
"learningRate": 0.05,
"weights": [
[
-84.54027063090014,
-50.36483530223822,
13.904576636453635,
37.27497755449738,
-395.0000542460034,
41.80818011695695
]
]
}
]
}

View File

@ -0,0 +1,46 @@
import { swingFeatures } from './features.js';
function runTest() {
console.log("Running no-lookahead guardrail test...");
// Create mock candles where index = day
const candles = [];
for (let i = 0; i < 250; i++) {
candles.push({
date: `2024-01-${i}`,
open: 100 + i,
high: 105 + i,
low: 95 + i,
close: 102 + i,
volume: 1000000
});
}
const spyCandles = [...candles]; // clone
const vix = 15;
// We are at day index 210.
const currentDayIdx = 210;
// Guardrail: Pass ONLY the slice up to current day
const slicedCandles = candles.slice(0, currentDayIdx + 1);
const slicedSpy = spyCandles.slice(0, currentDayIdx + 1);
// Compute features
const feats = swingFeatures(slicedCandles, slicedSpy, vix);
if (!feats) {
throw new Error("Features returned null unexpectedly.");
}
// The slice literally does not contain any future data in memory.
// We can verify the length of the array inside the feature to be absolutely sure.
if (slicedCandles.length > currentDayIdx + 1) {
throw new Error("Lookahead leak: slicedCandles contains future data!");
}
console.log("PASS: Features successfully computed without lookahead bias.");
console.log("Computed Features:", feats);
}
runTest();

View File

@ -0,0 +1,44 @@
import { applyCosts } from './costs.js';
function atr14(candles, period = 14) {
if (!candles || candles.length <= period) return null;
const trs = [];
for (let i = 1; i < candles.length; i++) {
const { high, low } = candles[i];
const prevClose = candles[i - 1].close;
trs.push(Math.max(high - low, Math.abs(high - prevClose), Math.abs(low - prevClose)));
}
const recent = trs.slice(-period);
return recent.reduce((a, b) => a + b, 0) / recent.length;
}
export function simulateSwing(candles, entryIdx, cfg) {
// If entry is impossible (out of bounds)
if (entryIdx >= candles.length) return null;
const rawEntry = candles[entryIdx].open;
const entry = applyCosts(rawEntry, 'buy', candles, entryIdx);
// ATR known at signal (close of previous day). candles slice goes UP TO the entryIdx (so it includes the signal bar, which is entryIdx - 1)
// Wait, if entryIdx is the open of tomorrow, we pass candles.slice(0, entryIdx) so the last candle is today (the signal bar).
const preEntryCandles = candles.slice(0, entryIdx);
const atr = atr14(preEntryCandles);
if (!atr) return null;
const stop = entry - cfg.stopAtr * atr;
const target = entry + cfg.targetAtr * atr;
const R = entry - stop; // 1 ATR worth of price
const end = Math.min(entryIdx + cfg.maxHoldDays - 1, candles.length - 1);
for (let d = entryIdx; d <= end; d++) {
// pessimistic tie-break: if both hit same day, assume STOP first
if (candles[d].low <= stop) return { outcome: 'LOSS', r: -1.0, exitDay: d, stopPrice: stop, targetPrice: target, atr };
if (candles[d].high >= target) return { outcome: 'WIN', r: cfg.targetAtr, exitDay: d, stopPrice: stop, targetPrice: target, atr };
}
// unresolved → exit at close of last day, net of exit costs
const rawExit = candles[end].close;
const exit = applyCosts(rawExit, 'sell', candles, end);
return { outcome: 'SCRATCH', r: (exit - entry) / R, exitDay: end, stopPrice: stop, targetPrice: target, atr };
}

View File

@ -0,0 +1,31 @@
export function calibration(testResults) {
// We bucket predicted probabilities into deciles (0-10%, 10-20%, etc)
const buckets = Array(10).fill(null).map(() => ({ total: 0, wins: 0, sumProb: 0 }));
for (const r of testResults) {
if (r.prob === undefined || r.prob === null) continue;
let bIdx = Math.floor(r.prob * 10);
if (bIdx > 9) bIdx = 9; // Handle p=1.0 edge case
if (bIdx < 0) bIdx = 0;
buckets[bIdx].total++;
buckets[bIdx].sumProb += r.prob;
if (r.outcome === 'WIN') buckets[bIdx].wins++;
}
console.log(`\n=== CALIBRATION TABLE ===`);
console.log(`Bucket\t\tN\tPred P(Win)\tActual Win%\tDiff`);
console.log(`------------------------------------------------------------------`);
for (let i = 0; i < 10; i++) {
const b = buckets[i];
if (b.total === 0) continue;
const label = `${(i * 10).toString().padStart(2, '0')}-${((i + 1) * 10).toString().padStart(2, '0')}%`;
const predP = (b.sumProb / b.total) * 100;
const actP = (b.wins / b.total) * 100;
const diff = actP - predP;
console.log(`[${label}]\t${b.total}\t${predP.toFixed(1)}%\t\t${actP.toFixed(1)}%\t\t${diff > 0 ? '+' : ''}${diff.toFixed(1)}pp`);
}
}

View File

@ -0,0 +1,75 @@
export function ciExpectancy(rValues) {
const n = rValues.length;
if (n === 0) return { mean: 0, lo: 0, hi: 0, n: 0 };
const mean = rValues.reduce((a, b) => a + b, 0) / n;
if (n === 1) return { mean, lo: mean, hi: mean, n };
const variance = rValues.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (n - 1);
const sd = Math.sqrt(variance);
const se = sd / Math.sqrt(n);
return { mean, lo: mean - 1.96 * se, hi: mean + 1.96 * se, n };
}
export function generateResultsTable(testResults) {
const grades = ['A', 'B', 'C', 'D'];
const grouped = {
A: { wins: 0, losses: 0, scratches: 0, rVals: [] },
B: { wins: 0, losses: 0, scratches: 0, rVals: [] },
C: { wins: 0, losses: 0, scratches: 0, rVals: [] },
D: { wins: 0, losses: 0, scratches: 0, rVals: [] }
};
for (const r of testResults) {
if (grouped[r.grade]) {
grouped[r.grade].rVals.push(r.rMultiple);
if (r.outcome === 'WIN') grouped[r.grade].wins++;
else if (r.outcome === 'LOSS') grouped[r.grade].losses++;
else grouped[r.grade].scratches++;
}
}
console.log(`\n=== OUT-OF-SAMPLE TEST RESULTS (EXPECTANCY) ===`);
console.log(`Grade\tExp(R)\t\t95% CI (Exp)\t\tWin%\tLoss%\tScratch%\tn`);
console.log(`-----------------------------------------------------------------------------------------`);
for (const g of grades) {
const st = grouped[g];
const n = st.rVals.length;
if (n === 0) continue;
const pWin = (st.wins / n) * 100;
const pLoss = (st.losses / n) * 100;
const pScratch = (st.scratches / n) * 100;
const ci = ciExpectancy(st.rVals);
const meanFmt = `${ci.mean > 0 ? '+' : ''}${ci.mean.toFixed(3)}R`;
const ciFmt = `[${ci.lo.toFixed(3)}, ${ci.hi.toFixed(3)}]`;
console.log(`${g}\t${meanFmt}\t\t${ciFmt}\t\t${pWin.toFixed(1)}%\t${pLoss.toFixed(1)}%\t${pScratch.toFixed(1)}%\t\t${n}`);
}
// Random Control = The entire test set
const allR = testResults.map(r => r.rMultiple);
const totalN = allR.length;
if (totalN > 0) {
const totalWins = testResults.filter(r => r.outcome === 'WIN').length;
const totalLosses = testResults.filter(r => r.outcome === 'LOSS').length;
const totalScratches = testResults.filter(r => r.outcome === 'SCRATCH').length;
const ciRand = ciExpectancy(allR);
const meanFmt = `${ciRand.mean > 0 ? '+' : ''}${ciRand.mean.toFixed(3)}R`;
const ciFmt = `[${ciRand.lo.toFixed(3)}, ${ciRand.hi.toFixed(3)}]`;
const pWin = (totalWins / totalN) * 100;
const pLoss = (totalLosses / totalN) * 100;
const pScratch = (totalScratches / totalN) * 100;
console.log(`Rand\t${meanFmt}\t\t${ciFmt}\t\t${pWin.toFixed(1)}%\t${pLoss.toFixed(1)}%\t${pScratch.toFixed(1)}%\t\t${totalN}`);
}
console.log(`-----------------------------------------------------------------------------------------`);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,68 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { parse } from 'csv-parse/sync';
import LogisticRegression from 'ml-logistic-regression';
import { Matrix } from 'ml-matrix';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function train() {
console.log('Loading results.csv...');
const csvPath = path.join(__dirname, 'results.csv');
const fileContent = fs.readFileSync(csvPath, 'utf8');
const records = parse(fileContent, {
columns: true,
skip_empty_lines: true
});
const X = [];
const Y = [];
for (const row of records) {
if (row.type !== 'signal') continue; // Skip controls if they exist
// Parse features
const rsi = parseFloat(row.rsi);
const atrPct = parseFloat(row.atrPct);
const rvol = parseFloat(row.rvol);
const macd = parseFloat(row.macd);
const regime = parseFloat(row.regime_risk_on);
// Skip rows with missing data
if (isNaN(rsi) || isNaN(atrPct) || isNaN(rvol) || isNaN(macd) || isNaN(regime)) continue;
X.push([
1.0, // Intercept
rsi / 100,
atrPct / 10,
rvol / 5,
macd / 2,
regime
]);
// Target: 1 if win or win_partial, 0 otherwise
const isWin = (row.outcome === 'win' || row.outcome === 'win_partial') ? 1 : 0;
Y.push(isWin);
}
console.log(`Loaded ${X.length} valid training samples.`);
const xMat = new Matrix(X);
const yMat = Matrix.columnVector(Y);
console.log('Training Logistic Regression model...');
const logreg = new LogisticRegression({ numSteps: 1000, learningRate: 0.05 });
logreg.train(xMat, yMat);
const modelJson = logreg.toJSON();
const outputPath = path.join(__dirname, 'model_weights.json');
fs.writeFileSync(outputPath, JSON.stringify(modelJson, null, 2));
console.log(`Model trained and saved to ${outputPath}`);
}
train().catch(console.error);

View File

@ -0,0 +1,60 @@
import { loadFundamentals, getFundamentalsAsOf } from '../fundamentals.js';
function runTest() {
console.log("Running fundamentals lookahead test...");
const testData = [
{
symbol: 'AAPL',
datekey: '2020-02-15', // Actually filed on Feb 15
fiscalEnd: '2019-12-31',
metrics: { roe: 0.10 }
},
{
symbol: 'AAPL',
datekey: '2020-05-15', // Q1 filed on May 15
fiscalEnd: '2020-03-31',
metrics: { roe: 0.12 }
},
{
symbol: 'MSFT',
datekey: null, // Missing datekey, should fallback to fiscalEnd + 120 days
fiscalEnd: '2019-12-31', // + 120 days = 2020-04-29
metrics: { roe: 0.15 }
}
];
loadFundamentals(testData);
// Test 1: Jan 31, 2020.
// The Dec 31 fiscal data isn't filed until Feb 15. We MUST return null.
const a1 = getFundamentalsAsOf('AAPL', '2020-01-31');
if (a1 !== null) throw new Error("Lookahead leak! Returned Q4 data before filing date.");
// Test 2: Feb 16, 2020.
// The Dec 31 fiscal data was filed Feb 15. We should get it.
const a2 = getFundamentalsAsOf('AAPL', '2020-02-16');
if (!a2 || a2.roe !== 0.10) throw new Error("Failed to return available data.");
// Test 3: May 14, 2020.
// Q1 isn't filed until May 15. We should still get Q4 (roe: 0.10).
const a3 = getFundamentalsAsOf('AAPL', '2020-05-14');
if (!a3 || a3.roe !== 0.10) throw new Error("Lookahead leak! Returned Q1 data early.");
// Test 4: May 16, 2020.
// Now we should get Q1 (roe: 0.12).
const a4 = getFundamentalsAsOf('AAPL', '2020-05-16');
if (!a4 || a4.roe !== 0.12) throw new Error("Failed to roll forward to new data.");
// Test 5: MSFT fallback logic. Target = April 28 (119 days after fiscalEnd). Should be null.
const m1 = getFundamentalsAsOf('MSFT', '2020-04-28');
if (m1 !== null) throw new Error("Lookahead leak! MSFT fallback data returned early.");
// Test 6: MSFT fallback logic. Target = April 30 (121 days after fiscalEnd). Should be available.
const m2 = getFundamentalsAsOf('MSFT', '2020-04-30');
if (!m2 || m2.roe !== 0.15) throw new Error("Failed to return fallback data after 120 days.");
console.log("PASS: Fundamentals guardrail strictly prevents lookahead bias.");
}
runTest();

View File

@ -0,0 +1,54 @@
import { buildFactorProfiles } from '../descriptiveLens.js';
test('strictly-better stock ranks higher on every factor', async () => {
const good = {
ticker: 'GOOD',
trailingPE: 8,
priceToBook: 1,
priceToSales: 0.5,
freeCashflow: 1e10,
marketCap: 1e11,
returnOnEquity: 0.30,
grossMargins: 0.60,
debtToEquity: 10,
mom_12_1: 0.25,
realizedVol252: 0.15
};
const bad = {
ticker: 'BAD',
trailingPE: 50,
priceToBook: 10,
priceToSales: 12,
freeCashflow: 1e8,
marketCap: 1e11,
returnOnEquity: 0.02,
grossMargins: 0.10,
debtToEquity: 300,
mom_12_1: -0.10,
realizedVol252: 0.60
};
// buildFactorProfiles supports injecting a universe payload directly for testing
const out = await buildFactorProfiles([good, bad]);
// 'out' returns a map { 'GOOD': { ... }, 'BAD': { ... } } during test injection
for (const f of ['value', 'quality', 'lowVol', 'momentum', 'composite']) {
// A strictly better stock MUST have a higher percentile rank
if (out['GOOD'][f] <= out['BAD'][f]) {
throw new Error(`Sign catastrophe: GOOD ranked ${out['GOOD'][f]} on ${f}, but BAD ranked ${out['BAD'][f]}. GOOD must strictly outrank BAD.`);
}
}
console.log("PASS: Orientation logic correctly handles signs. 'Higher percentile' always means 'more favorable'.");
});
// Jest polyfill for simple node execution
function test(name, fn) {
console.log(`Running test: ${name}`);
fn().catch(e => {
console.error(`FAIL: ${e.message}`);
process.exit(1);
});
}

View File

@ -0,0 +1,142 @@
import { getUniverseAsOf } from './universe.js';
import { getFundamentalsAsOf } from './fundamentals.js';
import { standardizeCrossSection, FACTORS } from './factors.js';
import { spearman, calculateTurnover } from './metrics.js';
// Global reference for forward returns in our testing environment
// In production, this would query a price DB
let globalReturnsMap = new Map();
export function setForwardReturnsMap(map) {
globalReturnsMap = map;
}
function getForwardReturn(ticker, dateStr, horizonMonths, allDates) {
// Find the index of the current date
const idx = allDates.indexOf(dateStr);
if (idx === -1 || idx + horizonMonths >= allDates.length) return null; // Not enough forward data
let cumulativeRet = 0;
// Simple sum for log returns, or compound. We'll use simple compound: (1+r1)*(1+r2) - 1
let mult = 1.0;
for (let m = 0; m < horizonMonths; m++) {
const nextDate = allDates[idx + m];
const r = globalReturnsMap.get(`${ticker}_${nextDate}`);
if (r === undefined || r === null) return null; // missing data
mult *= (1 + r);
}
return mult - 1.0;
}
function bucketIntoDeciles(rankedScores) {
const deciles = Array(10).fill(null).map(() => []);
const n = rankedScores.length;
if (n === 0) return deciles;
for (let i = 0; i < n; i++) {
const d = Math.min(9, Math.floor((i / n) * 10));
deciles[d].push(rankedScores[i].ticker);
}
return deciles; // deciles[0] = highest scores (Top Decile), deciles[9] = lowest scores
}
export function runFactor(factorName, allDates, horizonMonths = 1, costPerSideBps = 5) {
const results = [];
let prevD10Weights = null;
let prevD1Weights = null;
const costPct = costPerSideBps / 10000.0;
for (const date of allDates) {
const members = getUniverseAsOf(date);
if (!members || members.length === 0) continue;
const rawScores = {};
for (const t of members) {
const fund = getFundamentalsAsOf(t, date);
if (!fund) continue;
const score = FACTORS[factorName](fund);
if (score !== null && !isNaN(score)) {
rawScores[t] = score;
}
}
// Standardize cross-sectionally
const zScores = standardizeCrossSection(rawScores, false);
// Sort descending (High Score = Best)
const ranked = Object.keys(zScores)
.map(t => ({ ticker: t, score: zScores[t] }))
.sort((a, b) => b.score - a.score);
if (ranked.length < 10) continue; // need enough names for deciles
const deciles = bucketIntoDeciles(ranked);
const d10Tickers = deciles[0]; // Top decile
const d1Tickers = deciles[9]; // Bottom decile
// Calculate equal-weight portfolio weights for this month
const curD10Weights = {};
d10Tickers.forEach(t => curD10Weights[t] = 1.0 / d10Tickers.length);
const curD1Weights = {};
d1Tickers.forEach(t => curD1Weights[t] = 1.0 / d1Tickers.length);
// Calculate turnover
const d10Turnover = calculateTurnover(prevD10Weights, curD10Weights);
const d1Turnover = calculateTurnover(prevD1Weights, curD1Weights);
// Forward returns
const decileReturns = [];
let allFwdReturns = [];
let allZScores = [];
for (let d = 0; d < 10; d++) {
const decTickers = deciles[d];
let sumRet = 0;
let count = 0;
for (const t of decTickers) {
const ret = getForwardReturn(t, date, horizonMonths, allDates);
if (ret !== null) {
sumRet += ret;
count++;
allFwdReturns.push(ret);
allZScores.push(zScores[t]);
}
}
decileReturns.push(count > 0 ? sumRet / count : 0);
}
if (allFwdReturns.length > 0) {
const ic = spearman(allZScores, allFwdReturns);
const universeMeanRet = allFwdReturns.reduce((a, b) => a + b, 0) / allFwdReturns.length;
// Gross Returns
const d10Gross = decileReturns[0];
const d1Gross = decileReturns[9];
// Net Returns
const d10Net = d10Gross - (d10Turnover * costPct);
const d1Net = d1Gross - (d1Turnover * costPct);
results.push({
date,
ic,
decileReturns, // Gross decile returns for the staircase plot
d10Gross,
d1Gross,
d10Net,
d1Net,
universeMeanRet,
d10Turnover,
d1Turnover
});
}
prevD10Weights = curD10Weights;
prevD1Weights = curD1Weights;
}
return results;
}

View File

@ -0,0 +1,228 @@
import { FACTORS, standardizeCrossSection } from './factors.js';
import { pullRawRecord } from './ingest.js';
import { getUniverse } from '../services/stockUniverseService.js';
/**
* Assigns percentiles (0-100) to a raw factor array using the standardizer.
* @param {Array} raw - Array of objects: { ticker, value: 0.5, quality: 1.2 }
* @param {String} factorKey - The factor to assign percentiles for (e.g. 'value')
*/
function assignPercentiles(raw, factorKey) {
const rawByTicker = {};
for (const r of raw) {
if (r[factorKey] !== null && r[factorKey] !== undefined) {
rawByTicker[r.ticker] = r[factorKey];
}
}
// Uses winsorization and z-scoring from factors.js
const zScores = standardizeCrossSection(rawByTicker, false);
// Convert z-scores to percentiles relative to the universe
const validTickers = Object.keys(zScores);
const sortedZScores = validTickers.map(t => zScores[t]).sort((a, b) => a - b);
const n = sortedZScores.length;
for (const r of raw) {
const z = zScores[r.ticker];
if (z === undefined) {
r[`${factorKey}Percentile`] = null;
continue;
}
// Find percentile rank
let index = sortedZScores.findIndex(val => val >= z);
if (index === -1) index = n - 1;
r[`${factorKey}Percentile`] = Math.round((index / n) * 100);
}
}
/**
* Generates the descriptive Factor Profile for the entire universe based on CURRENT data.
* Does NOT generate forward predictions.
*/
let _cachedRawProfiles = null;
let _cachedZScoreMaps = null;
let _cachedRawTime = 0;
export async function buildFactorProfiles(injectedUniverseRecords = null) {
const rawProfiles = [];
// Allow test injection
if (injectedUniverseRecords) {
for (const data of injectedUniverseRecords) {
rawProfiles.push({
ticker: data.ticker || 'TEST',
value: FACTORS.value(data),
quality: FACTORS.quality(data),
lowVol: FACTORS.lowVol(data),
momentum: FACTORS.momentum(data)
});
}
} else {
// We now include ETFs in the factor ranking because we want to see momentum/value on country ETFs
const universe = getUniverse().map(s => s.symbol);
const BATCH_SIZE = 15;
for (let i = 0; i < universe.length; i += BATCH_SIZE) {
const batch = universe.slice(i, i + BATCH_SIZE);
const promises = batch.map(async (ticker) => {
try {
const data = await pullRawRecord(ticker);
if (!data) return null;
return {
ticker,
value: FACTORS.value(data),
quality: FACTORS.quality(data),
lowVol: FACTORS.lowVol(data),
momentum: FACTORS.momentum(data)
};
} catch (e) {
console.warn(`[DescriptiveLens] Failed for ${ticker}:`, e.message);
return null;
}
});
const results = await Promise.allSettled(promises);
for (const res of results) {
if (res.status === 'fulfilled' && res.value) {
rawProfiles.push(res.value);
}
}
// Throttle delay between batches to respect free API limits
if (i + BATCH_SIZE < universe.length) {
await new Promise(r => setTimeout(r, 1000));
}
}
}
// Composite is the equal-weight average of the non-null standardized z-scores
// First, we must standardize the individual factors to compute composite z-score
const factors = ['value', 'quality', 'lowVol', 'momentum'];
const zScoreMaps = {};
for (const f of factors) {
const rawByTicker = {};
for (const r of rawProfiles) {
if (r[f] !== null && r[f] !== undefined) rawByTicker[r.ticker] = r[f];
}
zScoreMaps[f] = standardizeCrossSection(rawByTicker, false);
}
// Compute composite raw score
for (const r of rawProfiles) {
let sum = 0;
let count = 0;
for (const f of factors) {
const z = zScoreMaps[f][r.ticker];
if (z !== undefined) {
sum += z;
count++;
}
}
r.composite = count > 0 ? (sum / count) : null;
}
// Cross-sectional standardization and percentile assignment
[...factors, 'composite'].forEach(f => assignPercentiles(rawProfiles, f));
// Cache the raw profiles for single-stock comparisons
if (!injectedUniverseRecords) {
_cachedRawProfiles = rawProfiles;
_cachedZScoreMaps = zScoreMaps;
_cachedRawTime = Date.now();
}
// If this is a test injection, return the raw profiles with percentiles for testing
if (injectedUniverseRecords) return rawProfiles.reduce((acc, p) => { acc[p.ticker] = p; return acc; }, {});
// Clean up the raw scores, we only return the percentiles for the UI
const cleanedProfiles = rawProfiles.map(p => ({
ticker: p.ticker,
value: p.valuePercentile,
quality: p.qualityPercentile,
lowVol: p.lowVolPercentile,
momentum: p.momentumPercentile,
composite: p.compositePercentile,
_disclaimer: "Descriptive only. Shows where this stock ranks vs. peers on each factor today. We have not validated that these ranks predict returns — and our own testing showed standard technical signals do not. We'll only call a factor predictive after it passes out-of-sample validation."
}));
return cleanedProfiles;
}
/**
* Computes a single stock's factor profile relative to the cached universe.
*/
export async function getSingleFactorProfile(symbol) {
if (!_cachedRawProfiles || (Date.now() - _cachedRawTime > 120000)) {
await buildFactorProfiles();
}
// If it's already in the universe, just return it
const existing = _cachedRawProfiles.find(p => p.ticker === symbol);
if (existing) {
return {
ticker: existing.ticker,
value: existing.valuePercentile,
quality: existing.qualityPercentile,
lowVol: existing.lowVolPercentile,
momentum: existing.momentumPercentile,
composite: existing.compositePercentile,
_disclaimer: "Descriptive only. Shows where this stock ranks vs. the core universe."
};
}
// Fetch out-of-universe data
const data = await pullRawRecord(symbol);
if (!data) return null;
const raw = {
ticker: symbol,
value: FACTORS.value(data),
quality: FACTORS.quality(data),
lowVol: FACTORS.lowVol(data),
momentum: FACTORS.momentum(data)
};
// Standardize single value using universe mean/std from zScoreMaps... wait, standardizer only gives cross-sectional z-scores.
// We can just re-run standardizer with the universe + this one stock.
const tempRaw = [..._cachedRawProfiles, raw];
const factors = ['value', 'quality', 'lowVol', 'momentum'];
const zScoreMaps = {};
for (const f of factors) {
const rawByTicker = {};
for (const r of tempRaw) {
if (r[f] !== null && r[f] !== undefined) rawByTicker[r.ticker] = r[f];
}
zScoreMaps[f] = standardizeCrossSection(rawByTicker, false);
}
let sum = 0;
let count = 0;
for (const f of factors) {
const z = zScoreMaps[f][raw.ticker];
if (z !== undefined) {
sum += z;
count++;
}
}
raw.composite = count > 0 ? (sum / count) : null;
[...factors, 'composite'].forEach(f => assignPercentiles(tempRaw, f));
return {
ticker: raw.ticker,
value: raw.valuePercentile,
quality: raw.qualityPercentile,
lowVol: raw.lowVolPercentile,
momentum: raw.momentumPercentile,
composite: raw.compositePercentile,
_disclaimer: "Descriptive only. Shows where this out-of-universe stock ranks vs. the core universe today."
};
}

View File

@ -0,0 +1,106 @@
function mean(arr) {
const valid = arr.filter(x => x !== null && x !== undefined && !isNaN(x));
if (valid.length === 0) return null;
return valid.reduce((a, b) => a + b, 0) / valid.length;
}
export function orientedComponents(r) {
const inv = x => (x != null && x > 0) ? 1 / x : null; // guard neg/zero multiples
const neg = x => (x != null) ? -x : null;
const div = (a, b) => (a != null && b) ? a / b : null;
return {
value: [ inv(r.trailingPE), div(r.freeCashflow, r.marketCap),
inv(r.priceToBook), inv(r.priceToSales) ],
quality: [ r.returnOnEquity, r.grossMargins, neg(r.debtToEquity) ],
lowVol: [ neg(r.realizedVol252) ],
momentum: [ r.mom_12_1 ],
};
}
export const FACTORS = {
value: r => mean(orientedComponents(r).value),
quality: r => mean(orientedComponents(r).quality),
lowVol: r => mean(orientedComponents(r).lowVol),
momentum: r => mean(orientedComponents(r).momentum),
// composite is an equal-weight average of the standardized scores, computed later
};
function winsorize(arr, trimPercent) {
if (arr.length < 5) return arr; // Don't winsorize tiny arrays
const sorted = [...arr].sort((a, b) => a - b);
const lowerIdx = Math.floor(arr.length * trimPercent);
let upperIdx = Math.floor(arr.length * (1 - trimPercent)) - 1;
if (upperIdx < lowerIdx) upperIdx = arr.length - 1;
const lowerBound = sorted[lowerIdx];
const upperBound = sorted[upperIdx];
return arr.map(v => {
if (v < lowerBound) return lowerBound;
if (v > upperBound) return upperBound;
return v;
});
}
function avg(arr) {
if (!arr.length) return 0;
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
function std(arr, meanVal) {
if (arr.length <= 1) return 1;
const variance = arr.reduce((sum, v) => sum + Math.pow(v - meanVal, 2), 0) / (arr.length - 1);
return Math.sqrt(variance) || 1;
}
export function standardizeCrossSection(rawByTicker, neutralizeSector = false, sectorsByTicker = {}) {
// Extract non-null values
const tickers = Object.keys(rawByTicker);
let validTickers = tickers.filter(t => rawByTicker[t] != null && !isNaN(rawByTicker[t]));
if (validTickers.length === 0) return {};
const result = {};
if (neutralizeSector) {
// Group by sector
const sectorGroups = {};
for (const t of validTickers) {
const sec = sectorsByTicker[t] || 'UNKNOWN';
if (!sectorGroups[sec]) sectorGroups[sec] = [];
sectorGroups[sec].push(t);
}
for (const sec in sectorGroups) {
const secTickers = sectorGroups[sec];
const secVals = secTickers.map(t => rawByTicker[t]);
const w = winsorize(secVals, 0.02);
const m = avg(w);
const s = std(w, m);
for (let i = 0; i < secTickers.length; i++) {
const t = secTickers[i];
let val = rawByTicker[t];
if (val < w[0]) val = w[0]; // approx winsorize clip (since w is same order)
// Actually, winsorize maps index-to-index.
const clipped = w[i];
result[t] = (clipped - m) / s;
}
}
} else {
// Global standard
const vals = validTickers.map(t => rawByTicker[t]);
const w = winsorize(vals, 0.02);
const m = avg(w);
const s = std(w, m);
for (let i = 0; i < validTickers.length; i++) {
const t = validTickers[i];
const clipped = w[i];
result[t] = (clipped - m) / s;
}
}
return result;
}

View File

@ -0,0 +1,53 @@
// In a real setup, this data would be loaded from Sharadar/Compustat.
// For testing, we allow an injected dataset.
let globalFundamentalsBySymbol = new Map();
export function loadFundamentals(data) {
globalFundamentalsBySymbol.clear();
// Group by symbol
for (const record of data) {
if (!globalFundamentalsBySymbol.has(record.symbol)) {
globalFundamentalsBySymbol.set(record.symbol, []);
}
globalFundamentalsBySymbol.get(record.symbol).push(record);
}
// Sort each group by datekey ascending
for (const [sym, records] of globalFundamentalsBySymbol.entries()) {
records.sort((a, b) => new Date(a.datekey) - new Date(b.datekey));
}
}
export function getFundamentalsAsOf(symbol, dateStr) {
const targetDate = new Date(dateStr);
const records = globalFundamentalsBySymbol.get(symbol);
if (!records) return null;
// Find the most recent fundamental report where filing date <= targetDate
let latest = null;
for (const record of records) {
// GUARDRAIL: We absolutely require a filing date (datekey).
// If datekey <= targetDate, it's safe.
// If datekey is missing, fallback to fiscalEnd + 4 months (120 days).
let isAvailable = false;
if (record.datekey) {
if (new Date(record.datekey) <= targetDate) isAvailable = true;
} else if (record.fiscalEnd) {
const safeReleaseDate = new Date(record.fiscalEnd);
safeReleaseDate.setDate(safeReleaseDate.getDate() + 120); // Add 4 months roughly
if (safeReleaseDate <= targetDate) isAvailable = true;
} else {
throw new Error(`Record for ${symbol} lacks both datekey and fiscalEnd. Unsafe.`);
}
if (isAvailable) {
latest = record;
} else {
if (record.datekey && new Date(record.datekey) > targetDate) break;
}
}
return latest ? latest.metrics : null;
}

View File

@ -0,0 +1,98 @@
import { generateSyntheticData } from './synthetic.js';
import { loadUniverse } from './universe.js';
import { loadFundamentals } from './fundamentals.js';
import { setForwardReturnsMap, runFactor } from './crossSection.js';
import { mean, neweyWestStdErr } from './metrics.js';
function printReport(title, results, horizonMonths) {
if (results.length === 0) {
console.log(`\n=== ${title} ===`);
console.log("No valid results.");
return;
}
const ics = results.map(r => r.ic);
const meanIc = mean(ics);
// We use Newey-West standard error for the t-stat if overlapping.
// For 1-month horizon, lags = 0 (simple std err).
// For H-month horizon, lags = H - 1.
const lags = Math.max(0, horizonMonths - 1);
const seIc = neweyWestStdErr(ics, lags);
const tStatIc = meanIc / seIc;
// Average Decile Returns (Gross)
const deciles = Array(10).fill(0);
for (const r of results) {
for (let d = 0; d < 10; d++) {
deciles[d] += r.decileReturns[d];
}
}
for (let d = 0; d < 10; d++) deciles[d] /= results.length;
// Long/Short Spread (Net)
const lsSpreads = results.map(r => r.d10Net - r.d1Net);
const meanLsSpread = mean(lsSpreads);
const seLsSpread = neweyWestStdErr(lsSpreads, lags);
const tStatLs = meanLsSpread / seLsSpread;
const sharpeLs = (meanLsSpread / (seLsSpread * Math.sqrt(results.length))) * Math.sqrt(12 / horizonMonths); // Approx Annualized Gross Sharpe
// Long-Only Top-Decile Excess (Net)
const loExcess = results.map(r => r.d10Net - r.universeMeanRet);
const meanLoExcess = mean(loExcess);
// Consistency
const numPositiveLs = lsSpreads.filter(s => s > 0).length;
const consistencyPct = (numPositiveLs / lsSpreads.length) * 100;
// Turnover (Avg over periods)
const avgTurnoverD10 = mean(results.map(r => r.d10Turnover)) * 100;
console.log(`\n=== ${title} ===`);
console.log(`Periods (N): ${results.length} months`);
console.log(`Horizon: ${horizonMonths} month(s)`);
console.log(`Mean IC: ${meanIc.toFixed(4)}`);
console.log(`IC t-stat: ${tStatIc.toFixed(2)} (Target: >= 3.0)`);
console.log(`L/S Spread (Net): ${(meanLsSpread * 100).toFixed(2)}% per period (t-stat: ${tStatLs.toFixed(2)})`);
console.log(`Long-Only Excess: ${(meanLoExcess * 100).toFixed(2)}% per period`);
console.log(`Consistency: ${consistencyPct.toFixed(1)}% positive periods`);
console.log(`D10 Avg Turnover: ${avgTurnoverD10.toFixed(1)}% per rebalance`);
console.log(`\nDecile Monotonicity (Gross):`);
const maxRet = Math.max(...deciles);
const minRet = Math.min(...deciles);
for (let d = 0; d < 10; d++) {
// simple text bar chart
const pct = deciles[d] * 100;
const barLen = Math.max(1, Math.floor((deciles[d] - minRet) / (maxRet - minRet + 0.0001) * 20));
console.log(` D${d+1}: ${pct.toFixed(2).padStart(5)}% | ${'#'.repeat(barLen)}`);
}
}
function runSyntheticTests() {
console.log("==================================================");
console.log(" SYNTHETIC KNOWN-ANSWER TESTS");
console.log("==================================================");
// Generate 25 years (300 months) of data for 500 stocks
// 1. NULL TEST (IC = 0)
const nullData = generateSyntheticData({ numMonths: 300, numStocks: 500, plantedIC: 0.0 });
loadUniverse(nullData.universe);
loadFundamentals(nullData.fundamentals);
setForwardReturnsMap(nullData.forwardReturnsMap);
const allDatesNull = nullData.universe.map(u => u.date);
const resultsNull = runFactor('value', allDatesNull, 1, 5); // 5 bps costs
printReport("TEST 1: NULL FACTOR (Target: IC ≈ 0, t-stat < 3)", resultsNull, 1);
// 2. PLANTED SIGNAL TEST (IC = 0.05)
const plantedData = generateSyntheticData({ numMonths: 300, numStocks: 500, plantedIC: 0.05 });
loadUniverse(plantedData.universe);
loadFundamentals(plantedData.fundamentals);
setForwardReturnsMap(plantedData.forwardReturnsMap);
const allDatesPlanted = plantedData.universe.map(u => u.date);
const resultsPlanted = runFactor('value', allDatesPlanted, 1, 5); // 5 bps costs
printReport("TEST 2: PLANTED SIGNAL (Target: IC ≈ 0.05, t-stat >= 3, Monotonic)", resultsPlanted, 1);
}
runSyntheticTests();

View File

@ -0,0 +1,75 @@
import { fetchQuoteSummary } from '../services/fundamentalsService.js';
const DEFAULT_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json',
};
async function fetchHistory(symbol) {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${symbol}?range=2y&interval=1d`;
try {
const res = await fetch(url, { headers: DEFAULT_HEADERS });
if (!res.ok) return null;
const data = await res.json();
const result = data?.chart?.result?.[0];
if (!result) return null;
return result.indicators?.quote?.[0]?.close || [];
} catch (e) {
return null;
}
}
function momentum12_1(closes) {
if (!closes || closes.length < 252) return null;
const n = closes.length;
// 12-1 month: close[-21] / close[-252] - 1
const current = closes[n - 21];
const past = closes[n - 252];
if (!current || !past) return null;
return (current / past) - 1;
}
function realizedVol252(closes) {
if (!closes || closes.length < 252) return null;
const n = closes.length;
const returns = [];
for (let i = n - 251; i < n; i++) {
const prev = closes[i - 1];
const cur = closes[i];
if (prev && cur) returns.push((cur - prev) / prev);
}
if (returns.length < 200) return null;
const mean = returns.reduce((a, b) => a + b, 0) / returns.length;
const variance = returns.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / (returns.length - 1);
// Annualize daily vol
return Math.sqrt(variance) * Math.sqrt(252);
}
export async function pullRawRecord(ticker, asOf = new Date().toISOString().split('T')[0]) {
const info = await fetchQuoteSummary(ticker);
if (!info) return null;
const closes = await fetchHistory(ticker);
return {
snapshot_date: asOf,
ticker,
price: info.price,
marketCap: info.market_cap,
// raw inputs
trailingPE: info.pe_ttm,
priceToBook: info.price_to_book,
priceToSales: info.price_to_sales,
freeCashflow: info.free_cash_flow,
returnOnEquity: info.return_on_equity,
grossMargins: info.gross_margins,
debtToEquity: info.debt_to_equity,
mom_12_1: momentum12_1(closes),
realizedVol252: realizedVol252(closes),
fundamentals_period_end: info.fiscal_end
};
}

View File

@ -0,0 +1,84 @@
export function rank(arr) {
const sorted = arr.map((val, ind) => ({ val, ind })).sort((a, b) => a.val - b.val);
const ranks = new Array(arr.length);
for (let i = 0; i < sorted.length; i++) {
ranks[sorted[i].ind] = i + 1;
}
// Handle ties (average rank) if needed, but simple rank is fine for large N
return ranks;
}
export function spearman(x, y) {
if (x.length !== y.length || x.length === 0) return null;
const rankX = rank(x);
const rankY = rank(y);
const n = x.length;
let dSqSum = 0;
for (let i = 0; i < n; i++) {
dSqSum += Math.pow(rankX[i] - rankY[i], 2);
}
return 1 - ((6 * dSqSum) / (n * (Math.pow(n, 2) - 1)));
}
// Simple mean
export function mean(arr) {
if (!arr || arr.length === 0) return 0;
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
// Simple standard deviation
export function std(arr, m) {
if (!arr || arr.length <= 1) return 1;
const variance = arr.reduce((sum, v) => sum + Math.pow(v - m, 2), 0) / (arr.length - 1);
return Math.sqrt(variance) || 1;
}
/**
* Calculate Newey-West standard error for a time-series of means
* Lags = 0 is equivalent to standard error.
* For N-month overlapping returns, lag = N - 1.
*/
export function neweyWestStdErr(ts, lags) {
const n = ts.length;
if (n <= 1) return 1;
const m = mean(ts);
// Variance
let s0 = 0;
for (let i = 0; i < n; i++) {
s0 += Math.pow(ts[i] - m, 2);
}
s0 = s0 / n;
// Covariances
let sLags = 0;
for (let l = 1; l <= lags; l++) {
let cov = 0;
for (let i = l; i < n; i++) {
cov += (ts[i] - m) * (ts[i - l] - m);
}
cov = cov / n;
const weight = 1 - (l / (lags + 1));
sLags += 2 * weight * cov;
}
const S = s0 + sLags;
return Math.sqrt(S / n) || (std(ts, m) / Math.sqrt(n)); // fallback
}
/**
* Compute turnover given two sets of weights.
* w1, w2 are objects: { 'AAPL': 0.05, 'MSFT': 0.02, ... }
* turnover = 0.5 * sum(|w2_i - w1_i|)
*/
export function calculateTurnover(wOld, wNew) {
if (!wOld) return 1.0; // 100% turnover on first month
let turnover = 0;
const allTickers = new Set([...Object.keys(wOld), ...Object.keys(wNew)]);
for (const t of allTickers) {
const oldWt = wOld[t] || 0;
const newWt = wNew[t] || 0;
turnover += Math.abs(newWt - oldWt);
}
return 0.5 * turnover;
}

View File

@ -0,0 +1,56 @@
import fs from 'fs';
import path from 'path';
import { pullRawRecord } from './ingest.js';
import { getUniverse } from '../services/stockUniverseService.js';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SNAPSHOT_FILE = path.join(__dirname, '../../../data/snapshots/fundamentals_snapshot.jsonl');
/**
* Monthly cron script to snapshot current point-in-time fundamentals.
* Append-only. Immutable.
*/
export async function runSnapshot() {
const snapshot_date = new Date().toISOString().split('T')[0];
console.log(`Starting PIT Snapshot for ${snapshot_date}`);
// Ensure directory exists
const dir = path.dirname(SNAPSHOT_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const universe = getUniverse();
let successCount = 0;
for (const stock of universe) {
if (stock.isETF) continue; // Skip ETFs for fundamental factors
const symbol = stock.symbol;
try {
const data = await pullRawRecord(symbol, snapshot_date);
if (!data) {
console.warn(`[SNAPSHOT] No data for ${symbol}`);
continue;
}
// Add index membership flag to the raw record
data.in_universe = true;
// Append-only rule: We never overwrite.
fs.appendFileSync(SNAPSHOT_FILE, JSON.stringify(data) + '\n');
successCount++;
} catch (e) {
console.error(`[SNAPSHOT] Failed for ${symbol}: ${e.message}`);
}
}
console.log(`Snapshot complete. Safely appended ${successCount} records to ${SNAPSHOT_FILE}.`);
}
// Allow running directly
if (process.argv[1] === __filename) {
runSnapshot().catch(console.error);
}

View File

@ -0,0 +1,88 @@
/**
* synthetic.js
* Generates synthetic datasets for Known-Answer tests.
*/
export function generateSyntheticData({ numMonths, numStocks, plantedIC = 0.0 }) {
const universe = [];
const fundamentals = [];
const forwardReturnsMap = new Map(); // key: `${symbol}_${date}`, value: 1mo forward return
// Base date
const startDate = new Date('2000-01-31');
for (let m = 0; m < numMonths; m++) {
const d = new Date(startDate);
d.setMonth(d.getMonth() + m);
// Approximate end of month
d.setDate(0);
const dateStr = d.toISOString().split('T')[0];
const symbols = [];
for (let s = 1; s <= numStocks; s++) {
const sym = `SYM${s}`;
symbols.push(sym);
// Generate a base signal (standard normal approx)
const u1 = Math.random(), u2 = Math.random();
const zScore = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);
// We will define a "value" factor that is just this zScore.
// For the null test, plantedIC = 0.
// For planted signal, plantedIC = 0.05.
// forwardReturn = plantedIC * zScore + noise
const noise_u1 = Math.random(), noise_u2 = Math.random();
const noise = Math.sqrt(-2.0 * Math.log(noise_u1)) * Math.cos(2.0 * Math.PI * noise_u2);
// Adjust noise variance so that correlation between zScore and fwdRet is roughly plantedIC
// Var(fwd) = IC^2 * Var(Z) + Var(noise)
// Since Var(Z)=1, if we want Cor(Z, fwd) = IC:
// Cor = Cov(Z, IC*Z + noise) / sqrt(1 * (IC^2 + Var(noise)))
// Cor = IC / sqrt(IC^2 + Var(noise))
// To get Cor = IC, we need sqrt(IC^2 + Var(noise)) = 1 => Var(noise) = 1 - IC^2
const noiseScale = Math.sqrt(Math.max(0, 1 - plantedIC * plantedIC));
const fwdRet = plantedIC * zScore + noiseScale * noise;
// Scale fwdRet to realistic monthly return (e.g. mean 0.5%, vol 5%)
const monthlyReturn = 0.005 + 0.05 * fwdRet;
forwardReturnsMap.set(`${sym}_${dateStr}`, monthlyReturn);
// Record fundamental.
// value factor = mean([ earningsYield, fcfYield, bookToPrice, salesToPrice ])
// We'll just set earningsYield = zScore, and others to 0 to keep it simple.
// So mean is zScore / 4.
// To make the actual score == zScore, we'll set all 4 to zScore.
fundamentals.push({
symbol: sym,
datekey: dateStr, // assume filed on the exact rebalance date for testing
fiscalEnd: dateStr,
metrics: {
earningsYield: zScore,
fcfYield: zScore,
bookToPrice: zScore,
salesToPrice: zScore,
// quality, lowVol, momentum just random noise
roe: Math.random(),
grossMargin: Math.random(),
accruals: Math.random(),
debtToEquity: Math.random()
}
});
}
universe.push({
date: dateStr,
symbols: symbols
});
}
return {
universe,
fundamentals,
forwardReturnsMap
};
}

View File

@ -0,0 +1,22 @@
// In a real setup, this data would be loaded from Sharadar/Compustat.
// For testing, we allow an injected dataset.
let globalUniverseData = []; // Array of { date, symbols: [ 'AAPL', 'MSFT', ... ] }
export function loadUniverse(data) {
globalUniverseData = [...data].sort((a, b) => new Date(a.date) - new Date(b.date));
}
export function getUniverseAsOf(dateStr) {
// Find the most recent universe membership <= dateStr
const targetDate = new Date(dateStr);
let latest = null;
for (const record of globalUniverseData) {
if (new Date(record.date) <= targetDate) {
latest = record;
} else {
break;
}
}
return latest ? latest.symbols : [];
}

View File

@ -3,16 +3,25 @@ import helmet from 'helmet';
import cors from 'cors';
export const setupSecurity = (app) => {
// Trust proxy for express-rate-limit behind Kubernetes Ingress
app.set('trust proxy', 1);
// Helmet for security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://static.cloudflareinsights.com'],
scriptSrcElem: ["'self'", "'unsafe-inline'", 'https://static.cloudflareinsights.com'],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", "ws:", "wss:", "http:", "https:"],
workerSrc: ["'self'", 'blob:'],
fontSrc: ["'self'", 'https:', 'data:'],
},
},
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
// CORS
@ -39,8 +48,15 @@ export const setupSecurity = (app) => {
}
}
// Default fallback (shouldn't reach here if CORS_ORIGIN is set or in dev mode)
callback(new Error('Not allowed by CORS'));
// Default fallback: allow if it matches our expected production domains
// or if we just want to be permissive when CORS_ORIGIN isn't configured
if (origin.includes('market.applaude.net') || origin.includes('localhost')) {
return callback(null, true);
}
// If none matched, we still don't want to throw a 500 for static assets.
// We'll allow it but CORS headers might not be optimal for true cross-origin.
callback(null, true);
}
};

View File

@ -293,8 +293,8 @@ router.get('/recent', async (req, res) => {
});
// WebSocket for real-time alerts
export function setupAlertsWebSocket(server) {
const wss = new WebSocketServer({ server, path: '/ws/alerts' });
export function setupAlertsWebSocket() {
const wss = new WebSocketServer({ noServer: true });
wss.on('connection', (ws) => {
console.log('Client connected to alerts feed');

View File

@ -102,7 +102,8 @@ router.get('/', async (req, res) => {
};
}
const statusCode = health.status === 'ok' ? 200 : 503;
// Only return 503 if the server itself is broken — degraded means optional services are down
const statusCode = health.status === 'error' ? 503 : 200;
res.status(statusCode).json(health);
});

View File

@ -7,12 +7,15 @@
import express from 'express';
import NodeCache from 'node-cache';
import { UNIVERSE, getSymbolMeta, classifyCapTier } from '../services/stockUniverseService.js';
import { fetchBasicQuote, fetchQuoteSummary, fetchTechnicals, fetchFullAnalysis } from '../services/fundamentalsService.js';
import { fetchBasicQuote, fetchQuoteSummary, fetchTechnicals, fetchFullAnalysis, fetchMacroPerformance } from '../services/fundamentalsService.js';
import { fetchInstitutionalHolders, fetchInsiderTransactions } from '../services/institutionalHoldersService.js';
import { fetchNewsForSymbol, fetchMarketNews } from '../services/newsService.js';
import { scoreSuitability } from '../services/scoringEngine.js';
import { classifyRegime } from '../services/regimeService.js';
import { logSignals } from '../services/signalLogger.js';
const router = express.Router();
const universeCache = new NodeCache({ stdTTL: 60 });
// Default cache TTL to 5 minutes (300s) to protect against rate limits on large universe
const universeCache = new NodeCache({ stdTTL: 300 });
/**
* GET /api/market/universe
@ -49,6 +52,8 @@ router.get('/universe', async (req, res) => {
change_pct: quote?.change_pct ?? null,
market_cap: quote?.market_cap ?? null,
volume: quote?.volume ?? null,
day_high: quote?.high ?? null,
day_low: quote?.low ?? null,
high_52w: null, // populated by quoteSummary if needed
low_52w: null,
market_state: quote?.market_state ?? null,
@ -100,12 +105,15 @@ router.get('/stock/:symbol', async (req, res) => {
const meta = getSymbolMeta(symbol);
const analysis = await fetchFullAnalysis(symbol);
const profile = await getSingleFactorProfile(symbol);
res.json({
success: true,
data: {
...analysis,
meta,
capTier: classifyCapTier(analysis.quote?.market_cap ?? null, meta?.isETF ?? false),
profile,
}
});
} catch (err) {
@ -212,4 +220,113 @@ router.get('/leaderboard', async (req, res) => {
}
});
import { buildFactorProfiles, getSingleFactorProfile } from '../factorlab/descriptiveLens.js';
/**
* GET /api/market/screener
* Returns the descriptive factor profiles for the entire universe.
* Strictly descriptive. No predictive claims.
*/
router.get('/screener', async (req, res) => {
try {
const cacheKey = 'screener';
const cached = universeCache.get(cacheKey);
if (cached) return res.json({ success: true, data: cached, cached: true });
// Fetch the descriptive factor profiles
const profiles = await buildFactorProfiles();
const data = {
profiles: profiles,
updatedAt: new Date().toISOString(),
};
universeCache.set(cacheKey, data, 300); // 300s cache for expanded universe
res.json({ success: true, data });
} catch (err) {
console.error('[market/screener]', err);
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/market/search/:symbol
* Search ANY ticker (not just universe). Returns full analysis + suitability.
*/
router.get('/search/:symbol', async (req, res) => {
try {
const symbol = req.params.symbol.toUpperCase().trim();
const meta = getSymbolMeta(symbol); // may be null for out-of-universe tickers
const analysis = await fetchFullAnalysis(symbol);
if (!analysis.quote) {
return res.status(404).json({ success: false, error: `No data found for symbol ${symbol}` });
}
const suitability = scoreSuitability({
quote: analysis.quote,
technicals: analysis.technicals,
fundamentals: analysis.fundamentals,
regime: { trend: 'neutral', vol: 'normal', breadth: 'mixed', raw: {} },
dataAgeSec: 15
});
const capTier = classifyCapTier(
analysis.quote?.market_cap ?? analysis.fundamentals?.market_cap ?? null,
meta?.isETF ?? false
);
const profile = await getSingleFactorProfile(symbol);
res.json({
success: true,
data: { ...analysis, meta, capTier, suitability, profile },
});
} catch (err) {
console.error(`[market/search/${req.params.symbol}]`, err);
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /api/market/macro
* Global Macro Indicators (Descriptive Context)
*/
router.get('/macro', async (req, res) => {
try {
const cacheKey = 'macro_indicators';
const cached = universeCache.get(cacheKey);
if (cached) return res.json({ success: true, data: cached });
const MACRO_SYMBOLS = [
{ symbol: '^VIX', name: 'Volatility (VIX)' },
{ symbol: 'DX-Y.NYB', name: 'US Dollar (DXY)' },
{ symbol: '^TNX', name: '10-Yr Yield' },
{ symbol: 'GC=F', name: 'Gold' },
{ symbol: 'SI=F', name: 'Silver' },
{ symbol: 'EWJ', name: 'Japan (EWJ)' },
{ symbol: 'FXI', name: 'China (FXI)' },
{ symbol: 'INDA', name: 'India (INDA)' },
{ symbol: 'VGK', name: 'Europe (VGK)' },
{ symbol: 'BTC-USD', name: 'Bitcoin' },
];
const results = await Promise.allSettled(
MACRO_SYMBOLS.map(async (entry) => {
const q = await fetchMacroPerformance(entry.symbol);
return q ? { symbol: entry.symbol, name: entry.name, ...q } : null;
})
);
const data = results
.filter(r => r.status === 'fulfilled' && r.value)
.map(r => r.value);
universeCache.set(cacheKey, data, 60); // Cache for 60 seconds
res.json({ success: true, data, updatedAt: new Date().toISOString() });
} catch (err) {
console.error('[market/macro]', err);
res.status(500).json({ success: false, error: err.message });
}
});
export default router;

View File

@ -31,7 +31,8 @@ router.get('/detect', async (req, res) => {
});
}
const reversals = await detectReversals(symbolList);
const returnAll = !!symbols;
const reversals = await detectReversals(symbolList, returnAll);
res.json({
success: true,

View File

@ -2,8 +2,8 @@ import { WebSocketServer } from 'ws';
import { rawQuery } from '../db.js';
import { optionsFlowQuery } from '../queries/optionsFlowQuery.js';
export function setupWebSocket(server) {
const wss = new WebSocketServer({ server, path: '/ws/flow' });
export function setupWebSocket() {
const wss = new WebSocketServer({ noServer: true });
wss.on('connection', (ws) => {
console.log('Client connected to WebSocket');

View File

@ -46,6 +46,17 @@ setupSecurity(app);
// Body parsing
app.use(express.json());
// Serve static frontend files in production
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const frontendPath = path.join(__dirname, '../../public');
// Static files MUST come before the wildcard catch-all
app.use(express.static(frontendPath, {
maxAge: '1d',
etag: true,
}));
// Routes
app.use('/api/options', optionsFlowRouter);
app.use('/api/analysis', dailyAnalysisRouter);
@ -61,19 +72,15 @@ app.use('/api/market', marketAnalysisRouter);
app.use('/api/backtest', backtestRouter);
app.use('/health', healthRouter);
// Serve static frontend files in production
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const frontendPath = path.join(__dirname, '../../public');
app.use(express.static(frontendPath));
// Fallback all other routes to React's index.html
// SPA fallback — only for non-API, non-asset routes
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/health')) {
if (req.path.startsWith('/api/') || req.path.startsWith('/health') || req.path.startsWith('/assets/')) {
return next();
}
res.sendFile(path.join(frontendPath, 'index.html'));
const indexPath = path.join(frontendPath, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) next(err);
});
});
// Error handler (must be last)
@ -120,9 +127,25 @@ const server = app.listen(PORT, async () => {
}
});
// Setup WebSocket
setupWebSocket(server);
setupAlertsWebSocket(server);
// Setup WebSockets manually to handle multiple paths
const flowWss = setupWebSocket();
const alertsWss = setupAlertsWebSocket();
server.on('upgrade', (request, socket, head) => {
const pathname = request.url.split('?')[0];
if (pathname === '/ws/flow') {
flowWss.handleUpgrade(request, socket, head, (ws) => {
flowWss.emit('connection', ws, request);
});
} else if (pathname === '/ws/alerts') {
alertsWss.handleUpgrade(request, socket, head, (ws) => {
alertsWss.emit('connection', ws, request);
});
} else {
socket.destroy();
}
});
// Graceful shutdown
process.on('SIGTERM', () => {

View File

@ -303,20 +303,26 @@ ${yahooData ? `STOCK DATA (Yahoo Finance):
- Volume: ${yahooData.volume?.toLocaleString() || 'N/A'}
` : ''}
Provide a concise analysis (2-3 sentences max) with:
1. Overall assessment (Bullish/Bearish/Neutral)
2. Key risk factors or opportunities
3. Clear action recommendation (Enter/Wait/Avoid)
Provide a detailed analysis categorized into three distinct trader profiles based on the data.
Format as JSON:
{
"assessment": "BULLISH|BEARISH|NEUTRAL",
"summary": "Brief 1-2 sentence summary",
"keyFactors": ["factor1", "factor2", "factor3"],
"recommendation": "ENTER|WAIT|AVOID",
"reasoning": "Brief explanation",
"riskLevel": "LOW|MEDIUM|HIGH",
"timeHorizon": "INTRADAY|SWING|POSITION"
"longTerm": {
"assessment": "BULLISH|BEARISH|NEUTRAL",
"moatAndFundamentals": "Brief analysis of economic moat, EPS growth, and fundamental ratios (P/E, P/B)",
"recommendation": "ENTER|WAIT|AVOID"
},
"swing": {
"assessment": "BULLISH|BEARISH|NEUTRAL",
"technicalSetup": "Analysis of technical setups, consolidation, moving averages, and RSI momentum",
"recommendation": "ENTER|WAIT|AVOID"
},
"dayTrade": {
"assessment": "BULLISH|BEARISH|NEUTRAL",
"volatilityAndLiquidity": "Analysis of immediate catalysts, intraday liquidity/volume, and volatility risks",
"recommendation": "ENTER|WAIT|AVOID"
},
"summary": "Overall 1-2 sentence conclusion covering the most viable approach"
}`;
// Use model from env or default to stable version
@ -346,13 +352,22 @@ Format as JSON:
// Fallback: create structured response from text
console.warn('Failed to parse AI response as JSON, using fallback');
analysis = {
assessment: direction,
summary: responseText.substring(0, 200),
keyFactors: [],
recommendation: score >= 2.0 ? 'ENTER' : 'WAIT',
reasoning: responseText,
riskLevel: score < 2.0 ? 'HIGH' : score < 5.0 ? 'MEDIUM' : 'LOW',
timeHorizon: 'INTRADAY'
longTerm: {
assessment: direction,
moatAndFundamentals: 'Unable to perform deep fundamental analysis. Please review ratios manually.',
recommendation: score >= 5.0 ? 'ENTER' : 'WAIT'
},
swing: {
assessment: direction,
technicalSetup: 'Technical analysis currently unavailable.',
recommendation: score >= 3.0 ? 'ENTER' : 'WAIT'
},
dayTrade: {
assessment: direction,
volatilityAndLiquidity: 'Analysis of intraday volume and catalysts unavailable.',
recommendation: score >= 2.0 ? 'ENTER' : 'WAIT'
},
summary: responseText.substring(0, 200) || 'Analysis unavailable'
};
}
@ -369,13 +384,10 @@ Format as JSON:
success: false,
error: error.message,
analysis: {
assessment: 'NEUTRAL',
summary: 'Analysis unavailable',
keyFactors: [],
recommendation: 'WAIT',
reasoning: 'Unable to generate analysis',
riskLevel: 'MEDIUM',
timeHorizon: 'INTRADAY'
longTerm: { assessment: 'NEUTRAL', moatAndFundamentals: 'Analysis unavailable', recommendation: 'WAIT' },
swing: { assessment: 'NEUTRAL', technicalSetup: 'Analysis unavailable', recommendation: 'WAIT' },
dayTrade: { assessment: 'NEUTRAL', volatilityAndLiquidity: 'Analysis unavailable', recommendation: 'WAIT' },
summary: 'Analysis unavailable'
}
};
}

View File

@ -0,0 +1,76 @@
/**
* Feature Service
* Extracts raw mathematical features from raw data to feed the scoring engine.
*/
/**
* Calculate ATR as a percentage of the last close price.
* ATR% = the real intraday "is this tradeable?" metric.
*
* @param {Array} candles - Array of candle objects {high, low, close}, most recent last.
* @param {number} period - Period for ATR calculation (default: 14)
* @returns {number|null} ATR as a percentage (e.g. 5.8 means 5.8% average range)
*/
export function atrPercent(candles, period = 14) {
if (!candles || candles.length <= period) return null;
const trs = [];
for (let i = 1; i < candles.length; i++) {
const { high, low } = candles[i];
const prevClose = candles[i - 1].close;
// True Range
trs.push(Math.max(
high - low,
Math.abs(high - prevClose),
Math.abs(low - prevClose)
));
}
// Simple moving average of TRs for the last `period` days
const recent = trs.slice(-period);
const atr = recent.reduce((a, b) => a + b, 0) / recent.length;
const lastClose = candles[candles.length - 1].close;
if (!lastClose) return null;
return (atr / lastClose) * 100;
}
/**
* Extract Estimate Revisions (Up vs Down) direction.
* If quoteSummary contains earningsTrend (from Yahoo Finance).
*
* @param {Object} rawData - The raw data payload from Yahoo Finance
* @returns {number} Score representing net revisions: positive = more up than down
*/
export function estimateRevisionsNet(rawData) {
// If we don't have the raw earningsTrend array, return 0 (neutral)
const trends = rawData?.quoteSummary?.result?.[0]?.earningsTrend?.trend;
if (!trends || !Array.isArray(trends)) return 0;
// We want to look at the current quarter (0q) or current year (0y)
// Usually the first two elements are current qtr and next qtr
let netUp = 0;
for (const t of trends.slice(0, 2)) {
const up = t?.earningsEstimate?.upLast30days?.raw || 0;
const down = t?.earningsEstimate?.downLast30days?.raw || 0;
netUp += (up - down);
}
return netUp;
}
/**
* Calculates Relative Volume
* @param {number} volume - Today's volume
* @param {number} avgVolume10d - 10-day average volume
* @param {number} avgVolume3m - 3-month average volume
*/
export function calculateRvol(volume, avgVolume10d, avgVolume3m) {
if (!volume) return null;
if (avgVolume10d) return volume / avgVolume10d;
if (avgVolume3m) return volume / avgVolume3m;
return null;
}

View File

@ -81,6 +81,51 @@ export async function fetchBasicQuote(symbol) {
}
}
/**
* Fetch macro performance (1D, 1W, 1M, 1Y)
*/
export async function fetchMacroPerformance(symbol) {
const cacheKey = `macro_perf_${symbol}`;
const cached = fundamentalsCache.get(cacheKey);
if (cached) return cached;
const url = `${YF_BASE}/v8/finance/chart/${symbol}?interval=1d&range=1y`;
try {
const data = await yfFetchAuth(url);
const result = data?.chart?.result?.[0];
if (!result) return null;
const closes = result.indicators?.quote?.[0]?.close?.filter(v => v != null) || [];
if (closes.length === 0) return null;
const currentPrice = closes[closes.length - 1];
// Calculate returns safely based on available data length
const getRet = (daysBack) => {
if (closes.length <= daysBack) return null;
const oldPrice = closes[closes.length - 1 - daysBack];
if (!oldPrice) return null;
return ((currentPrice - oldPrice) / oldPrice) * 100;
};
const q = {
symbol: symbol.toUpperCase(),
price: currentPrice,
change_pct: getRet(1),
change_1w: getRet(5),
change_1m: getRet(21),
change_1y: getRet(closes.length - 1),
};
fundamentalsCache.set(cacheKey, q);
return q;
} catch (err) {
console.warn(`[fundamentalsService] macro perf failed for ${symbol}:`, err.message);
return null;
}
}
/**
* Fetch full fundamentals via quoteSummary v10
*/
@ -186,18 +231,34 @@ export async function fetchTechnicals(symbol) {
const closes = result.indicators?.quote?.[0]?.close?.filter(v => v != null) || [];
if (closes.length < 26) return null;
// Extract candles for ATR
const quote = result.indicators?.quote?.[0] || {};
const candles = [];
for (let i = 0; i < closes.length; i++) {
if (quote.high?.[i] != null && quote.low?.[i] != null && quote.close?.[i] != null) {
candles.push({
high: quote.high[i],
low: quote.low[i],
close: quote.close[i]
});
}
}
const rsi = calculateRSI(closes, 14);
const macd = calculateMACD(closes, 12, 26, 9);
const sma20 = closes.length >= 20 ? avg(closes.slice(-20)) : null;
const sma50 = closes.length >= 50 ? avg(closes.slice(-50)) : null;
const sma200 = closes.length >= 200 ? avg(closes.slice(-200)) : null;
const currentPrice = closes[closes.length - 1];
const result_data = {
candles, // Pass candles to featureService
rsi_14: rsi !== null ? Math.round(rsi * 100) / 100 : null,
macd_line: macd?.line ?? null,
macd_signal: macd?.signal ?? null,
macd_histogram: macd?.histogram ?? null,
sma_20: sma20 ? Math.round(sma20 * 100) / 100 : null,
sma_50: sma50 ? Math.round(sma50 * 100) / 100 : null,
sma_200: sma200 ? Math.round(sma200 * 100) / 100 : null,
above_sma50: sma50 ? currentPrice > sma50 : null,

View File

@ -0,0 +1,47 @@
/**
* Regime Service
* Classifies the broad market state (fast, mechanical).
*/
/**
* Classify Market Regime based on SPY, VIX, and Breadth
* @param {Object} spyTechnicals - Technical data for SPY (needs close, sma_20, sma_50)
* @param {number} vixClose - Latest close price of ^VIX
* @param {number} breadthPctAbove50 - Percentage of universe stocks above their 50 SMA
* @returns {Object} Regime classification
*/
export function classifyRegime(spyTechnicals, vixClose, breadthPctAbove50) {
if (!spyTechnicals || vixClose == null || breadthPctAbove50 == null) {
return { trend: 'neutral', vol: 'normal_vol', breadth: 'mixed', raw: {} };
}
// If we have candles, get the latest close. Otherwise fallback to current price if passed differently.
const close = spyTechnicals.candles ? spyTechnicals.candles[spyTechnicals.candles.length - 1].close : 0;
const sma20 = spyTechnicals.sma_20;
const sma50 = spyTechnicals.sma_50;
// Trend logic: mechanical
const trend = (close > sma50 && sma20 > sma50) ? 'risk_on'
: (close < sma50 && sma20 < sma50) ? 'risk_off'
: 'neutral';
// Volatility logic (VIX)
const vol = vixClose > 25 ? 'high_vol' : vixClose < 15 ? 'low_vol' : 'normal_vol';
// Breadth logic
const breadth = breadthPctAbove50 > 60 ? 'broad'
: breadthPctAbove50 < 40 ? 'narrow' : 'mixed';
return {
trend,
vol,
breadth,
raw: {
spy_close: close,
spy_sma20: sma20,
spy_sma50: sma50,
vix: vixClose,
breadthPctAbove50
}
};
}

View File

@ -6,7 +6,7 @@ import { calculateBadges } from './badgeCalculator.js';
* Compares current badges vs 15min ago vs 30min ago
*/
export async function detectReversals(symbolList) {
export async function detectReversals(symbolList, returnAll = false) {
const reversals = [];
for (const symbol of symbolList) {
@ -63,10 +63,25 @@ export async function detectReversals(symbolList) {
currentPremium: parseFloat(current.premium_num || 0),
previousPremium: parseFloat(prev15min.premium_num || 0),
priceChange,
signal: currentBadge === '🟢' ? 'REVERSAL_BULLISH' : 'REVERSAL_BEARISH'
signal: currentBadge === '🟢' ? 'REVERSAL_BULLISH' : 'REVERSAL_BEARISH',
isReversal: true
};
reversals.push(reversal);
} else if (returnAll && current) {
// Return current status even if no reversal
const currentBadge = current.badges?.round || '⚪';
reversals.push({
symbol: symbol.toUpperCase(),
from: currentBadge,
to: currentBadge,
timestamp: new Date(current.timestamp).toISOString(),
currentPremium: parseFloat(current.premium_num || 0),
previousPremium: 0,
priceChange: '0%',
signal: currentBadge === '🟢' ? 'BULLISH' : currentBadge === '🔴' ? 'BEARISH' : 'NEUTRAL',
isReversal: false
});
}
}

View File

@ -1,3 +1,197 @@
import { atrPercent, estimateRevisionsNet, calculateRvol } from './featureService.js';
/**
* Scoring Engine
* Generates testable signals.
*/
function gradeLabel(score) {
if (score >= 80) return { label: 'A', color: 'emerald' };
if (score >= 60) return { label: 'B', color: 'blue' };
if (score >= 40) return { label: 'C', color: 'yellow' };
if (score >= 20) return { label: 'D', color: 'orange' };
return { label: 'F', color: 'red' };
}
/**
* Score a single stock entry
* @returns {Object} { daytrade, shortTerm, longTerm } where each is { score, grade, drivers, dataAgeSec, stale, ... }
*/
export function scoreSuitability({ quote, technicals, fundamentals, regime, dataAgeSec }) {
const q = quote || {};
const t = technicals || {};
const f = fundamentals || {};
const stale = dataAgeSec > 90; // stale if older than 90s
// Compute raw features
const rvol = calculateRvol(q.volume, f.avg_volume_10d, f.avg_volume_3m);
const atrPct = t.candles ? atrPercent(t.candles, 14) : null;
const revisionsNet = estimateRevisionsNet(f);
const rsi = t.rsi_14 ?? null;
const macdH = t.macd_histogram ?? null;
const pctSma50 = t.pct_from_sma50 ?? null;
const above200 = t.above_sma200 ?? null;
const pctSma200 = t.pct_from_sma200 ?? null;
const eg = f.earnings_growth ?? null;
const pm = f.profit_margins ?? null;
// ─── Daytrade Score (Logistic Regression) ───────────────────────────────────
// Weights from Phase 2 training (features: rsi/100, atrPct/10, rvol/5, macd/2, regime)
const dtParts = [];
// Safe values for dot product
const safeRsi = rsi ?? 50;
const safeAtr = atrPct ?? 1.0;
const safeRvol = rvol ?? 1.0;
const safeMacd = macdH ?? 0.0;
const regimeFlag = regime?.trend === 'risk_on' ? 1 : 0;
// Log-odds calculation (z)
const intercept = -84.54;
const wRsi = -50.36 * (safeRsi / 100);
const wAtr = 13.90 * (safeAtr / 10);
const wRvol = 37.27 * (safeRvol / 5);
const wMacd = -395.0 * (safeMacd / 2);
const wRegime = 41.80 * regimeFlag;
const z = intercept + wRsi + wAtr + wRvol + wMacd + wRegime;
// Sigmoid
const pWin = 1 / (1 + Math.exp(-z));
// We use P(win) * 100 as the "score" for sorting, but we'll export it cleanly
const dtScore = Math.round(pWin * 100);
// Expose feature importance in the drivers for the UI
dtParts.push({ key: 'rsi', label: 'RSI Impact', value: safeRsi, points: Math.round(wRsi), desc: `Oversold effect (w: ${wRsi.toFixed(1)})` });
dtParts.push({ key: 'atrPct', label: 'Volatility Impact', value: safeAtr, points: Math.round(wAtr), desc: `ATR push (w: ${wAtr.toFixed(1)})` });
dtParts.push({ key: 'rvol', label: 'Volume Impact', value: safeRvol, points: Math.round(wRvol), desc: `Relative Vol (w: ${wRvol.toFixed(1)})` });
dtParts.push({ key: 'macd', label: 'Trend Impact', value: safeMacd, points: Math.round(wMacd), desc: `MACD (w: ${wMacd.toFixed(1)})` });
if (regimeFlag) dtParts.push({ key: 'regime', label: 'Regime Impact', value: 1, points: Math.round(wRegime), desc: `Risk-On Boost` });
// ─── Short Term (Swing) Score ─────────────────────────────────────────────
const stParts = [];
let stMacdPts = 0, stMacdLbl = 'MACD neutral';
if (macdH != null) {
if (macdH > 0.5) { stMacdPts = 30; stMacdLbl = `Strong MACD bullish`; }
else if (macdH > 0) { stMacdPts = 22; stMacdLbl = `MACD bullish`; }
else if (macdH > -0.2) { stMacdPts = 10; stMacdLbl = `MACD near crossover`; }
}
stParts.push({ key: 'macd', label: 'MACD setup', value: macdH, points: stMacdPts, desc: stMacdLbl });
let stRsiPts = 0, stRsiLbl = 'RSI extended';
if (rsi != null) {
if (rsi >= 40 && rsi <= 60) { stRsiPts = 25; stRsiLbl = `RSI ${rsi.toFixed(0)} (swing zone)`; }
else if (rsi >= 30 && rsi < 40) { stRsiPts = 20; stRsiLbl = `RSI ${rsi.toFixed(0)} (reset/dip)`; }
else if (rsi > 60 && rsi <= 70) { stRsiPts = 15; stRsiLbl = `RSI ${rsi.toFixed(0)} (momentum)`; }
else if (rsi <= 30) { stRsiPts = 10; stRsiLbl = `RSI oversold`; }
else { stRsiPts = 2; }
}
stParts.push({ key: 'rsi', label: 'RSI sweet spot', value: rsi, points: stRsiPts, desc: stRsiLbl });
let stSmaPts = 0, stSmaLbl = 'Extended from SMA50';
if (pctSma50 != null) {
const abs = Math.abs(pctSma50);
if (abs <= 3) { stSmaPts = 25; stSmaLbl = `Near SMA50 (${pctSma50.toFixed(1)}%) - coiled`; }
else if (abs <= 7) { stSmaPts = 18; stSmaLbl = `${pctSma50 > 0 ? 'Above' : 'Below'} SMA50 by ${abs.toFixed(1)}%`; }
else if (abs <= 15) { stSmaPts = 8; stSmaLbl = `${abs.toFixed(1)}% from SMA50`; }
}
stParts.push({ key: 'dist50', label: 'SMA50 proximity', value: pctSma50, points: stSmaPts, desc: stSmaLbl });
// Revisions (Replacing Analyst Targets)
let revPts = 0, revLbl = 'Revisions neutral';
if (revisionsNet > 0) {
if (revisionsNet >= 5) { revPts = 20; revLbl = `Strong upward revisions`; }
else if (revisionsNet >= 2) { revPts = 14; revLbl = `Net positive revisions`; }
else { revPts = 8; revLbl = `Slight upward revisions`; }
} else if (revisionsNet < 0) {
revLbl = `Negative revisions`;
}
stParts.push({ key: 'revisions', label: 'Estimate revisions', value: revisionsNet, points: revPts, desc: revLbl });
const stScore = Math.min(100, Math.round(stParts.reduce((s, p) => s + p.points, 0)));
// ─── Long Term Score ──────────────────────────────────────────────────────
const ltParts = [];
let ltSmaPts = 0, ltSmaLbl = 'Below SMA200';
if (above200 != null) {
if (above200 && pctSma200 != null && pctSma200 > 10) { ltSmaPts = 30; ltSmaLbl = `${pctSma200.toFixed(0)}% above SMA200`; }
else if (above200) { ltSmaPts = 22; ltSmaLbl = `Above SMA200`; }
else if (pctSma200 != null && pctSma200 > -5) { ltSmaPts = 10; ltSmaLbl = `Near SMA200 support`; }
}
ltParts.push({ key: 'dist200', label: 'Macro trend', value: pctSma200, points: ltSmaPts, desc: ltSmaLbl });
let egPts = 0, egLbl = 'Negative growth';
if (eg != null) {
if (eg >= 0.30) { egPts = 25; egLbl = `${(eg * 100).toFixed(0)}% earnings growth`; }
else if (eg >= 0.15) { egPts = 18; egLbl = `${(eg * 100).toFixed(0)}% earnings growth`; }
else if (eg >= 0.05) { egPts = 11; egLbl = `${(eg * 100).toFixed(0)}% growth`; }
else if (eg >= 0) { egPts = 5; egLbl = `Flat growth`; }
}
ltParts.push({ key: 'epsGrowth', label: 'Earnings power', value: eg, points: egPts, desc: egLbl });
const totalRec = (f.rec_strong_buy || 0) + (f.rec_buy || 0) + (f.rec_hold || 0) + (f.rec_sell || 0) + (f.rec_strong_sell || 0);
const bullishRec = (f.rec_strong_buy || 0) + (f.rec_buy || 0);
let recPts = 0, recLbl = 'Bearish consensus';
if (totalRec > 0) {
const bullPct = bullishRec / totalRec;
if (bullPct >= 0.75) { recPts = 25; recLbl = `${(bullPct * 100).toFixed(0)}% bullish`; }
else if (bullPct >= 0.55) { recPts = 16; recLbl = `${(bullPct * 100).toFixed(0)}% bullish`; }
else if (bullPct >= 0.40) { recPts = 8; recLbl = `Mixed consensus`; }
}
ltParts.push({ key: 'conviction', label: 'Analyst conviction', value: bullishRec/totalRec, points: recPts, desc: recLbl });
let pmPts = 0, pmLbl = 'Unprofitable';
if (pm != null) {
if (pm >= 0.25) { pmPts = 20; pmLbl = `${(pm * 100).toFixed(0)}% margin`; }
else if (pm >= 0.15) { pmPts = 14; pmLbl = `${(pm * 100).toFixed(0)}% margin`; }
else if (pm >= 0.05) { pmPts = 7; pmLbl = `${(pm * 100).toFixed(0)}% margin`; }
else if (pm >= 0) { pmPts = 2; pmLbl = `Thin margin`; }
}
ltParts.push({ key: 'margin', label: 'Profit durability', value: pm, points: pmPts, desc: pmLbl });
const ltScore = Math.min(100, Math.round(ltParts.reduce((s, p) => s + p.points, 0)));
// Raw features map for logging
const featuresLog = {
rvol, atrPct, rsi, macdHist: macdH, dist50: pctSma50, dist200: pctSma200,
revisions: revisionsNet, epsGrowth: eg, margin: pm,
beta: q.beta
};
return {
daytrade: {
score: dtScore,
grade: gradeLabel(dtScore),
probability: pWin,
drivers: dtParts.sort((a, b) => b.points - a.points),
dataAgeSec,
stale,
features: featuresLog
},
shortTerm: {
score: stScore,
grade: gradeLabel(stScore),
drivers: stParts.sort((a, b) => b.points - a.points),
dataAgeSec,
stale,
features: featuresLog
},
longTerm: {
score: ltScore,
grade: gradeLabel(ltScore),
drivers: ltParts.sort((a, b) => b.points - a.points),
dataAgeSec,
stale,
features: featuresLog
},
};
}
export function calculateRocketScore(row) {
let score = 0;

View File

@ -0,0 +1,51 @@
import { rawQuery } from '../db.js';
/**
* Signal Logger
* Writes generated signals to the database asynchronously.
*/
/**
* Log a batch of signals to PostgreSQL
*
* @param {Array} signals - Array of signal objects from scoringEngine
*/
export async function logSignals(signals) {
if (!signals || signals.length === 0) return;
try {
// Generate a single INSERT with multiple VALUES clauses for batching
const values = [];
const params = [];
let paramIdx = 1;
for (const s of signals) {
values.push(`($${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++})`);
params.push(
s.ticker,
s.ts, // TIMESTAMPTZ
s.lane,
s.score,
s.grade,
s.entry_price,
JSON.stringify(s.features),
JSON.stringify(s.regime),
s.data_age_sec
);
}
const sql = `
INSERT INTO signals (
ticker, ts, lane, score, grade, entry_price, features, regime, data_age_sec
) VALUES ${values.join(', ')}
`;
// Fire and forget (don't block the caller)
rawQuery(sql, params).catch(err => {
console.error('[signalLogger] Async insert failed:', err.message);
});
} catch (err) {
console.error('[signalLogger] Sync setup failed:', err.message);
}
}

View File

@ -14,69 +14,162 @@ const CAP_TIERS = {
// Below $2B = SMALL
};
// Master universe of top 50 stocks + ETFs
// Master universe of ~200 carefully selected global stocks + ETFs
export const UNIVERSE = [
// Mega-cap Tech
// --- Mega-cap Tech & Comm Services ---
{ symbol: 'AAPL', name: 'Apple Inc.', sector: 'Technology', isETF: false },
{ symbol: 'MSFT', name: 'Microsoft Corp.', sector: 'Technology', isETF: false },
{ symbol: 'NVDA', name: 'NVIDIA Corp.', sector: 'Technology', isETF: false },
{ symbol: 'AMZN', name: 'Amazon.com Inc.', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'GOOGL', name: 'Alphabet Inc.', sector: 'Technology', isETF: false },
{ symbol: 'META', name: 'Meta Platforms', sector: 'Technology', isETF: false },
{ symbol: 'GOOGL', name: 'Alphabet Inc.', sector: 'Communication', isETF: false },
{ symbol: 'META', name: 'Meta Platforms', sector: 'Communication', isETF: false },
{ symbol: 'TSLA', name: 'Tesla Inc.', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'AVGO', name: 'Broadcom Inc.', sector: 'Technology', isETF: false },
{ symbol: 'ORCL', name: 'Oracle Corp.', sector: 'Technology', isETF: false },
{ symbol: 'AMD', name: 'Advanced Micro Devices', sector: 'Technology', isETF: false },
// Financials
{ symbol: 'CRM', name: 'Salesforce Inc.', sector: 'Technology', isETF: false },
{ symbol: 'ADBE', name: 'Adobe Inc.', sector: 'Technology', isETF: false },
{ symbol: 'CSCO', name: 'Cisco Systems', sector: 'Technology', isETF: false },
{ symbol: 'NFLX', name: 'Netflix Inc.', sector: 'Communication', isETF: false },
{ symbol: 'DIS', name: 'Walt Disney Co.', sector: 'Communication', isETF: false },
{ symbol: 'INTC', name: 'Intel Corp.', sector: 'Technology', isETF: false },
{ symbol: 'IBM', name: 'Intl Business Machines', sector: 'Technology', isETF: false },
// --- Emerging Markets & Global Giants (Heavily weighted) ---
{ symbol: 'TSM', name: 'Taiwan Semiconductor', sector: 'Technology', isETF: false },
{ symbol: 'BABA', name: 'Alibaba Group', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'PDD', name: 'PDD Holdings', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'HDB', name: 'HDFC Bank', sector: 'Financials', isETF: false },
{ symbol: 'IBN', name: 'ICICI Bank', sector: 'Financials', isETF: false },
{ symbol: 'INFY', name: 'Infosys Ltd.', sector: 'Technology', isETF: false },
{ symbol: 'RELIANCE.NS', name: 'Reliance Industries', sector: 'Energy', isETF: false },
{ symbol: 'VALE', name: 'Vale S.A.', sector: 'Materials', isETF: false },
{ symbol: 'PBR', name: 'Petrobras', sector: 'Energy', isETF: false },
{ symbol: 'ITUB', name: 'Itau Unibanco', sector: 'Financials', isETF: false },
{ symbol: 'MELI', name: 'MercadoLibre', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'NU', name: 'Nu Holdings', sector: 'Financials', isETF: false },
{ symbol: 'ASML', name: 'ASML Holding', sector: 'Technology', isETF: false },
{ symbol: 'NVO', name: 'Novo Nordisk', sector: 'Healthcare', isETF: false },
{ symbol: 'TM', name: 'Toyota Motor', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'SONY', name: 'Sony Group', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'RY', name: 'Royal Bank of Canada', sector: 'Financials', isETF: false },
{ symbol: 'BHP', name: 'BHP Group', sector: 'Materials', isETF: false },
{ symbol: 'RIO', name: 'Rio Tinto', sector: 'Materials', isETF: false },
{ symbol: 'BIDU', name: 'Baidu Inc.', sector: 'Communication', isETF: false },
{ symbol: 'JD', name: 'JD.com', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'TCEHY', name: 'Tencent Holdings', sector: 'Communication', isETF: false },
{ symbol: 'NTES', name: 'NetEase Inc.', sector: 'Communication', isETF: false },
{ symbol: 'AMX', name: 'America Movil', sector: 'Communication', isETF: false },
{ symbol: 'KB', name: 'KB Financial Group', sector: 'Financials', isETF: false },
{ symbol: 'BBD', name: 'Banco Bradesco', sector: 'Financials', isETF: false },
// --- Financials ---
{ symbol: 'JPM', name: 'JPMorgan Chase', sector: 'Financials', isETF: false },
{ symbol: 'BAC', name: 'Bank of America', sector: 'Financials', isETF: false },
{ symbol: 'WFC', name: 'Wells Fargo', sector: 'Financials', isETF: false },
{ symbol: 'GS', name: 'Goldman Sachs', sector: 'Financials', isETF: false },
{ symbol: 'MS', name: 'Morgan Stanley', sector: 'Financials', isETF: false },
{ symbol: 'C', name: 'Citigroup', sector: 'Financials', isETF: false },
{ symbol: 'V', name: 'Visa Inc.', sector: 'Financials', isETF: false },
{ symbol: 'MA', name: 'Mastercard Inc.', sector: 'Financials', isETF: false },
// Healthcare
{ symbol: 'AXP', name: 'American Express', sector: 'Financials', isETF: false },
{ symbol: 'BLK', name: 'BlackRock Inc.', sector: 'Financials', isETF: false },
{ symbol: 'BX', name: 'Blackstone Inc.', sector: 'Financials', isETF: false },
// --- Healthcare ---
{ symbol: 'UNH', name: 'UnitedHealth Group', sector: 'Healthcare', isETF: false },
{ symbol: 'LLY', name: 'Eli Lilly & Co.', sector: 'Healthcare', isETF: false },
{ symbol: 'JNJ', name: 'Johnson & Johnson', sector: 'Healthcare', isETF: false },
{ symbol: 'ABBV', name: 'AbbVie Inc.', sector: 'Healthcare', isETF: false },
{ symbol: 'PFE', name: 'Pfizer Inc.', sector: 'Healthcare', isETF: false },
// Energy
{ symbol: 'MRK', name: 'Merck & Co.', sector: 'Healthcare', isETF: false },
{ symbol: 'TMO', name: 'Thermo Fisher Scientific', sector: 'Healthcare', isETF: false },
{ symbol: 'DHR', name: 'Danaher Corp.', sector: 'Healthcare', isETF: false },
{ symbol: 'ISRG', name: 'Intuitive Surgical', sector: 'Healthcare', isETF: false },
{ symbol: 'SYK', name: 'Stryker Corp.', sector: 'Healthcare', isETF: false },
// --- Energy & Materials ---
{ symbol: 'XOM', name: 'Exxon Mobil', sector: 'Energy', isETF: false },
{ symbol: 'CVX', name: 'Chevron Corp.', sector: 'Energy', isETF: false },
// Industrials
{ symbol: 'COP', name: 'ConocoPhillips', sector: 'Energy', isETF: false },
{ symbol: 'SLB', name: 'Schlumberger', sector: 'Energy', isETF: false },
{ symbol: 'LIN', name: 'Linde plc', sector: 'Materials', isETF: false },
{ symbol: 'SHW', name: 'Sherwin-Williams', sector: 'Materials', isETF: false },
{ symbol: 'FCX', name: 'Freeport-McMoRan', sector: 'Materials', isETF: false },
{ symbol: 'EOG', name: 'EOG Resources', sector: 'Energy', isETF: false },
{ symbol: 'OXY', name: 'Occidental Petroleum', sector: 'Energy', isETF: false },
// --- Industrials & Aerospace ---
{ symbol: 'CAT', name: 'Caterpillar Inc.', sector: 'Industrials', isETF: false },
{ symbol: 'HON', name: 'Honeywell Intl.', sector: 'Industrials', isETF: false },
// Consumer
{ symbol: 'GE', name: 'General Electric', sector: 'Industrials', isETF: false },
{ symbol: 'BA', name: 'Boeing Co.', sector: 'Industrials', isETF: false },
{ symbol: 'LMT', name: 'Lockheed Martin', sector: 'Industrials', isETF: false },
{ symbol: 'UNP', name: 'Union Pacific', sector: 'Industrials', isETF: false },
{ symbol: 'UPS', name: 'United Parcel Service', sector: 'Industrials', isETF: false },
{ symbol: 'RTX', name: 'RTX Corp.', sector: 'Industrials', isETF: false },
{ symbol: 'DE', name: 'Deere & Co.', sector: 'Industrials', isETF: false },
// --- Consumer Staples & Discretionary ---
{ symbol: 'WMT', name: 'Walmart Inc.', sector: 'Consumer Staples', isETF: false },
{ symbol: 'COST', name: 'Costco Wholesale', sector: 'Consumer Staples', isETF: false },
// Semiconductors
{ symbol: 'QCOM', name: 'Qualcomm Inc.', sector: 'Technology', isETF: false },
{ symbol: 'MU', name: 'Micron Technology', sector: 'Technology', isETF: false },
{ symbol: 'INTC', name: 'Intel Corp.', sector: 'Technology', isETF: false },
// Growth / Momentum
{ symbol: 'PG', name: 'Procter & Gamble', sector: 'Consumer Staples', isETF: false },
{ symbol: 'KO', name: 'Coca-Cola Co.', sector: 'Consumer Staples', isETF: false },
{ symbol: 'PEP', name: 'PepsiCo Inc.', sector: 'Consumer Staples', isETF: false },
{ symbol: 'MCD', name: 'McDonald\'s Corp.', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'HD', name: 'Home Depot', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'NKE', name: 'NIKE Inc.', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'SBUX', name: 'Starbucks Corp.', sector: 'Consumer Discret.', isETF: false },
{ symbol: 'TGT', name: 'Target Corp.', sector: 'Consumer Staples', isETF: false },
// --- Utilities & Real Estate ---
{ symbol: 'NEE', name: 'NextEra Energy', sector: 'Utilities', isETF: false },
{ symbol: 'DUK', name: 'Duke Energy', sector: 'Utilities', isETF: false },
{ symbol: 'SO', name: 'Southern Co.', sector: 'Utilities', isETF: false },
{ symbol: 'PLD', name: 'Prologis Inc.', sector: 'Real Estate', isETF: false },
{ symbol: 'AMT', name: 'American Tower', sector: 'Real Estate', isETF: false },
{ symbol: 'EQIX', name: 'Equinix Inc.', sector: 'Real Estate', isETF: false },
// --- High-Growth / Mid-Cap Tech ---
{ symbol: 'PLTR', name: 'Palantir Technologies', sector: 'Technology', isETF: false },
{ symbol: 'CRWD', name: 'CrowdStrike Holdings', sector: 'Technology', isETF: false },
{ symbol: 'SNOW', name: 'Snowflake Inc.', sector: 'Technology', isETF: false },
{ symbol: 'COIN', name: 'Coinbase Global', sector: 'Financials', isETF: false },
{ symbol: 'NFLX', name: 'Netflix Inc.', sector: 'Technology', isETF: false },
{ symbol: 'CRM', name: 'Salesforce Inc.', sector: 'Technology', isETF: false },
{ symbol: 'ADBE', name: 'Adobe Inc.', sector: 'Technology', isETF: false },
{ symbol: 'NOW', name: 'ServiceNow Inc.', sector: 'Technology', isETF: false },
{ symbol: 'MSTR', name: 'MicroStrategy Inc.', sector: 'Technology', isETF: false },
// Broad ETFs
{ symbol: 'U', name: 'Unity Software', sector: 'Technology', isETF: false },
{ symbol: 'DDOG', name: 'Datadog Inc.', sector: 'Technology', isETF: false },
{ symbol: 'SHOP', name: 'Shopify Inc.', sector: 'Technology', isETF: false },
{ symbol: 'SQ', name: 'Block Inc.', sector: 'Financials', isETF: false },
// --- Regional / Global ETFs (Including EM focus) ---
{ symbol: 'EEM', name: 'iShares MSCI Emerging Markets', sector: 'ETF', isETF: true },
{ symbol: 'VWO', name: 'Vanguard FTSE Emerging Markets', sector: 'ETF', isETF: true },
{ symbol: 'INDA', name: 'iShares MSCI India', sector: 'ETF', isETF: true },
{ symbol: 'FXI', name: 'iShares China Large-Cap', sector: 'ETF', isETF: true },
{ symbol: 'MCHI', name: 'iShares MSCI China', sector: 'ETF', isETF: true },
{ symbol: 'EWZ', name: 'iShares MSCI Brazil', sector: 'ETF', isETF: true },
{ symbol: 'EWT', name: 'iShares MSCI Taiwan', sector: 'ETF', isETF: true },
{ symbol: 'EWY', name: 'iShares MSCI South Korea', sector: 'ETF', isETF: true },
{ symbol: 'EWW', name: 'iShares MSCI Mexico', sector: 'ETF', isETF: true },
{ symbol: 'EWJ', name: 'iShares MSCI Japan', sector: 'ETF', isETF: true },
{ symbol: 'VGK', name: 'Vanguard FTSE Europe', sector: 'ETF', isETF: true },
{ symbol: 'EZU', name: 'iShares MSCI Eurozone', sector: 'ETF', isETF: true },
{ symbol: 'SPY', name: 'SPDR S&P 500 ETF', sector: 'ETF', isETF: true },
{ symbol: 'QQQ', name: 'Invesco QQQ Trust', sector: 'ETF', isETF: true },
{ symbol: 'IWM', name: 'iShares Russell 2000', sector: 'ETF', isETF: true },
{ symbol: 'DIA', name: 'SPDR Dow Jones ETF', sector: 'ETF', isETF: true },
// Sector ETFs
// --- Sector & Specialty ETFs ---
{ symbol: 'XLF', name: 'Financial Select SPDR', sector: 'ETF', isETF: true },
{ symbol: 'XLE', name: 'Energy Select SPDR', sector: 'ETF', isETF: true },
{ symbol: 'XLK', name: 'Technology Select SPDR', sector: 'ETF', isETF: true },
{ symbol: 'XLV', name: 'Health Care Select SPDR', sector: 'ETF', isETF: true },
// Specialty ETFs
{ symbol: 'XLU', name: 'Utilities Select SPDR', sector: 'ETF', isETF: true },
{ symbol: 'XLI', name: 'Industrial Select SPDR', sector: 'ETF', isETF: true },
{ symbol: 'ARKK', name: 'ARK Innovation ETF', sector: 'ETF', isETF: true },
{ symbol: 'GLD', name: 'SPDR Gold Shares', sector: 'ETF', isETF: true },
{ symbol: 'SLV', name: 'iShares Silver Trust', sector: 'ETF', isETF: true },
{ symbol: 'TLT', name: 'iShares 20+ Yr Treasury', sector: 'ETF', isETF: true },
{ symbol: 'UUP', name: 'Invesco DB US Dollar Index', sector: 'ETF', isETF: true },
];
/**

View File

@ -10,8 +10,6 @@
"dependencies": {
"@tanstack/react-query": "^5.12.2",
"clsx": "^2.0.0",
"d3": "^7.8.5",
"lightweight-charts": "^4.1.3",
"lucide-react": "^0.294.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -1560,417 +1558,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
"license": "ISC",
"dependencies": {
"d3-array": "3",
"d3-axis": "3",
"d3-brush": "3",
"d3-chord": "3",
"d3-color": "3",
"d3-contour": "4",
"d3-delaunay": "6",
"d3-dispatch": "3",
"d3-drag": "3",
"d3-dsv": "3",
"d3-ease": "3",
"d3-fetch": "3",
"d3-force": "3",
"d3-format": "3",
"d3-geo": "3",
"d3-hierarchy": "3",
"d3-interpolate": "3",
"d3-path": "3",
"d3-polygon": "3",
"d3-quadtree": "3",
"d3-random": "3",
"d3-scale": "4",
"d3-scale-chromatic": "3",
"d3-selection": "3",
"d3-shape": "3",
"d3-time": "3",
"d3-time-format": "4",
"d3-timer": "3",
"d3-transition": "3",
"d3-zoom": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-axis": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-brush": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "3",
"d3-transition": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-chord": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
"license": "ISC",
"dependencies": {
"d3-path": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-contour": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
"license": "ISC",
"dependencies": {
"d3-array": "^3.2.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-polygon": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -1999,15 +1586,6 @@
"node": ">=8"
}
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
"license": "ISC",
"dependencies": {
"robust-predicates": "^3.0.2"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -2085,12 +1663,6 @@
"node": ">=6"
}
},
"node_modules/fancy-canvas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
"integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
"license": "MIT"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@ -2229,27 +1801,6 @@
"node": ">= 0.4"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -2394,15 +1945,6 @@
"node": ">=6"
}
},
"node_modules/lightweight-charts": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-4.2.3.tgz",
"integrity": "sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==",
"license": "Apache-2.0",
"dependencies": {
"fancy-canvas": "2.1.0"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@ -2914,12 +2456,6 @@
"node": ">=0.10.0"
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
@ -3030,18 +2566,6 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",

View File

@ -3,103 +3,96 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import OptionsFlowPanel from '@/components/dashboard/OptionsFlowPanel';
import ScannerPanel from '@/components/dashboard/ScannerPanel';
import BacktestPanel from '@/components/dashboard/BacktestPanel';
import AlertsFeed from '@/components/dashboard/AlertsFeed';
import Watchlist from '@/components/dashboard/Watchlist';
import PerformanceTrackingPanel from '@/components/dashboard/PerformanceTrackingPanel';
import MethodologyModal from '@/components/modals/MethodologyModal';
import ReversalAlerts from '@/components/alerts/ReversalAlerts';
import ConvergenceAlerts from '@/components/alerts/ConvergenceAlerts';
import MarketScreenerPanel from '@/components/dashboard/MarketScreenerPanel';
import StockDetailPanel from '@/components/dashboard/StockDetailPanel';
import NewsFeedPanel from '@/components/dashboard/NewsFeedPanel';
import ReversalScreenerPanel from '@/components/dashboard/ReversalScreenerPanel';
export default function App() {
const [selectedSymbol, setSelectedSymbol] = useState(null);
const [isMethodologyOpen, setIsMethodologyOpen] = useState(false);
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
<ConvergenceAlerts />
<ReversalAlerts />
<MethodologyModal isOpen={isMethodologyOpen} onClose={() => setIsMethodologyOpen(false)} />
{/* Header */}
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur">
<div className="container mx-auto px-4 py-4">
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur sticky top-0 z-40">
<div className="container mx-auto px-4 py-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
🚀 Institutional Flow Platform
FactorLab: Transparent Market Research
</h1>
<p className="text-sm text-slate-400 mt-1">
Real-time options flow · Tape analysis · Pro-grade signals
<p className="text-xs text-slate-400 mt-0.5">
We show you the facts fast and we tell you what we've tested and what we haven't.
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<button
onClick={() => setIsMethodologyOpen(true)}
className="text-xs font-semibold text-blue-400 hover:text-blue-300 transition-colors border border-blue-400/30 px-3 py-1.5 rounded bg-blue-500/10"
>
Methodology & Disclaimers
</button>
<div className="text-right border-l border-slate-700 pl-4 ml-2">
<div className="text-xs text-slate-400">Market Status</div>
<div className="text-sm font-semibold text-green-400">
RTH LIVE
</div>
<div className="text-sm font-semibold text-green-400">RTH LIVE</div>
</div>
</div>
</div>
</div>
</header>
{/* Main Content */}
{/* Main Content — full width */}
<div className="container mx-auto px-4 py-6">
<div className="grid grid-cols-12 gap-6">
{/* Left: Main Panels */}
<div className="col-span-9">
<Tabs defaultValue="market" className="w-full">
<TabsList className="bg-slate-900 border border-slate-800">
<TabsTrigger value="market" className="data-[state=active]:bg-slate-800">
📊 Market Analysis
</TabsTrigger>
<TabsTrigger value="flow" className="data-[state=active]:bg-slate-800">
🎯 Options Flow
</TabsTrigger>
<TabsTrigger value="scanner" className="data-[state=active]:bg-slate-800">
🔍 Multi-Signal Scanner
</TabsTrigger>
<TabsTrigger value="backtest" className="data-[state=active]:bg-slate-800">
📊 Backtests
</TabsTrigger>
</TabsList>
<Tabs defaultValue="market" className="w-full">
<TabsList className="bg-slate-900 border border-slate-800">
<TabsTrigger value="market" className="data-[state=active]:bg-slate-800">
📊 Market Analysis
</TabsTrigger>
<TabsTrigger value="flow" className="data-[state=active]:bg-slate-800">
🎯 Options Flow
</TabsTrigger>
<TabsTrigger value="reversals" className="data-[state=active]:bg-slate-800">
🔄 Reversal Screener
</TabsTrigger>
<TabsTrigger value="scanner" className="data-[state=active]:bg-slate-800">
🔍 Multi-Signal Scanner
</TabsTrigger>
<TabsTrigger value="backtest" className="data-[state=active]:bg-slate-800">
📊 Backtests
</TabsTrigger>
</TabsList>
<TabsContent value="market" className="mt-6 space-y-6">
<MarketScreenerPanel onSelectSymbol={setSelectedSymbol} />
{selectedSymbol && (
<StockDetailPanel
symbol={selectedSymbol}
onClose={() => setSelectedSymbol(null)}
/>
)}
</TabsContent>
<TabsContent value="market" className="mt-6 space-y-6">
<MarketScreenerPanel
selectedSymbol={selectedSymbol}
onSelectSymbol={setSelectedSymbol}
onCloseSymbol={() => setSelectedSymbol(null)}
/>
</TabsContent>
<TabsContent value="flow" className="mt-6">
<OptionsFlowPanel />
</TabsContent>
<TabsContent value="flow" className="mt-6">
<OptionsFlowPanel />
</TabsContent>
<TabsContent value="scanner" className="mt-6">
<ScannerPanel />
</TabsContent>
<TabsContent value="reversals" className="mt-6">
<ReversalScreenerPanel onSelectSymbol={setSelectedSymbol} />
</TabsContent>
<TabsContent value="backtest" className="mt-6">
<BacktestPanel />
</TabsContent>
</Tabs>
</div>
<TabsContent value="scanner" className="mt-6">
<ScannerPanel />
</TabsContent>
{/* Right: News, Watchlist & Alerts Feed */}
<div className="col-span-3 space-y-6">
<NewsFeedPanel />
<Watchlist />
<AlertsFeed />
</div>
</div>
{/* Bottom: Performance Tracking */}
<div className="mt-6">
<PerformanceTrackingPanel />
</div>
<TabsContent value="backtest" className="mt-6">
<BacktestPanel />
</TabsContent>
</Tabs>
</div>
</div>
);

Some files were not shown because too many files have changed in this diff Show More