merge: resolve conflicts between local production fixes and remote updates
Build Institutional Trader / build-and-deploy (push) Successful in 5m41s
Details
Build Institutional Trader / build-and-deploy (push) Successful in 5m41s
Details
This commit is contained in:
commit
2b3355cddb
|
|
@ -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
|
||||
|
|
|
|||
13
Dockerfile
13
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
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
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
|
|
@ -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,
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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."
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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 : [];
|
||||
}
|
||||
|
|
@ -12,11 +12,16 @@ export const setupSecurity = (app) => {
|
|||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc: ["'self'", "https://static.cloudflareinsights.com", "'unsafe-inline'"],
|
||||
connectSrc: ["'self'", "ws:", "wss:", "http:", "https:"],
|
||||
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
|
||||
|
|
@ -43,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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -102,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) {
|
||||
|
|
@ -214,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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,103 +3,88 @@ 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';
|
||||
|
||||
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="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="scanner" className="mt-6">
|
||||
<ScannerPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backtest" className="mt-6">
|
||||
<BacktestPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
function fmtPrice(n, symbol) {
|
||||
if (n == null) return '—';
|
||||
// VIX and TNX are usually shown raw without $
|
||||
if (symbol === '^VIX' || symbol === '^TNX' || symbol === 'DX-Y.NYB') {
|
||||
return n.toFixed(2);
|
||||
}
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function MacroIndicatorsPanel() {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/market/macro`);
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
setData(json.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MacroIndicatorsPanel] Error fetching macro data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 90000); // 90 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
if (loading && data.length === 0) {
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-hidden p-1 opacity-50 mb-6">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse bg-slate-900 border border-slate-800 rounded-xl min-w-[140px] h-[72px]" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
function fmtPct(n) {
|
||||
if (n == null) return '—';
|
||||
return (n >= 0 ? '+' : '') + n.toFixed(2) + '%';
|
||||
}
|
||||
|
||||
function getColor(n) {
|
||||
if (n == null) return 'text-slate-500';
|
||||
return n >= 0 ? 'text-emerald-400' : 'text-red-400';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="text-xs font-bold text-slate-500 mb-3 uppercase tracking-wider pl-1">Global Macro Context</div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-transparent">
|
||||
{data.map((item) => (
|
||||
<div
|
||||
key={item.symbol}
|
||||
className="bg-slate-900 border border-slate-700/50 rounded-xl p-3 min-w-[200px] flex-shrink-0 flex flex-col justify-between hover:bg-slate-800 transition-colors shadow-sm"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="text-xs font-semibold text-slate-400">{item.name}</div>
|
||||
<span className="text-white font-mono font-bold text-sm">
|
||||
{fmtPrice(item.price, item.symbol)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1 text-[10px] text-center">
|
||||
<div className="flex flex-col bg-slate-800/50 rounded p-1">
|
||||
<span className="text-slate-500 mb-0.5">1D</span>
|
||||
<span className={`font-semibold tabular-nums ${getColor(item.change_pct)}`}>{fmtPct(item.change_pct)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col bg-slate-800/50 rounded p-1">
|
||||
<span className="text-slate-500 mb-0.5">1W</span>
|
||||
<span className={`font-semibold tabular-nums ${getColor(item.change_1w)}`}>{fmtPct(item.change_1w)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col bg-slate-800/50 rounded p-1">
|
||||
<span className="text-slate-500 mb-0.5">1M</span>
|
||||
<span className={`font-semibold tabular-nums ${getColor(item.change_1m)}`}>{fmtPct(item.change_1m)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col bg-slate-800/50 rounded p-1">
|
||||
<span className="text-slate-500 mb-0.5">1Y</span>
|
||||
<span className={`font-semibold tabular-nums ${getColor(item.change_1y)}`}>{fmtPct(item.change_1y)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,348 +1,394 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import MacroIndicatorsPanel from './MacroIndicatorsPanel';
|
||||
import StockDetailPanel from './StockDetailPanel';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
const CAP_TABS = [
|
||||
{ id: 'all', label: '🌐 All', color: 'text-slate-300' },
|
||||
{ id: 'large', label: '🏛️ Large Cap', color: 'text-blue-400' },
|
||||
{ id: 'mid', label: '🏢 Mid Cap', color: 'text-purple-400' },
|
||||
{ id: 'small', label: '🔬 Small Cap', color: 'text-orange-400' },
|
||||
{ id: 'etf', label: '📦 ETFs', color: 'text-green-400' },
|
||||
];
|
||||
|
||||
// ─── Formatters ───────────────────────────────────────────────────────────────
|
||||
function fmtPrice(n) {
|
||||
if (n == null) return '—';
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
function fmtChange(n) {
|
||||
if (n == null) return null;
|
||||
return { value: n, label: `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`, positive: n >= 0 };
|
||||
}
|
||||
function fmtMktCap(n) {
|
||||
if (n == null) return '—';
|
||||
if (n >= 1e12) return `$${(n / 1e12).toFixed(2)}T`;
|
||||
if (n >= 1e9) return `$${(n / 1e9).toFixed(2)}B`;
|
||||
if (n >= 1e6) return `$${(n / 1e6).toFixed(2)}M`;
|
||||
if (n >= 1e12) return `$${(n / 1e12).toFixed(1)}T`;
|
||||
if (n >= 1e9) return `$${(n / 1e9).toFixed(1)}B`;
|
||||
if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
|
||||
return `$${n.toFixed(0)}`;
|
||||
}
|
||||
|
||||
function fmtVolume(n) {
|
||||
if (n == null) return '—';
|
||||
if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`;
|
||||
return `${n}`;
|
||||
}
|
||||
|
||||
function ChangeBadge({ value }) {
|
||||
if (value == null) return <span className="text-slate-500">—</span>;
|
||||
const positive = value >= 0;
|
||||
return (
|
||||
<span className={`font-semibold tabular-nums ${
|
||||
positive ? 'text-emerald-400' : 'text-red-400'
|
||||
}`}>
|
||||
{positive ? '+' : ''}{value.toFixed(2)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CapBadge({ tier }) {
|
||||
const styles = {
|
||||
LARGE: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
MID: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
SMALL: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
ETF: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
UNKNOWN: 'bg-slate-700 text-slate-400 border-slate-600',
|
||||
// ─── Score ring component ─────────────────────────────────────────────────────
|
||||
function ScoreRing({ score, color }) {
|
||||
const r = 20;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const fill = (score / 100) * circ;
|
||||
const colors = {
|
||||
emerald: '#10b981',
|
||||
blue: '#3b82f6',
|
||||
yellow: '#eab308',
|
||||
orange: '#f97316',
|
||||
red: '#ef4444',
|
||||
};
|
||||
const stroke = colors[color] || colors.blue;
|
||||
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded border font-medium ${
|
||||
styles[tier] || styles.UNKNOWN
|
||||
}`}>
|
||||
{tier}
|
||||
</span>
|
||||
<div className="relative w-14 h-14 flex-shrink-0">
|
||||
<svg viewBox="0 0 50 50" className="w-full h-full -rotate-90">
|
||||
<circle cx="25" cy="25" r={r} fill="none" stroke="#1e293b" strokeWidth="5" />
|
||||
<circle
|
||||
cx="25" cy="25" r={r}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth="5"
|
||||
strokeDasharray={`${fill} ${circ}`}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dasharray 0.6s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-white tabular-nums">{score}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MarketScreenerPanel({ onSelectSymbol }) {
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [setupFilter, setSetupFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('market_cap');
|
||||
const [sortDir, setSortDir] = useState('desc');
|
||||
const [search, setSearch] = useState('');
|
||||
const [data, setData] = useState([]);
|
||||
const [leaderboard, setLeaderboard] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [lastUpdated, setLastUpdated] = useState(null);
|
||||
// ─── Factor Profile Component ───────────────────────────────────────────────────
|
||||
function pctBar(pct) {
|
||||
if (pct == null) return <span className="text-slate-600 text-xs italic">Missing</span>;
|
||||
|
||||
let colorClass = 'bg-yellow-500'; // middle 50%
|
||||
if (pct >= 75) colorClass = 'bg-emerald-500'; // top 25%
|
||||
else if (pct < 25) colorClass = 'bg-red-500'; // bottom 25%
|
||||
|
||||
return (
|
||||
<div className="flex-1 mx-3 h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${colorClass} transition-all duration-500`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorProfileCard({ profile, onSelect }) {
|
||||
// Check for incomplete data
|
||||
const isIncomplete = profile.value == null || profile.quality == null || profile.lowVol == null || profile.momentum == null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect?.(profile.ticker)}
|
||||
className={`group bg-slate-900 border rounded-xl p-4 cursor-pointer transition-all duration-150 relative ${
|
||||
isIncomplete ? 'border-orange-900/50 hover:border-orange-500/50' : 'border-slate-700/50 hover:border-blue-500/50 hover:shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 border-b border-slate-800 pb-2">
|
||||
<span className="font-bold text-white text-lg group-hover:text-blue-400 transition-colors">
|
||||
{profile.ticker}
|
||||
</span>
|
||||
|
||||
{isIncomplete ? (
|
||||
<span className="text-[10px] text-orange-400 font-semibold bg-orange-950/50 px-2 py-1 rounded border border-orange-800/50" title="Rank computed from incomplete data. Low confidence.">
|
||||
⚠️ INCOMPLETE DATA
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold bg-slate-800 px-2 py-1 rounded">
|
||||
Factor Profile
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between" title="How cheap the stock is relative to earnings (P/E) and book value. Higher pct = Cheaper.">
|
||||
<span className="text-slate-400 w-20 border-b border-dotted border-slate-600/50 cursor-help">Value</span>
|
||||
{pctBar(profile.value)}
|
||||
<span className="text-white w-16 text-right tabular-nums">{profile.value != null ? `${profile.value}th pct` : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between" title="Profitability and balance sheet health (e.g., ROE, margins). Higher pct = Healthier/More Profitable.">
|
||||
<span className="text-slate-400 w-20 border-b border-dotted border-slate-600/50 cursor-help">Quality</span>
|
||||
{pctBar(profile.quality)}
|
||||
<span className="text-white w-16 text-right tabular-nums">{profile.quality != null ? `${profile.quality}th pct` : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between" title="Price stability and lower market risk (e.g., lower Beta). Higher pct = Less Volatile/More Stable.">
|
||||
<span className="text-slate-400 w-20 border-b border-dotted border-slate-600/50 cursor-help">Low-Vol</span>
|
||||
{pctBar(profile.lowVol)}
|
||||
<span className="text-white w-16 text-right tabular-nums">{profile.lowVol != null ? `${profile.lowVol}th pct` : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between" title="Recent price strength and trend (e.g., RSI, moving averages). Higher pct = Stronger Uptrend.">
|
||||
<span className="text-slate-400 w-20 border-b border-dotted border-slate-600/50 cursor-help">Momentum</span>
|
||||
{pctBar(profile.momentum)}
|
||||
<span className="text-white w-16 text-right tabular-nums">{profile.momentum != null ? `${profile.momentum}th pct` : 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer tooltip hint */}
|
||||
<div className="absolute top-2 right-2 flex items-center justify-center w-5 h-5 rounded-full bg-slate-800 text-slate-400 text-[10px] opacity-0 group-hover:opacity-100 transition-opacity" title={profile._disclaimer}>
|
||||
i
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Search bar ───────────────────────────────────────────────────────────────
|
||||
function SearchBar({ onResult, onClear }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const handleSearch = async () => {
|
||||
const sym = query.trim().toUpperCase();
|
||||
if (!sym) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/market/search/${sym}`);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
onResult(sym, data.data);
|
||||
} else {
|
||||
setError(data.error || 'Symbol not found');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Network error. Check backend.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
if (e.key === 'Escape') { setQuery(''); onClear(); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm">🔍</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search any ticker (e.g. TSLA, GME)..."
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value.toUpperCase()); setError(null); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="bg-slate-800 border border-slate-700 rounded-xl pl-9 pr-4 py-2 text-sm text-white placeholder-slate-500
|
||||
focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 w-72 transition-all"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => { setQuery(''); onClear(); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white text-lg leading-none"
|
||||
>×</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
id="search-fetch-btn"
|
||||
onClick={handleSearch}
|
||||
disabled={!query.trim() || loading}
|
||||
className="bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-500
|
||||
text-white font-semibold px-4 py-2 rounded-xl text-sm transition-all flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-spin text-base">⟳</span>
|
||||
) : (
|
||||
<span>Fetch Details</span>
|
||||
)}
|
||||
</button>
|
||||
{error && <span className="text-red-400 text-xs">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
export default function MarketScreenerPanel({ selectedSymbol, onSelectSymbol, onCloseSymbol }) {
|
||||
const [screenerData, setScreenerData] = useState(null);
|
||||
const [leaderboard, setLeaderboard] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState(null);
|
||||
const [sortConfig, setSortConfig] = useState({ key: 'composite', direction: 'desc' });
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const cap = activeTab === 'all' ? '' : activeTab;
|
||||
const url = `${API_BASE}/api/market/universe${cap ? `?cap=${cap}` : ''}`;
|
||||
const [universeRes, lbRes] = await Promise.allSettled([
|
||||
fetch(url).then(r => r.json()),
|
||||
const [screenerRes, lbRes] = await Promise.allSettled([
|
||||
fetch(`${API_BASE}/api/market/screener`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/api/market/leaderboard`).then(r => r.json()),
|
||||
]);
|
||||
|
||||
if (universeRes.status === 'fulfilled' && universeRes.value.success) {
|
||||
setData(universeRes.value.data || []);
|
||||
if (screenerRes.status === 'fulfilled' && screenerRes.value.success) {
|
||||
setScreenerData(screenerRes.value.data);
|
||||
setLastUpdated(new Date());
|
||||
}
|
||||
if (lbRes.status === 'fulfilled' && lbRes.value.success) {
|
||||
setLeaderboard(lbRes.value.data);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error('[MarketScreenerPanel]', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeTab]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 60000);
|
||||
const interval = setInterval(fetchData, 90000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
const processed = [...data]
|
||||
.map(r => {
|
||||
// Calculate intraday volatility if we have high/low
|
||||
const volPct = (r.day_high && r.day_low && r.day_low > 0)
|
||||
? ((r.day_high - r.day_low) / r.day_low) * 100
|
||||
: null;
|
||||
return { ...r, volatility_pct: volPct };
|
||||
})
|
||||
.filter(r => {
|
||||
if (!search) return true;
|
||||
const q = search.toUpperCase();
|
||||
return r.symbol.includes(q) || r.name.toUpperCase().includes(q) || r.sector?.toUpperCase().includes(q);
|
||||
})
|
||||
.filter(r => {
|
||||
if (setupFilter === 'all') return true;
|
||||
if (setupFilter === 'day') {
|
||||
// Day Trade: high volume (>1M), high volatility (>3%)
|
||||
return (r.volume >= 1000000) && (r.volatility_pct > 3 || Math.abs(r.change_pct || 0) > 3);
|
||||
}
|
||||
if (setupFilter === 'swing') {
|
||||
// Swing Trade: potential reversal (massive drop > 5% or massive gain > 5% on high volume)
|
||||
return (r.volume >= 500000) && (Math.abs(r.change_pct || 0) > 5);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const av = a[sortBy] ?? -Infinity;
|
||||
const bv = b[sortBy] ?? -Infinity;
|
||||
return sortDir === 'desc' ? bv - av : av - bv;
|
||||
});
|
||||
|
||||
const toggleSort = (col) => {
|
||||
if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc');
|
||||
else { setSortBy(col); setSortDir('desc'); }
|
||||
};
|
||||
|
||||
const SortIcon = ({ col }) => {
|
||||
if (sortBy !== col) return <span className="text-slate-600 ml-1">⇅</span>;
|
||||
return <span className="text-blue-400 ml-1">{sortDir === 'desc' ? '↓' : '↑'}</span>;
|
||||
};
|
||||
// Sort profiles based on config
|
||||
const profiles = screenerData?.profiles || [];
|
||||
const sortedProfiles = [...profiles].sort((a, b) => {
|
||||
const aVal = a[sortConfig.key] || 0;
|
||||
const bVal = b[sortConfig.key] || 0;
|
||||
return sortConfig.direction === 'desc' ? bVal - aVal : aVal - bVal;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
{/* Global Macro Strip */}
|
||||
<MacroIndicatorsPanel />
|
||||
|
||||
{/* Leaderboard strip */}
|
||||
{leaderboard && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<LeaderCard title="🚀 Top Gainers" items={leaderboard.top_gainers} type="gain" />
|
||||
<LeaderCard title="📉 Top Losers" items={leaderboard.top_losers} type="loss" />
|
||||
<LeaderCard title="🔥 Most Active" items={leaderboard.most_active} type="volume" />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<LeaderCard title="🚀 Top Gainers" items={leaderboard.top_gainers} type="gain" onSelect={onSelectSymbol} />
|
||||
<LeaderCard title="📉 Top Losers" items={leaderboard.top_losers} type="loss" onSelect={onSelectSymbol} />
|
||||
<LeaderCard title="🔥 Most Active" items={leaderboard.most_active} type="volume" onSelect={onSelectSymbol} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main screener card */}
|
||||
<div className="bg-slate-900 border border-slate-700/50 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-white font-bold text-lg">📊 Market Screener</h2>
|
||||
{lastUpdated && (
|
||||
<span className="text-xs text-slate-500">
|
||||
Updated {lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
{loading && <span className="text-xs text-blue-400 animate-pulse">Refreshing...</span>}
|
||||
{/* Factor Profiles header */}
|
||||
<div className="bg-slate-900 border border-slate-700/50 rounded-2xl overflow-hidden">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 px-5 py-4 border-b border-slate-700/40">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-white font-bold text-lg flex items-center gap-2">
|
||||
📊 Fundamental Factor Lens
|
||||
<span className="text-[10px] uppercase tracking-wider font-bold bg-slate-800 text-blue-400 px-1.5 py-0.5 rounded border border-blue-500/30">
|
||||
Descriptive Only
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-slate-500 text-xs mt-0.5">
|
||||
Where stocks rank vs. peers today • {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : 'Loading...'}
|
||||
{loading && <span className="ml-2 text-blue-400 animate-pulse">Refreshing...</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search symbol or name..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 w-52"
|
||||
|
||||
{/* Search and Sort */}
|
||||
<div className="flex items-center gap-3 mt-3 md:mt-0">
|
||||
<select
|
||||
className="bg-slate-800 border border-slate-700 text-slate-200 text-sm rounded-xl px-3 py-2 outline-none focus:border-blue-500 transition-colors cursor-pointer"
|
||||
value={`${sortConfig.key}-${sortConfig.direction}`}
|
||||
onChange={(e) => {
|
||||
const [key, direction] = e.target.value.split('-');
|
||||
setSortConfig({ key, direction });
|
||||
}}
|
||||
>
|
||||
<option value="composite-desc">Sort: Composite Top</option>
|
||||
<option value="value-desc">Sort: Value (Cheap)</option>
|
||||
<option value="quality-desc">Sort: Quality (High)</option>
|
||||
<option value="lowVol-desc">Sort: Low-Vol (Safe)</option>
|
||||
<option value="momentum-desc">Sort: Momentum (Strong)</option>
|
||||
</select>
|
||||
|
||||
<SearchBar
|
||||
onResult={(sym) => onSelectSymbol?.(sym)}
|
||||
onClear={() => {}}
|
||||
/>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="bg-slate-800 hover:bg-slate-700 border border-slate-600 text-slate-300 px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
onClick={() => { setLoading(true); fetchData(); }}
|
||||
className="bg-slate-800 hover:bg-slate-700 border border-slate-600 text-slate-300 px-3 py-2 rounded-xl text-sm transition-colors"
|
||||
title="Refresh profiles"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cap tier tabs */}
|
||||
<div className="flex flex-col sm:flex-row justify-between border-b border-slate-700/50 bg-slate-950/30">
|
||||
<div className="flex">
|
||||
{CAP_TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-all ${
|
||||
activeTab === tab.id
|
||||
? `border-b-2 border-blue-500 ${tab.color} bg-blue-500/5`
|
||||
: 'text-slate-500 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center px-4 space-x-2 border-t sm:border-t-0 border-slate-700/50 py-2 sm:py-0">
|
||||
<span className="text-xs text-slate-500 uppercase font-semibold mr-2">Setups:</span>
|
||||
{['all', 'day', 'swing'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setSetupFilter(f)}
|
||||
className={`px-3 py-1 text-xs rounded transition-all ${
|
||||
setupFilter === f
|
||||
? 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
|
||||
: 'bg-slate-800 text-slate-400 border border-slate-700 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'None' : f === 'day' ? '⚡ Day Trade' : '🌊 Swing Trade'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{error ? (
|
||||
<div className="p-8 text-center text-red-400">
|
||||
<p>⚠️ {error}</p>
|
||||
<button onClick={fetchData} className="mt-2 text-blue-400 underline text-sm">Retry</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-slate-500 text-xs uppercase tracking-wide bg-slate-950/50">
|
||||
<th className="text-left px-4 py-3 font-medium">Symbol</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Sector</th>
|
||||
<th
|
||||
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => toggleSort('price')}
|
||||
>Price <SortIcon col="price" /></th>
|
||||
<th
|
||||
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => toggleSort('change_pct')}
|
||||
>Change <SortIcon col="change_pct" /></th>
|
||||
<th
|
||||
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => toggleSort('market_cap')}
|
||||
>Mkt Cap <SortIcon col="market_cap" /></th>
|
||||
<th
|
||||
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => toggleSort('volume')}
|
||||
>Volume <SortIcon col="volume" /></th>
|
||||
<th
|
||||
className="text-right px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors"
|
||||
onClick={() => toggleSort('volatility_pct')}
|
||||
>Volatility <SortIcon col="volatility_pct" /></th>
|
||||
<th className="text-center px-4 py-3 font-medium">Cap Tier</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loading && data.length === 0 ? (
|
||||
Array.from({ length: 10 }).map((_, i) => (
|
||||
<tr key={i} className="animate-pulse">
|
||||
{Array.from({ length: 9 }).map((_, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-4 bg-slate-800 rounded w-full" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : processed.length === 0 ? (
|
||||
<tr><td colSpan={9} className="px-4 py-12 text-center text-slate-500">No results found</td></tr>
|
||||
) : (
|
||||
processed.map(row => (
|
||||
<tr
|
||||
key={row.symbol}
|
||||
onClick={() => onSelectSymbol?.(row.symbol)}
|
||||
className="hover:bg-slate-800/40 cursor-pointer transition-colors group"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-bold text-white group-hover:text-blue-400 transition-colors">
|
||||
{row.symbol}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-400 max-w-[180px] truncate">{row.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-slate-500 bg-slate-800 px-2 py-0.5 rounded">
|
||||
{row.sector}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">
|
||||
<span className="text-white font-medium">
|
||||
{row.price != null ? `$${row.price.toFixed(2)}` : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<ChangeBadge value={row.change_pct} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-300 tabular-nums">
|
||||
{fmtMktCap(row.market_cap)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-400 tabular-nums">
|
||||
{fmtVolume(row.volume)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">
|
||||
{row.volatility_pct != null ? (
|
||||
<span className={`text-sm ${row.volatility_pct > 3 ? 'text-emerald-400 font-medium' : 'text-slate-400'}`}>
|
||||
{row.volatility_pct.toFixed(2)}%
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<CapBadge tier={row.capTier} />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{selectedSymbol && (
|
||||
<div className="p-5 border-b border-slate-700/40 bg-slate-900">
|
||||
<StockDetailPanel
|
||||
symbol={selectedSymbol}
|
||||
onClose={onCloseSymbol}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Factor Grid ─────────────────────────────────────────────────── */}
|
||||
<div className="p-5 bg-slate-950/50">
|
||||
<div className="mb-6 bg-blue-950/20 border border-blue-900/50 p-4 rounded-lg flex items-start gap-3 text-sm text-slate-300 max-w-4xl shadow-inner">
|
||||
<span className="text-xl leading-none">💡</span>
|
||||
<p>
|
||||
<strong>This is a research starting point, not a buy list.</strong> High rank = "worth a closer look," not "will go up."
|
||||
Think Zillow for stocks: it shows you the facts fast — you still do the homework and decide.
|
||||
We will only upgrade these to "predictive" signals after they pass rigorous out-of-sample holdout validation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{loading ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse bg-slate-800/60 rounded-xl h-[160px]" />
|
||||
))
|
||||
) : sortedProfiles.length === 0 ? (
|
||||
<div className="col-span-full text-center text-slate-600 py-12 text-sm">No data available</div>
|
||||
) : (
|
||||
sortedProfiles.map(p => (
|
||||
<FactorProfileCard key={p.ticker} profile={p} onSelect={onSelectSymbol} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-slate-700/50 bg-slate-950/30 flex justify-between items-center">
|
||||
<span className="text-xs text-slate-600">{processed.length} of {data.length} symbols</span>
|
||||
<span className="text-xs text-slate-600">Powered by Yahoo Finance • Free data • 60s refresh</span>
|
||||
<div className="px-5 py-2.5 border-t border-slate-800/50 flex justify-between items-center">
|
||||
<span className="text-xs text-slate-600">
|
||||
Sorted by Composite score · Unvalidated descriptive lens
|
||||
</span>
|
||||
<span className="text-xs text-slate-600">Data: Yahoo Finance · Refreshes every 90s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaderCard({ title, items = [], type }) {
|
||||
// ─── Leaderboard helper ───────────────────────────────────────────────────────
|
||||
function LeaderCard({ title, items = [], type, onSelect }) {
|
||||
return (
|
||||
<div className="bg-slate-900 border border-slate-700/50 rounded-xl p-3">
|
||||
<div className="text-xs font-semibold text-slate-400 mb-2">{title}</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="bg-slate-900 border border-slate-700/40 rounded-xl p-4">
|
||||
<div className="text-xs font-bold text-slate-400 mb-3 uppercase tracking-wider">{title}</div>
|
||||
<div className="space-y-2">
|
||||
{items.slice(0, 5).map(item => (
|
||||
<div key={item.symbol} className="flex items-center justify-between">
|
||||
<span className="text-white font-medium text-sm">{item.symbol}</span>
|
||||
<div
|
||||
key={item.symbol}
|
||||
onClick={() => onSelect?.(item.symbol)}
|
||||
className="flex items-center justify-between cursor-pointer hover:bg-slate-800/50 rounded-lg px-2 py-1 transition-colors group"
|
||||
>
|
||||
<div>
|
||||
<span className="text-white font-semibold text-sm group-hover:text-blue-400 transition-colors">
|
||||
{item.symbol}
|
||||
</span>
|
||||
<span className="text-slate-600 text-xs ml-1.5">{fmtPrice(item.price)}</span>
|
||||
</div>
|
||||
{type === 'volume' ? (
|
||||
<span className="text-slate-400 text-xs tabular-nums">
|
||||
{item.volume >= 1e6 ? `${(item.volume / 1e6).toFixed(1)}M` : item.volume}
|
||||
{item.volume >= 1e9 ? `${(item.volume / 1e9).toFixed(1)}B` :
|
||||
item.volume >= 1e6 ? `${(item.volume / 1e6).toFixed(1)}M` : item.volume}
|
||||
</span>
|
||||
) : (
|
||||
<ChangeBadge value={item.change_pct} />
|
||||
<span className={`text-sm font-semibold tabular-nums ${
|
||||
(item.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||||
}`}>
|
||||
{item.change_pct != null ? `${item.change_pct >= 0 ? '+' : ''}${item.change_pct.toFixed(2)}%` : '—'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <div className="text-slate-600 text-xs">Loading...</div>}
|
||||
{items.length === 0 && <div className="text-slate-600 text-xs py-2">Loading...</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1471,6 +1471,15 @@ export default function OptionsFlowPanel() {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Descriptive Disclaimer */}
|
||||
<div className="bg-yellow-950/20 border border-yellow-900/50 p-4 rounded-lg flex items-start gap-3 text-sm text-yellow-200/80 shadow-inner">
|
||||
<span className="text-xl leading-none">⚠️</span>
|
||||
<p>
|
||||
<strong>Descriptive Only.</strong> Options flow imbalance is shown strictly for market context and sentiment analysis.
|
||||
The "Smart Money" thesis has not been validated out-of-sample as a predictive signal by our engine. Do not treat flow as a buy/sell signal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Header with Date and Quick Actions */}
|
||||
<div className="flex items-center justify-between bg-slate-900/50 backdrop-blur-sm rounded-lg border border-slate-800/50 p-3">
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
|
|||
|
|
@ -55,6 +55,73 @@ function DirectionArrow({ dir }) {
|
|||
return <span className="text-slate-500">—</span>;
|
||||
}
|
||||
|
||||
function pctBar(pct) {
|
||||
if (pct == null) return <span className="text-slate-600 text-xs italic">Missing</span>;
|
||||
|
||||
let colorClass = 'bg-yellow-500'; // middle 50%
|
||||
if (pct >= 75) colorClass = 'bg-emerald-500'; // top 25%
|
||||
else if (pct < 25) colorClass = 'bg-red-500'; // bottom 25%
|
||||
|
||||
return (
|
||||
<div className="flex-1 mx-3 h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${colorClass} transition-all duration-500`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorLens({ profile }) {
|
||||
if (!profile) return null;
|
||||
const isIncomplete = profile.value == null || profile.quality == null || profile.lowVol == null || profile.momentum == null;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900/50 border border-slate-700/50 rounded-xl p-4 mb-6 relative group">
|
||||
<div className="flex items-center justify-between mb-3 border-b border-slate-800 pb-2">
|
||||
<span className="font-bold text-white text-sm flex items-center gap-2">
|
||||
📊 Fundamental Factor Lens
|
||||
<span className="text-[10px] uppercase tracking-wider font-bold bg-slate-800 text-blue-400 px-1.5 py-0.5 rounded border border-blue-500/30">
|
||||
Descriptive Only
|
||||
</span>
|
||||
</span>
|
||||
{isIncomplete && (
|
||||
<span className="text-[10px] text-orange-400 font-semibold bg-orange-950/50 px-2 py-1 rounded border border-orange-800/50">
|
||||
⚠️ INCOMPLETE DATA
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 w-20">Value</span>
|
||||
{pctBar(profile.value)}
|
||||
<span className="text-white w-16 text-right tabular-nums">{profile.value != null ? `${profile.value}th pct` : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 w-20">Quality</span>
|
||||
{pctBar(profile.quality)}
|
||||
<span className="text-white w-16 text-right tabular-nums">{profile.quality != null ? `${profile.quality}th pct` : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 w-20">Low-Vol</span>
|
||||
{pctBar(profile.lowVol)}
|
||||
<span className="text-white w-16 text-right tabular-nums">{profile.lowVol != null ? `${profile.lowVol}th pct` : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 w-20">Momentum</span>
|
||||
{pctBar(profile.momentum)}
|
||||
<span className="text-white w-16 text-right tabular-nums">{profile.momentum != null ? `${profile.momentum}th pct` : 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 flex items-center justify-center w-5 h-5 rounded-full bg-slate-800 text-slate-400 text-[10px] opacity-0 group-hover:opacity-100 transition-opacity" title={profile._disclaimer}>
|
||||
i
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StockDetailPanel({ symbol, onClose }) {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [stockData, setStockData] = useState(null);
|
||||
|
|
@ -183,7 +250,7 @@ export default function StockDetailPanel({ symbol, onClose }) {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'overview' && <OverviewTab q={q} f={f} t={t} />}
|
||||
{activeTab === 'overview' && <OverviewTab q={q} f={f} t={t} profile={stockData?.profile} />}
|
||||
{activeTab === 'fundamentals' && <FundamentalsTab f={f} />}
|
||||
{activeTab === 'technicals' && <TechnicalsTab t={t} q={q} />}
|
||||
{activeTab === 'holders' && <HoldersTab data={holders} loading={holdersLoading} />}
|
||||
|
|
@ -195,9 +262,11 @@ export default function StockDetailPanel({ symbol, onClose }) {
|
|||
);
|
||||
}
|
||||
|
||||
function OverviewTab({ q, f, t }) {
|
||||
function OverviewTab({ q, f, t, profile }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
{profile && <FactorLens profile={profile} />}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-widest text-slate-500 mb-3 font-semibold">Price Snapshot</div>
|
||||
<table className="w-full">
|
||||
|
|
@ -231,6 +300,7 @@ function OverviewTab({ q, f, t }) {
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import { Info, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function MethodologyModal({ isOpen, onClose }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="bg-slate-900 border border-slate-700 rounded-lg max-w-2xl w-full mx-auto shadow-2xl flex flex-col max-h-[90vh]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-800 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Info className="w-5 h-5 text-blue-400" />
|
||||
<h2 className="text-xl font-bold text-slate-100">
|
||||
Our Methodology: Why We Don't Sell Predictions
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-6 space-y-6 text-sm text-slate-300">
|
||||
<section>
|
||||
<h3 className="text-lg font-semibold text-slate-100 mb-2">The Out-of-Sample Reality</h3>
|
||||
<p className="mb-4">
|
||||
Most platforms show you backtests that work perfectly. We're showing you ours that didn't — because that's how you know our descriptive tools are honest.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
We built a highly sophisticated predictive scoring engine based on technical indicators and options flow. We optimized it, tuned the hyperparameters, and achieved a 63% win rate in-sample.
|
||||
<strong> Then, we tested it honestly.</strong> We ran a strict out-of-sample forward test on unseen data.
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-950 border border-slate-800 rounded-md overflow-hidden mb-4">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-800/50 text-slate-200">
|
||||
<th className="p-3 border-b border-slate-700 font-medium">Test Group</th>
|
||||
<th className="p-3 border-b border-slate-700 font-medium text-right">Expectancy (R-Multiple)</th>
|
||||
<th className="p-3 border-b border-slate-700 font-medium text-right">Hit Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="p-3 border-b border-slate-800 text-slate-300">Random Control (Coin Flip)</td>
|
||||
<td className="p-3 border-b border-slate-800 text-right font-mono">+0.027R</td>
|
||||
<td className="p-3 border-b border-slate-800 text-right font-mono">49.2%</td>
|
||||
</tr>
|
||||
<tr className="bg-red-950/20">
|
||||
<td className="p-3 text-red-200">Top-Graded "A" Signals</td>
|
||||
<td className="p-3 text-right font-mono text-red-300">+0.019R</td>
|
||||
<td className="p-3 text-right font-mono text-red-300">48.5%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-400 italic">
|
||||
<strong>The Result:</strong> The edge completely vanished. The highly-engineered signals failed to beat a random coin flip. So we stopped selling them.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-lg font-semibold text-slate-100 mb-2">The FactorLab Approach</h3>
|
||||
<p className="mb-3">
|
||||
Instead of black-box predictions, we built the <strong>FactorLab</strong>. This is a purely descriptive lens.
|
||||
</p>
|
||||
<p className="mb-3">
|
||||
We take the Top 50 liquid stocks and ETFs, ingest their raw fundamentals and technicals, and mathematically rank them across four academic factors:
|
||||
<span className="text-blue-300 ml-1">Value</span>,
|
||||
<span className="text-emerald-300 mx-1">Quality</span>,
|
||||
<span className="text-purple-300 mx-1">Low-Volatility</span>, and
|
||||
<span className="text-orange-300 ml-1">Momentum</span>.
|
||||
</p>
|
||||
<p>
|
||||
When you see a stock is in the 95th percentile for Quality, it is a mathematical fact about its balance sheet relative to its peers. <strong>It is not a buy recommendation.</strong> Think of this tool like Zillow for stocks: we show you the facts fast, but you still do the homework and make the decision.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-slate-800 bg-slate-900 rounded-b-lg flex justify-end">
|
||||
<Button onClick={onClose} className="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded">
|
||||
Got it, I'll do my own homework
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
k8s/app.yaml
42
k8s/app.yaml
|
|
@ -32,17 +32,49 @@ spec:
|
|||
name: market-db-app
|
||||
key: uri
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
tcpSocket:
|
||||
port: 3010
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
failureThreshold: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
tcpSocket:
|
||||
port: 3010
|
||||
initialDelaySeconds: 5
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
- name: python-service
|
||||
image: python:3.11-slim
|
||||
command: ['sh', '-c', 'pip install --no-cache-dir -r requirements.txt && uvicorn main:app --host 0.0.0.0 --port 8010']
|
||||
workingDir: /shared-code
|
||||
ports:
|
||||
- containerPort: 8010
|
||||
env:
|
||||
- name: USE_LOCAL_DB
|
||||
value: "true"
|
||||
- name: LOCAL_DB_HOST
|
||||
value: "192.168.8.151"
|
||||
- name: LOCAL_DB_PORT
|
||||
value: "5432"
|
||||
- name: LOCAL_DB_USER
|
||||
value: "postgres"
|
||||
- name: LOCAL_DB_PASSWORD
|
||||
value: "postgres"
|
||||
- name: LOCAL_DB_NAME
|
||||
value: "institutional_trader"
|
||||
volumeMounts:
|
||||
- name: shared-code
|
||||
mountPath: /shared-code
|
||||
initContainers:
|
||||
- name: copy-python-code
|
||||
image: 192.168.8.250:5000/market:latest
|
||||
command: ['sh', '-c', 'cp -r /app/backend/python_service/* /shared-code/']
|
||||
volumeMounts:
|
||||
- name: shared-code
|
||||
mountPath: /shared-code
|
||||
volumes:
|
||||
- name: shared-code
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
|
|
|||
Loading…
Reference in New Issue