Compare commits
No commits in common. "main" and "jan12026" have entirely different histories.
|
|
@ -10,7 +10,14 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build and push
|
||||
- name: Build and push (Kaniko)
|
||||
run: |
|
||||
docker build -t 192.168.8.250:5000/market:latest .
|
||||
docker push 192.168.8.250:5000/market:latest
|
||||
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
|
||||
|
|
|
|||
13
Dockerfile
13
Dockerfile
|
|
@ -10,12 +10,6 @@ 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
|
||||
|
|
@ -23,9 +17,6 @@ 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
|
||||
|
|
@ -37,7 +28,3 @@ ENV PORT=3010
|
|||
EXPOSE 3010
|
||||
|
||||
CMD ["node", "src/server.js"]
|
||||
|
||||
# Cache bust 20260625222919
|
||||
|
||||
# Cache bust 20260625223615
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@
|
|||
"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",
|
||||
|
|
@ -935,12 +933,6 @@
|
|||
"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",
|
||||
|
|
@ -1082,54 +1074,6 @@
|
|||
"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,8 +33,6 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
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
|
|
@ -1,16 +0,0 @@
|
|||
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,
|
||||
}
|
||||
};
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
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)
|
||||
};
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
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';
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
{
|
||||
"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
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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();
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
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
|
|
@ -1,68 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
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();
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
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."
|
||||
};
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
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();
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
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
|
||||
};
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
// 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 : [];
|
||||
}
|
||||
|
|
@ -3,25 +3,16 @@ import helmet from 'helmet';
|
|||
import cors from 'cors';
|
||||
|
||||
export const setupSecurity = (app) => {
|
||||
// Trust proxy for express-rate-limit behind Kubernetes Ingress
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Helmet for security headers
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", 'https://static.cloudflareinsights.com'],
|
||||
scriptSrcElem: ["'self'", "'unsafe-inline'", 'https://static.cloudflareinsights.com'],
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
connectSrc: ["'self'", "ws:", "wss:", "http:", "https:"],
|
||||
workerSrc: ["'self'", 'blob:'],
|
||||
fontSrc: ["'self'", 'https:', 'data:'],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}));
|
||||
|
||||
// CORS
|
||||
|
|
@ -48,15 +39,8 @@ export const setupSecurity = (app) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Default fallback (shouldn't reach here if CORS_ORIGIN is set or in dev mode)
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -293,8 +293,8 @@ router.get('/recent', async (req, res) => {
|
|||
});
|
||||
|
||||
// WebSocket for real-time alerts
|
||||
export function setupAlertsWebSocket() {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
export function setupAlertsWebSocket(server) {
|
||||
const wss = new WebSocketServer({ server, path: '/ws/alerts' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('Client connected to alerts feed');
|
||||
|
|
|
|||
|
|
@ -102,8 +102,7 @@ router.get('/', async (req, res) => {
|
|||
};
|
||||
}
|
||||
|
||||
// Only return 503 if the server itself is broken — degraded means optional services are down
|
||||
const statusCode = health.status === 'error' ? 503 : 200;
|
||||
const statusCode = health.status === 'ok' ? 200 : 503;
|
||||
res.status(statusCode).json(health);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,15 +7,12 @@
|
|||
import express from 'express';
|
||||
import NodeCache from 'node-cache';
|
||||
import { UNIVERSE, getSymbolMeta, classifyCapTier } from '../services/stockUniverseService.js';
|
||||
import { fetchBasicQuote, fetchQuoteSummary, fetchTechnicals, fetchFullAnalysis, fetchMacroPerformance } from '../services/fundamentalsService.js';
|
||||
import { fetchBasicQuote, fetchQuoteSummary, fetchTechnicals, fetchFullAnalysis } 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();
|
||||
// Default cache TTL to 5 minutes (300s) to protect against rate limits on large universe
|
||||
const universeCache = new NodeCache({ stdTTL: 300 });
|
||||
const universeCache = new NodeCache({ stdTTL: 60 });
|
||||
|
||||
/**
|
||||
* GET /api/market/universe
|
||||
|
|
@ -52,8 +49,6 @@ router.get('/universe', async (req, res) => {
|
|||
change_pct: quote?.change_pct ?? null,
|
||||
market_cap: quote?.market_cap ?? null,
|
||||
volume: quote?.volume ?? null,
|
||||
day_high: quote?.high ?? null,
|
||||
day_low: quote?.low ?? null,
|
||||
high_52w: null, // populated by quoteSummary if needed
|
||||
low_52w: null,
|
||||
market_state: quote?.market_state ?? null,
|
||||
|
|
@ -105,15 +100,12 @@ 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) {
|
||||
|
|
@ -220,113 +212,4 @@ 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;
|
||||
|
|
|
|||
|
|
@ -31,8 +31,7 @@ router.get('/detect', async (req, res) => {
|
|||
});
|
||||
}
|
||||
|
||||
const returnAll = !!symbols;
|
||||
const reversals = await detectReversals(symbolList, returnAll);
|
||||
const reversals = await detectReversals(symbolList);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { WebSocketServer } from 'ws';
|
|||
import { rawQuery } from '../db.js';
|
||||
import { optionsFlowQuery } from '../queries/optionsFlowQuery.js';
|
||||
|
||||
export function setupWebSocket() {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
export function setupWebSocket(server) {
|
||||
const wss = new WebSocketServer({ server, path: '/ws/flow' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('Client connected to WebSocket');
|
||||
|
|
|
|||
|
|
@ -46,17 +46,6 @@ 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);
|
||||
|
|
@ -72,15 +61,19 @@ app.use('/api/market', marketAnalysisRouter);
|
|||
app.use('/api/backtest', backtestRouter);
|
||||
app.use('/health', healthRouter);
|
||||
|
||||
// SPA fallback — only for non-API, non-asset routes
|
||||
// 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
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api/') || req.path.startsWith('/health') || req.path.startsWith('/assets/')) {
|
||||
if (req.path.startsWith('/api/') || req.path.startsWith('/health')) {
|
||||
return next();
|
||||
}
|
||||
const indexPath = path.join(frontendPath, 'index.html');
|
||||
res.sendFile(indexPath, (err) => {
|
||||
if (err) next(err);
|
||||
});
|
||||
res.sendFile(path.join(frontendPath, 'index.html'));
|
||||
});
|
||||
|
||||
// Error handler (must be last)
|
||||
|
|
@ -127,25 +120,9 @@ const server = app.listen(PORT, async () => {
|
|||
}
|
||||
});
|
||||
|
||||
// Setup WebSockets manually to handle multiple paths
|
||||
const flowWss = setupWebSocket();
|
||||
const alertsWss = setupAlertsWebSocket();
|
||||
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const pathname = request.url.split('?')[0];
|
||||
|
||||
if (pathname === '/ws/flow') {
|
||||
flowWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
flowWss.emit('connection', ws, request);
|
||||
});
|
||||
} else if (pathname === '/ws/alerts') {
|
||||
alertsWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
alertsWss.emit('connection', ws, request);
|
||||
});
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
// Setup WebSocket
|
||||
setupWebSocket(server);
|
||||
setupAlertsWebSocket(server);
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
|
|
|
|||
|
|
@ -303,26 +303,20 @@ ${yahooData ? `STOCK DATA (Yahoo Finance):
|
|||
- Volume: ${yahooData.volume?.toLocaleString() || 'N/A'}
|
||||
` : ''}
|
||||
|
||||
Provide a detailed analysis categorized into three distinct trader profiles based on the data.
|
||||
Provide a concise analysis (2-3 sentences max) with:
|
||||
1. Overall assessment (Bullish/Bearish/Neutral)
|
||||
2. Key risk factors or opportunities
|
||||
3. Clear action recommendation (Enter/Wait/Avoid)
|
||||
|
||||
Format as JSON:
|
||||
{
|
||||
"longTerm": {
|
||||
"assessment": "BULLISH|BEARISH|NEUTRAL",
|
||||
"moatAndFundamentals": "Brief analysis of economic moat, EPS growth, and fundamental ratios (P/E, P/B)",
|
||||
"recommendation": "ENTER|WAIT|AVOID"
|
||||
},
|
||||
"swing": {
|
||||
"assessment": "BULLISH|BEARISH|NEUTRAL",
|
||||
"technicalSetup": "Analysis of technical setups, consolidation, moving averages, and RSI momentum",
|
||||
"recommendation": "ENTER|WAIT|AVOID"
|
||||
},
|
||||
"dayTrade": {
|
||||
"assessment": "BULLISH|BEARISH|NEUTRAL",
|
||||
"volatilityAndLiquidity": "Analysis of immediate catalysts, intraday liquidity/volume, and volatility risks",
|
||||
"recommendation": "ENTER|WAIT|AVOID"
|
||||
},
|
||||
"summary": "Overall 1-2 sentence conclusion covering the most viable approach"
|
||||
"assessment": "BULLISH|BEARISH|NEUTRAL",
|
||||
"summary": "Brief 1-2 sentence summary",
|
||||
"keyFactors": ["factor1", "factor2", "factor3"],
|
||||
"recommendation": "ENTER|WAIT|AVOID",
|
||||
"reasoning": "Brief explanation",
|
||||
"riskLevel": "LOW|MEDIUM|HIGH",
|
||||
"timeHorizon": "INTRADAY|SWING|POSITION"
|
||||
}`;
|
||||
|
||||
// Use model from env or default to stable version
|
||||
|
|
@ -352,22 +346,13 @@ Format as JSON:
|
|||
// Fallback: create structured response from text
|
||||
console.warn('Failed to parse AI response as JSON, using fallback');
|
||||
analysis = {
|
||||
longTerm: {
|
||||
assessment: direction,
|
||||
moatAndFundamentals: 'Unable to perform deep fundamental analysis. Please review ratios manually.',
|
||||
recommendation: score >= 5.0 ? 'ENTER' : 'WAIT'
|
||||
},
|
||||
swing: {
|
||||
assessment: direction,
|
||||
technicalSetup: 'Technical analysis currently unavailable.',
|
||||
recommendation: score >= 3.0 ? 'ENTER' : 'WAIT'
|
||||
},
|
||||
dayTrade: {
|
||||
assessment: direction,
|
||||
volatilityAndLiquidity: 'Analysis of intraday volume and catalysts unavailable.',
|
||||
recommendation: score >= 2.0 ? 'ENTER' : 'WAIT'
|
||||
},
|
||||
summary: responseText.substring(0, 200) || 'Analysis unavailable'
|
||||
assessment: direction,
|
||||
summary: responseText.substring(0, 200),
|
||||
keyFactors: [],
|
||||
recommendation: score >= 2.0 ? 'ENTER' : 'WAIT',
|
||||
reasoning: responseText,
|
||||
riskLevel: score < 2.0 ? 'HIGH' : score < 5.0 ? 'MEDIUM' : 'LOW',
|
||||
timeHorizon: 'INTRADAY'
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -384,10 +369,13 @@ Format as JSON:
|
|||
success: false,
|
||||
error: error.message,
|
||||
analysis: {
|
||||
longTerm: { assessment: 'NEUTRAL', moatAndFundamentals: 'Analysis unavailable', recommendation: 'WAIT' },
|
||||
swing: { assessment: 'NEUTRAL', technicalSetup: 'Analysis unavailable', recommendation: 'WAIT' },
|
||||
dayTrade: { assessment: 'NEUTRAL', volatilityAndLiquidity: 'Analysis unavailable', recommendation: 'WAIT' },
|
||||
summary: 'Analysis unavailable'
|
||||
assessment: 'NEUTRAL',
|
||||
summary: 'Analysis unavailable',
|
||||
keyFactors: [],
|
||||
recommendation: 'WAIT',
|
||||
reasoning: 'Unable to generate analysis',
|
||||
riskLevel: 'MEDIUM',
|
||||
timeHorizon: 'INTRADAY'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
/**
|
||||
* 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,51 +81,6 @@ 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
|
||||
*/
|
||||
|
|
@ -231,34 +186,18 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
/**
|
||||
* 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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { calculateBadges } from './badgeCalculator.js';
|
|||
* Compares current badges vs 15min ago vs 30min ago
|
||||
*/
|
||||
|
||||
export async function detectReversals(symbolList, returnAll = false) {
|
||||
export async function detectReversals(symbolList) {
|
||||
const reversals = [];
|
||||
|
||||
for (const symbol of symbolList) {
|
||||
|
|
@ -63,25 +63,10 @@ export async function detectReversals(symbolList, returnAll = false) {
|
|||
currentPremium: parseFloat(current.premium_num || 0),
|
||||
previousPremium: parseFloat(prev15min.premium_num || 0),
|
||||
priceChange,
|
||||
signal: currentBadge === '🟢' ? 'REVERSAL_BULLISH' : 'REVERSAL_BEARISH',
|
||||
isReversal: true
|
||||
signal: currentBadge === '🟢' ? 'REVERSAL_BULLISH' : 'REVERSAL_BEARISH'
|
||||
};
|
||||
|
||||
reversals.push(reversal);
|
||||
} else if (returnAll && current) {
|
||||
// Return current status even if no reversal
|
||||
const currentBadge = current.badges?.round || '⚪';
|
||||
reversals.push({
|
||||
symbol: symbol.toUpperCase(),
|
||||
from: currentBadge,
|
||||
to: currentBadge,
|
||||
timestamp: new Date(current.timestamp).toISOString(),
|
||||
currentPremium: parseFloat(current.premium_num || 0),
|
||||
previousPremium: 0,
|
||||
priceChange: '0%',
|
||||
signal: currentBadge === '🟢' ? 'BULLISH' : currentBadge === '🔴' ? 'BEARISH' : 'NEUTRAL',
|
||||
isReversal: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,197 +1,3 @@
|
|||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
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,162 +14,69 @@ const CAP_TIERS = {
|
|||
// Below $2B = SMALL
|
||||
};
|
||||
|
||||
// Master universe of ~200 carefully selected global stocks + ETFs
|
||||
// Master universe of top 50 stocks + ETFs
|
||||
export const UNIVERSE = [
|
||||
// --- Mega-cap Tech & Comm Services ---
|
||||
// Mega-cap Tech
|
||||
{ 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: 'Communication', isETF: false },
|
||||
{ symbol: 'META', name: 'Meta Platforms', sector: 'Communication', isETF: false },
|
||||
{ symbol: 'GOOGL', name: 'Alphabet Inc.', sector: 'Technology', isETF: false },
|
||||
{ symbol: 'META', name: 'Meta Platforms', sector: 'Technology', 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 },
|
||||
{ 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 ---
|
||||
// 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 },
|
||||
{ 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 ---
|
||||
// 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 },
|
||||
{ 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 ---
|
||||
// Energy
|
||||
{ symbol: 'XOM', name: 'Exxon Mobil', sector: 'Energy', isETF: false },
|
||||
{ symbol: 'CVX', name: 'Chevron Corp.', sector: 'Energy', isETF: false },
|
||||
{ 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 ---
|
||||
// Industrials
|
||||
{ symbol: 'CAT', name: 'Caterpillar Inc.', sector: 'Industrials', isETF: false },
|
||||
{ symbol: 'HON', name: 'Honeywell Intl.', sector: 'Industrials', isETF: false },
|
||||
{ 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 ---
|
||||
// Consumer
|
||||
{ symbol: 'WMT', name: 'Walmart Inc.', sector: 'Consumer Staples', isETF: false },
|
||||
{ symbol: 'COST', name: 'Costco Wholesale', sector: 'Consumer Staples', isETF: false },
|
||||
{ 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 ---
|
||||
// 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: '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 },
|
||||
{ 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 },
|
||||
// Broad ETFs
|
||||
{ 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 },
|
||||
|
||||
// --- Sector & Specialty ETFs ---
|
||||
{ symbol: 'DIA', name: 'SPDR Dow Jones ETF', sector: 'ETF', isETF: true },
|
||||
// Sector 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 },
|
||||
{ symbol: 'XLU', name: 'Utilities Select SPDR', sector: 'ETF', isETF: true },
|
||||
{ symbol: 'XLI', name: 'Industrial Select SPDR', sector: 'ETF', isETF: true },
|
||||
// Specialty ETFs
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.12.2",
|
||||
"clsx": "^2.0.0",
|
||||
"d3": "^7.8.5",
|
||||
"lightweight-charts": "^4.1.3",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
|
@ -1558,6 +1560,417 @@
|
|||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "3",
|
||||
"d3-axis": "3",
|
||||
"d3-brush": "3",
|
||||
"d3-chord": "3",
|
||||
"d3-color": "3",
|
||||
"d3-contour": "4",
|
||||
"d3-delaunay": "6",
|
||||
"d3-dispatch": "3",
|
||||
"d3-drag": "3",
|
||||
"d3-dsv": "3",
|
||||
"d3-ease": "3",
|
||||
"d3-fetch": "3",
|
||||
"d3-force": "3",
|
||||
"d3-format": "3",
|
||||
"d3-geo": "3",
|
||||
"d3-hierarchy": "3",
|
||||
"d3-interpolate": "3",
|
||||
"d3-path": "3",
|
||||
"d3-polygon": "3",
|
||||
"d3-quadtree": "3",
|
||||
"d3-random": "3",
|
||||
"d3-scale": "4",
|
||||
"d3-scale-chromatic": "3",
|
||||
"d3-selection": "3",
|
||||
"d3-shape": "3",
|
||||
"d3-time": "3",
|
||||
"d3-time-format": "4",
|
||||
"d3-timer": "3",
|
||||
"d3-transition": "3",
|
||||
"d3-zoom": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-axis": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-brush": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "3",
|
||||
"d3-transition": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-chord": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-contour": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"delaunator": "5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "7",
|
||||
"iconv-lite": "0.6",
|
||||
"rw": "1"
|
||||
},
|
||||
"bin": {
|
||||
"csv2json": "bin/dsv2json.js",
|
||||
"csv2tsv": "bin/dsv2dsv.js",
|
||||
"dsv2dsv": "bin/dsv2dsv.js",
|
||||
"dsv2json": "bin/dsv2json.js",
|
||||
"json2csv": "bin/json2dsv.js",
|
||||
"json2dsv": "bin/json2dsv.js",
|
||||
"json2tsv": "bin/json2dsv.js",
|
||||
"tsv2csv": "bin/dsv2dsv.js",
|
||||
"tsv2json": "bin/dsv2json.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dsv/node_modules/commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-fetch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dsv": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-hierarchy": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-polygon": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-random": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -1586,6 +1999,15 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/delaunator": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
|
||||
"integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
|
|
@ -1663,6 +2085,12 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fancy-canvas": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
|
||||
"integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
|
|
@ -1801,6 +2229,27 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
|
|
@ -1945,6 +2394,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lightweight-charts": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-4.2.3.tgz",
|
||||
"integrity": "sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"fancy-canvas": "2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
|
|
@ -2456,6 +2914,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||
|
|
@ -2566,6 +3030,18 @@
|
|||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
|
|
|
|||
|
|
@ -3,96 +3,103 @@ 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 MethodologyModal from '@/components/modals/MethodologyModal';
|
||||
import AlertsFeed from '@/components/dashboard/AlertsFeed';
|
||||
import Watchlist from '@/components/dashboard/Watchlist';
|
||||
import PerformanceTrackingPanel from '@/components/dashboard/PerformanceTrackingPanel';
|
||||
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 ReversalScreenerPanel from '@/components/dashboard/ReversalScreenerPanel';
|
||||
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 sticky top-0 z-40">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<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">
|
||||
FactorLab: Transparent Market Research
|
||||
🚀 Institutional Flow Platform
|
||||
</h1>
|
||||
<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 className="text-sm text-slate-400 mt-1">
|
||||
Real-time options flow · Tape analysis · Pro-grade signals
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<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-right">
|
||||
<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 — full width */}
|
||||
{/* Main Content */}
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<Tabs defaultValue="market" className="w-full">
|
||||
<TabsList className="bg-slate-900 border border-slate-800">
|
||||
<TabsTrigger value="market" className="data-[state=active]:bg-slate-800">
|
||||
📊 Market Analysis
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="flow" className="data-[state=active]:bg-slate-800">
|
||||
🎯 Options Flow
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="reversals" className="data-[state=active]:bg-slate-800">
|
||||
🔄 Reversal Screener
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scanner" className="data-[state=active]:bg-slate-800">
|
||||
🔍 Multi-Signal Scanner
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="backtest" className="data-[state=active]:bg-slate-800">
|
||||
📊 Backtests
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<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>
|
||||
|
||||
<TabsContent value="market" className="mt-6 space-y-6">
|
||||
<MarketScreenerPanel
|
||||
selectedSymbol={selectedSymbol}
|
||||
onSelectSymbol={setSelectedSymbol}
|
||||
onCloseSymbol={() => setSelectedSymbol(null)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="market" className="mt-6 space-y-6">
|
||||
<MarketScreenerPanel onSelectSymbol={setSelectedSymbol} />
|
||||
{selectedSymbol && (
|
||||
<StockDetailPanel
|
||||
symbol={selectedSymbol}
|
||||
onClose={() => setSelectedSymbol(null)}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="flow" className="mt-6">
|
||||
<OptionsFlowPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="flow" className="mt-6">
|
||||
<OptionsFlowPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="reversals" className="mt-6">
|
||||
<ReversalScreenerPanel onSelectSymbol={setSelectedSymbol} />
|
||||
</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>
|
||||
|
||||
<TabsContent value="backtest" className="mt-6">
|
||||
<BacktestPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue