UI enhancemnts

This commit is contained in:
saikiranvella
2026-06-09 19:34:31 -04:00
parent 5c8cd8935a
commit 662a717916
55 changed files with 6226 additions and 465 deletions
+2
View File
@@ -119,9 +119,11 @@ export class AuthService {
this.#store.createResetToken(user.id, token, expiresAt);
const link = `${appOrigin}/auth/reset-password?token=${token}`;
/* eslint-disable no-console -- no mailer yet: reset link must reach the operator's terminal */
console.log('\n🔐 Password reset requested for:', email);
console.log(' Link (expires in 1 hour):');
console.log(` ${link}\n`);
/* eslint-enable no-console */
}
/**
+8 -8
View File
@@ -143,7 +143,7 @@ export class ScreenerEngine {
asset,
fundamental,
inflated,
signal: this.signal(fundamental.label, inflated.label),
signal: this.signal(fundamental, inflated),
});
} catch (err) {
results.ERROR.push({
@@ -184,13 +184,13 @@ export class ScreenerEngine {
}
}
private signal(fundamentalLabel: string, inflatedLabel: string): Signal {
const green = (l: string) => l.startsWith('🟢');
const yellow = (l: string) => l.startsWith('🟡');
if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY;
if (green(inflatedLabel) && yellow(fundamentalLabel)) return SIGNAL.MOMENTUM;
if (green(inflatedLabel) && !green(fundamentalLabel)) return SIGNAL.SPECULATION;
if (yellow(fundamentalLabel) || yellow(inflatedLabel)) return SIGNAL.NEUTRAL;
// Signal derives from the structured verdict tier — never from label strings.
// Rewording a display label can no longer silently corrupt signals.
private signal(fundamental: ScoreResult, inflated: ScoreResult): Signal {
if (fundamental.tier === 'PASS') return SIGNAL.STRONG_BUY;
if (inflated.tier === 'PASS' && fundamental.tier === 'HOLD') return SIGNAL.MOMENTUM;
if (inflated.tier === 'PASS') return SIGNAL.SPECULATION;
if (fundamental.tier === 'HOLD' || inflated.tier === 'HOLD') return SIGNAL.NEUTRAL;
return SIGNAL.AVOID;
}
@@ -22,6 +22,8 @@ export class BondScorer {
if (metrics.creditRatingNumeric < gates.minCreditRating) {
return {
label: '🔴 REJECT',
tier: 'REJECT',
score: null,
scoreSummary: `Credit rating gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
audit: {
passedGates: false,
@@ -42,6 +44,8 @@ export class BondScorer {
return {
label: score >= 4 ? '🟢 Attractive' : score >= 1 ? '🟡 Neutral' : '🔴 Avoid',
tier: score >= 4 ? 'PASS' : score >= 1 ? 'HOLD' : 'REJECT',
score,
scoreSummary: `Score: ${score}`,
audit: { passedGates: true, breakdown },
};
+51 -18
View File
@@ -1,6 +1,13 @@
import type { EtfMetrics, ScoreResult } from '../../../domains/shared';
export class EtfScorer {
/** Parse to a finite number, preserving null for missing data. */
private static n(v: unknown): number | null {
if (v == null) return null;
const f = parseFloat(String(v));
return Number.isFinite(f) ? f : null;
}
static score(
m: EtfMetrics,
rules: {
@@ -11,51 +18,77 @@ export class EtfScorer {
): ScoreResult {
const { gates, weights, thresholds } = rules;
const metrics = {
expenseRatio: parseFloat(String(m.expenseRatio)) || 0,
yield: parseFloat(String(m.yield)) || 0,
volume: parseFloat(String(m.volume)) || 0,
fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0,
expenseRatio: EtfScorer.n(m.expenseRatio),
yield: EtfScorer.n(m.yield),
volume: EtfScorer.n(m.volume),
fiveYearReturn: EtfScorer.n(m.fiveYearReturn),
};
// Gates are only checked when the underlying data exists — missing data
// skips the gate (same convention as StockScorer) instead of auto-failing.
const failures: string[] = [];
if (metrics.expenseRatio > gates.maxExpenseRatio) {
if (metrics.expenseRatio != null && metrics.expenseRatio > gates.maxExpenseRatio) {
failures.push(`Expense ratio: ${metrics.expenseRatio} > ${gates.maxExpenseRatio}`);
}
if (
metrics.fiveYearReturn != null &&
thresholds.minFiveYearReturn != null &&
metrics.fiveYearReturn < thresholds.minFiveYearReturn
) {
failures.push(`5-year return: ${metrics.fiveYearReturn}% < ${thresholds.minFiveYearReturn}%`);
}
if (thresholds.minVolume != null && metrics.volume < thresholds.minVolume) {
if (
metrics.volume != null &&
thresholds.minVolume != null &&
metrics.volume < thresholds.minVolume
) {
failures.push(`Volume: ${metrics.volume} < ${thresholds.minVolume}`);
}
if (failures.length > 0) {
return {
label: '🔴 REJECT',
tier: 'REJECT',
score: null,
scoreSummary: `Gate failed: ${failures.map((f) => f.split(':')[0]).join(', ')}`,
audit: { passedGates: false, failures },
};
}
const breakdown: Record<string, number> = {
cost: metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3,
yield: metrics.yield >= thresholds.minYield ? weights.yield : -1,
vol: metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2,
fiveYearReturn:
thresholds.minFiveYearReturn != null
? metrics.fiveYearReturn >= thresholds.minFiveYearReturn
? (weights.fiveYearReturn ?? 1)
: -1
: 0,
};
// Factors only fire when the underlying data exists.
const breakdown: Record<string, number> = {};
if (metrics.expenseRatio != null) {
breakdown.cost = metrics.expenseRatio <= thresholds.maxExpense ? weights.lowCost : -3;
}
if (metrics.yield != null) {
breakdown.yield = metrics.yield >= thresholds.minYield ? weights.yield : -1;
}
if (metrics.volume != null) {
breakdown.vol = metrics.volume >= (thresholds.minVolume ?? 1_000_000) ? 0 : -2;
}
if (metrics.fiveYearReturn != null && thresholds.minFiveYearReturn != null) {
breakdown.fiveYearReturn =
metrics.fiveYearReturn >= thresholds.minFiveYearReturn ? (weights.fiveYearReturn ?? 1) : -1;
}
const activeFactors = Object.keys(breakdown).length;
const score = Object.values(breakdown).reduce((a, b) => a + b, 0);
if (activeFactors === 0) {
return {
label: '🟡 Neutral (No Data)',
tier: 'HOLD',
score: 0,
scoreSummary: 'Score: 0 (no metrics available)',
audit: { passedGates: true, breakdown, coverage: { active: 0, total: 4 } },
};
}
return {
label: score >= 3 ? '🟢 Efficient' : score >= 0 ? '🟡 Neutral' : '🔴 Expensive/Low Yield',
tier: score >= 3 ? 'PASS' : score >= 0 ? 'HOLD' : 'REJECT',
score,
scoreSummary: `Score: ${score}`,
audit: { passedGates: true, breakdown },
audit: { passedGates: true, breakdown, coverage: { active: activeFactors, total: 4 } },
};
}
}
+55 -6
View File
@@ -1,9 +1,24 @@
import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../../../domains/shared';
export class StockScorer {
/**
* Parse to a finite number, preserving 0 — zero is a real value for metrics
* like revenueGrowth (stagnant), debtToEquity (debt-free), or
* dcfMarginOfSafety (exactly fair value).
*/
private static n(v: unknown): NumVal {
if (v == null) return null;
const f = parseFloat(String(v));
return !isNaN(f) && f !== 0 ? f : null;
return Number.isFinite(f) ? f : null;
}
/**
* Parse to a strictly positive number. Used for ratios where 0 is
* impossible and indicates junk/missing data (P/E, PEG, P/B, P/FFO).
*/
private static pos(v: unknown): NumVal {
const f = StockScorer.n(v);
return f != null && f > 0 ? f : null;
}
private static scoreValue(val: number, high: number, med: number, weight: number): number {
@@ -46,6 +61,8 @@ export class StockScorer {
if (failures.length > 0) {
return {
label: '🔴 REJECT',
tier: 'REJECT',
score: null,
scoreSummary: `Gate failed: ${failures.join(' | ')}`,
audit: { passedGates: false, failures },
};
@@ -172,6 +189,8 @@ export class StockScorer {
breakdown[f.key] = f.fn() as number;
return sum + breakdown[f.key];
}, 0);
const activeFactors = Object.keys(breakdown).length;
const coverage = { active: activeFactors, total: factors.length };
const riskFlags = [
m.beta != null && m.beta > 1.5 && `High volatility (β ${m.beta.toFixed(2)})`,
@@ -207,10 +226,34 @@ export class StockScorer {
`DCF: stock trading ${Math.abs(m.dcfMarginOfSafety).toFixed(0)}% above intrinsic value`,
].filter(Boolean) as string[];
// No factor had data — distinguish "insufficient data" from a genuine
// neutral score so the UI doesn't present an unknown as a Hold verdict.
if (activeFactors === 0) {
return {
label: '🟡 HOLD (No Data)',
tier: 'HOLD',
score: 0,
scoreSummary: 'Score: 0 (no scoring factors had data)',
audit: {
passedGates: true,
breakdown,
riskFlags: riskFlags.length ? riskFlags : null,
coverage,
},
};
}
return {
label: StockScorer.label(totalScore),
tier: StockScorer.tier(totalScore),
score: totalScore,
scoreSummary: `Score: ${totalScore}`,
audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null },
audit: {
passedGates: true,
breakdown,
riskFlags: riskFlags.length ? riskFlags : null,
coverage,
},
};
}
@@ -221,6 +264,12 @@ export class StockScorer {
return '🔴 REJECT';
}
private static tier(score: number): 'PASS' | 'HOLD' | 'REJECT' {
if (score >= 4) return 'PASS';
if (score >= 0) return 'HOLD';
return 'REJECT';
}
private static sanitize(m: StockMetrics): SanitizedMetrics {
const w52 =
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
@@ -229,16 +278,16 @@ export class StockScorer {
return {
debtToEquity: StockScorer.n(m.debtToEquity),
quickRatio: StockScorer.n(m.quickRatio),
peRatio: StockScorer.n(m.peRatio),
pegRatio: StockScorer.n(m.pegRatio),
priceToBook: StockScorer.n(m.priceToBook),
peRatio: StockScorer.pos(m.peRatio),
pegRatio: StockScorer.pos(m.pegRatio),
priceToBook: StockScorer.pos(m.priceToBook),
netProfitMargin: StockScorer.n(m.netProfitMargin),
operatingMargin: StockScorer.n(m.operatingMargin),
returnOnEquity: StockScorer.n(m.returnOnEquity),
revenueGrowth: StockScorer.n(m.revenueGrowth),
fcfYield: StockScorer.n(m.fcfYield),
dividendYield: StockScorer.n(m.dividendYield),
pFFO: StockScorer.n(m.pFFO),
pFFO: StockScorer.pos(m.pFFO),
beta: StockScorer.n(m.beta),
week52Position: w52,
week52Change: StockScorer.n(m.week52Change),
+51 -2
View File
@@ -1,13 +1,15 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ScreenerEngine } from './ScreenerEngine';
import { CatalystCache } from '../../domains/shared';
import type { LiveAssetResult } from '../../domains/shared';
import { CatalystCache, SignalSnapshotRepository } from '../../domains/shared';
import type { LiveAssetResult, ScreenerResult } from '../../domains/shared';
import { screenSchema } from '../../domains/shared/types/schemas';
export class ScreenerController {
constructor(
private readonly engine: ScreenerEngine,
private readonly catalystCache: CatalystCache,
// Optional so tests and minimal setups work without a database.
private readonly snapshots?: SignalSnapshotRepository,
) {}
register(app: FastifyInstance): void {
@@ -21,6 +23,29 @@ export class ScreenerController {
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
this.catalysts.bind(this),
);
app.get('/api/screen/history/:ticker', this.history.bind(this));
}
/** Signal snapshot history for one ticker (P0.1 ledger read side). */
private async history(req: FastifyRequest) {
if (!this.snapshots) return { ticker: null, snapshots: [] };
const { ticker } = req.params as { ticker: string };
return {
ticker: ticker.toUpperCase(),
snapshots: this.snapshots.history(ticker).map((row) => ({
date: row.snapshot_date,
signal: row.signal,
price: row.price,
fundamental: { tier: row.fundamental_tier, score: row.fundamental_score },
inflated: { tier: row.inflated_tier, score: row.inflated_score },
coverage:
row.coverage_active != null
? { active: row.coverage_active, total: row.coverage_total }
: null,
riskFlags: row.risk_flags ? JSON.parse(row.risk_flags) : [],
rateRegime: row.rate_regime,
})),
};
}
private static serializeAssets(arr: LiveAssetResult[]) {
@@ -39,6 +64,7 @@ export class ScreenerController {
private async screen(req: FastifyRequest) {
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
const results = await this.engine.screenTickers(tickers);
this.recordSnapshots(results, req);
return {
...results,
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
@@ -47,6 +73,29 @@ export class ScreenerController {
};
}
/**
* P0.1 signal track record — persist one snapshot per asset per day.
* Best-effort: a snapshot failure must never fail the screen response.
*/
private recordSnapshots(results: ScreenerResult, req: FastifyRequest): void {
if (!this.snapshots) return;
try {
const rateRegime = results.marketContext?.rateRegime ?? null;
const inputs = [...results.STOCK, ...results.ETF, ...results.BOND].map((r) => ({
ticker: r.asset.ticker,
assetType: r.asset.type,
price: r.asset.currentPrice ?? null,
signal: r.signal,
fundamental: r.fundamental,
inflated: r.inflated,
rateRegime,
}));
this.snapshots.recordBatch(inputs);
} catch (err) {
req.log?.warn?.({ err }, 'signal snapshot recording failed');
}
}
private async catalysts() {
const { tickers, stories } = await this.catalystCache.get();
return { tickers, stories };
+25 -13
View File
@@ -7,14 +7,20 @@ export class DataMapper {
// ── Public entry point ────────────────────────────────────────────────────
static mapToStandardFormat(ticker: string, summary: YahooSummary): MappedData {
const quoteType = summary.price?.quoteType as string | undefined;
const category = ((summary.assetProfile?.category as string) || '').toLowerCase();
const yieldVal = (summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0;
// Prefer fundProfile.categoryName (Morningstar category, e.g. "Intermediate
// Core Bond") — assetProfile.category is rarely populated for ETFs. A
// dividend-yield heuristic is deliberately NOT used: high-yield equity ETFs
// (SCHD, VYM) are not bonds.
const category = (
(summary.fundProfile?.categoryName as string) ||
(summary.assetProfile?.category as string) ||
''
).toLowerCase();
const isBond =
category.includes('bond') ||
category.includes('fixed income') ||
category.includes('treasury') ||
(quoteType === 'ETF' && yieldVal > 0.02 && category === '');
category.includes('treasury');
if (quoteType === 'ETF') {
return isBond
@@ -143,17 +149,23 @@ export class DataMapper {
}
// ── ETF ───────────────────────────────────────────────────────────────────
// Missing fields are preserved as null (not coerced to 0) so EtfScorer can
// skip the corresponding gate instead of auto-failing on absent Yahoo data.
private static mapEtfData(summary: YahooSummary) {
const num = (v: unknown): number | null =>
typeof v === 'number' && Number.isFinite(v) ? v : null;
const expenseRatio = num(summary.summaryDetail?.expenseRatio);
const dividendYield = num(summary.summaryDetail?.trailingAnnualDividendYield);
const fiveYearReturn = num(summary.defaultKeyStatistics?.fiveYearAverageReturn);
return {
expenseRatio: ((summary.summaryDetail?.expenseRatio as number) ?? 0) * 100,
totalAssets: (summary.summaryDetail?.totalAssets as number) ?? 0,
yield: ((summary.summaryDetail?.trailingAnnualDividendYield as number) ?? 0) * 100,
fiveYearReturn: ((summary.defaultKeyStatistics?.fiveYearAverageReturn as number) ?? 0) * 100,
volume:
(summary.summaryDetail?.averageVolume as number) ??
(summary.price?.averageVolume as number) ??
0,
currentPrice: (summary.price?.regularMarketPrice as number) ?? 0,
expenseRatio: expenseRatio != null ? expenseRatio * 100 : null,
totalAssets: num(summary.summaryDetail?.totalAssets),
yield: dividendYield != null ? dividendYield * 100 : null,
fiveYearReturn: fiveYearReturn != null ? fiveYearReturn * 100 : null,
volume: num(summary.summaryDetail?.averageVolume) ?? num(summary.price?.averageVolume),
currentPrice: num(summary.price?.regularMarketPrice) ?? 0,
};
}
@@ -60,6 +60,7 @@ export const YAHOO_MODULES: string[] = [
'defaultKeyStatistics',
'price',
'summaryDetail',
'fundProfile', // categoryName drives ETF vs bond-fund classification in DataMapper
];
export const SIGNAL_ORDER: Record<string, number> = {
@@ -139,6 +139,15 @@ export class DatabaseConnection {
return txn();
}
/**
* Execute a raw SQL SELECT and return all rows.
* Use only when QueryBuilder is not practical (e.g. static named queries).
*/
rawAll<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] {
const stmt = this.getOrCacheStatement(sql);
return stmt.all(...params) as T[];
}
/**
* Execute a raw SQL SELECT and return the first row.
* Use only when QueryBuilder is not practical (e.g. auth domain with static queries).
@@ -130,6 +130,82 @@ export const RESET_TOKEN_QUERIES = {
// ── Schema Definition (DDL) ──────────────────────────────────────────────────
// ── Watchlist Queries ────────────────────────────────────────────────────────
export const WATCHLIST_QUERIES = {
SELECT_ALL: `
SELECT ticker, pinned_at
FROM watchlist
WHERE user_id = ?
ORDER BY pinned_at DESC
`,
INSERT: `
INSERT OR IGNORE INTO watchlist (ticker, user_id, pinned_at)
VALUES (?, ?, ?)
`,
DELETE: `
DELETE FROM watchlist WHERE ticker = ? AND user_id = ?
`,
EXISTS: `
SELECT 1 FROM watchlist WHERE ticker = ? AND user_id = ?
`,
};
// ── Signal Snapshot Queries (P0.1 — signal track record) ────────────────────
export const SIGNAL_SNAPSHOT_QUERIES = {
// One row per ticker per day — repeated screens the same day keep the latest
UPSERT: `
INSERT INTO signal_snapshots (
ticker, snapshot_date, asset_type, price, signal,
fundamental_tier, fundamental_score, fundamental_label,
inflated_tier, inflated_score, inflated_label,
coverage_active, coverage_total, risk_flags, rate_regime, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ticker, snapshot_date) DO UPDATE SET
asset_type = excluded.asset_type,
price = excluded.price,
signal = excluded.signal,
fundamental_tier = excluded.fundamental_tier,
fundamental_score = excluded.fundamental_score,
fundamental_label = excluded.fundamental_label,
inflated_tier = excluded.inflated_tier,
inflated_score = excluded.inflated_score,
inflated_label = excluded.inflated_label,
coverage_active = excluded.coverage_active,
coverage_total = excluded.coverage_total,
risk_flags = excluded.risk_flags,
rate_regime = excluded.rate_regime,
created_at = excluded.created_at
`,
// Full history for one ticker, oldest first (for trend/backtest views)
SELECT_BY_TICKER: `
SELECT * FROM signal_snapshots
WHERE ticker = ?
ORDER BY snapshot_date ASC
`,
// All snapshots for one day (for daily diff jobs)
SELECT_BY_DATE: `
SELECT * FROM signal_snapshots
WHERE snapshot_date = ?
ORDER BY ticker ASC
`,
// Latest snapshot per ticker on or before a given date (for change detection)
SELECT_LATEST_BEFORE: `
SELECT s.* FROM signal_snapshots s
JOIN (
SELECT ticker, MAX(snapshot_date) AS d
FROM signal_snapshots
WHERE snapshot_date < ?
GROUP BY ticker
) latest ON latest.ticker = s.ticker AND latest.d = s.snapshot_date
`,
};
export const DDL = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
@@ -167,6 +243,36 @@ export const DDL = `
snapshot TEXT NOT NULL, -- JSON object
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS watchlist (
ticker TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
pinned_at TEXT NOT NULL,
PRIMARY KEY (ticker, user_id)
);
CREATE TABLE IF NOT EXISTS signal_snapshots (
ticker TEXT NOT NULL,
snapshot_date TEXT NOT NULL, -- YYYY-MM-DD
asset_type TEXT NOT NULL, -- STOCK / ETF / BOND
price REAL,
signal TEXT NOT NULL, -- ✅ Strong Buy etc.
fundamental_tier TEXT NOT NULL, -- PASS / HOLD / REJECT
fundamental_score REAL,
fundamental_label TEXT,
inflated_tier TEXT NOT NULL,
inflated_score REAL,
inflated_label TEXT,
coverage_active INTEGER,
coverage_total INTEGER,
risk_flags TEXT, -- JSON array
rate_regime TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (ticker, snapshot_date)
);
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON signal_snapshots(snapshot_date);
CREATE INDEX IF NOT EXISTS idx_snapshots_signal ON signal_snapshots(signal, snapshot_date);
`;
// ── Runtime migrations (ALTER TABLE for existing DBs) ────────────────────────
+19 -9
View File
@@ -6,24 +6,34 @@ export class Etf extends Asset {
constructor(data: EtfData) {
super(data);
// Preserve null for missing fields — coercing to 0 would auto-fail gates
// in EtfScorer for data Yahoo simply didn't return.
const num = (v: unknown): number | null => {
if (v == null) return null;
const f = parseFloat(String(v));
return Number.isFinite(f) ? f : null;
};
this.metrics = {
expenseRatio: parseFloat(String(data.expenseRatio)) || 0,
totalAssets: parseFloat(String(data.totalAssets)) || 0,
yield: parseFloat(String(data.yield)) || 0,
volume: parseFloat(String(data.volume)) || 0,
fiveYearReturn: parseFloat(String(data.fiveYearReturn)) || 0,
expenseRatio: num(data.expenseRatio),
totalAssets: num(data.totalAssets),
yield: num(data.yield),
volume: num(data.volume),
fiveYearReturn: num(data.fiveYearReturn),
};
}
getDisplayMetrics(): Record<string, string> {
const m = this.metrics;
const fmt = (v: number | null, dec: number, suffix = '') =>
v != null ? `${v.toFixed(dec)}${suffix}` : '—';
return {
Ticker: this.ticker,
Type: 'ETF',
Price: this.formatCurrency(this.currentPrice),
'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`,
'Yield%': `${this.metrics.yield.toFixed(2)}%`,
AUM: this.formatLargeNumber(this.metrics.totalAssets),
'5Y Return%': `${this.metrics.fiveYearReturn.toFixed(1)}%`,
'Exp Ratio%': fmt(m.expenseRatio, 2, '%'),
'Yield%': fmt(m.yield, 2, '%'),
AUM: m.totalAssets != null ? this.formatLargeNumber(m.totalAssets) : '—',
'5Y Return%': fmt(m.fiveYearReturn, 1, '%'),
};
}
}
+2
View File
@@ -25,6 +25,8 @@ export { MarketRegime } from './scoring/MarketRegime';
// Persistence (repositories)
export { MarketCallRepository } from './persistence/MarketCallRepository';
export { PortfolioRepository } from './persistence/PortfolioRepository';
export { SignalSnapshotRepository } from './persistence/SignalSnapshotRepository';
export type { SnapshotInput } from './persistence/SignalSnapshotRepository';
export { DatabaseConnection, QueryAudit, createDb } from './db/index';
// Config & Constants
@@ -0,0 +1,90 @@
import { DatabaseConnection } from '../db/index';
import { QueryBuilder } from '../utils/QueryBuilder';
import type { ScoreResult, SignalSnapshotRow } from '../types';
/**
* Signal snapshot ledger (PRODUCT.md P0.1).
*
* Persists one row per ticker per day on every /api/screen call so the
* product builds a verifiable signal track record. This data cannot be
* backfilled — the backtest dashboard (Phase 10.5e), thesis review (10.6d),
* and calibration features all depend on it accumulating from day one.
*
* Recording is best-effort: failures are logged by the caller and must never
* fail the screen request itself.
*/
export interface SnapshotInput {
ticker: string;
assetType: string;
price: number | null;
signal: string;
fundamental: ScoreResult;
inflated: ScoreResult;
rateRegime?: string | null;
}
export class SignalSnapshotRepository {
constructor(private readonly db: DatabaseConnection) {}
/**
* Upsert today's snapshot for a batch of screened assets.
* Repeated screens on the same day keep the latest result.
*/
recordBatch(inputs: SnapshotInput[], date = SignalSnapshotRepository.today()): number {
let written = 0;
for (const input of inputs) {
this.record(input, date);
written++;
}
return written;
}
record(input: SnapshotInput, date = SignalSnapshotRepository.today()): void {
const { ticker, assetType, price, signal, fundamental, inflated, rateRegime } = input;
const coverage = fundamental.audit?.coverage ?? inflated.audit?.coverage ?? null;
const riskFlags = fundamental.audit?.riskFlags ?? inflated.audit?.riskFlags ?? null;
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.UPSERT', [
ticker.toUpperCase(),
date,
assetType,
price,
signal,
fundamental.tier,
fundamental.score,
fundamental.label,
inflated.tier,
inflated.score,
inflated.label,
coverage?.active ?? null,
coverage?.total ?? null,
riskFlags ? JSON.stringify(riskFlags) : null,
rateRegime ?? null,
new Date().toISOString(),
]);
this.db.run(qb);
}
/** Full history for one ticker, oldest first. */
history(ticker: string): SignalSnapshotRow[] {
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.SELECT_BY_TICKER', [ticker.toUpperCase()]);
return this.db.all<SignalSnapshotRow>(qb);
}
/** All snapshots for a given day (YYYY-MM-DD). */
byDate(date: string): SignalSnapshotRow[] {
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.SELECT_BY_DATE', [date]);
return this.db.all<SignalSnapshotRow>(qb);
}
/** Latest snapshot per ticker strictly before a date — for daily diffing. */
latestBefore(date: string): SignalSnapshotRow[] {
const qb = new QueryBuilder('SIGNAL_SNAPSHOT_QUERIES.SELECT_LATEST_BEFORE', [date]);
return this.db.all<SignalSnapshotRow>(qb);
}
private static today(): string {
return new Date().toISOString().slice(0, 10);
}
}
@@ -12,19 +12,52 @@ export class BenchmarkProvider {
private static readonly TTL_MS = 60 * 60 * 1000;
private static readonly CACHE_PATH = '.benchmark-cache.json';
// NOTE: regimes must stay consistent with rateRegime()/volRegime() below —
// 4.5% ⇒ NORMAL (25%), VIX 20 ⇒ NORMAL (1525).
private static readonly DEFAULTS: MarketContext = {
sp500Price: 5000,
riskFreeRate: 4.5,
vixLevel: 20,
rateRegime: 'HIGH',
rateRegime: 'NORMAL',
volatilityRegime: 'NORMAL',
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
};
/** Hysteresis band: the 10Y must cross a regime boundary by this much to flip. */
private static readonly REGIME_HYSTERESIS = 0.25;
private static rateRegime(rate: number): MarketContext['rateRegime'] {
return rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
}
/**
* Rate regime with hysteresis (PRODUCT.md P0.5).
*
* The raw thresholds (2% / 5%) flip the INFLATED scoring gates between
* back-to-back requests when the 10Y hovers near a boundary. With a known
* previous regime, the rate must cross the boundary by ±0.25% before the
* regime switches. A two-step jump (LOW→HIGH) applies immediately.
* Public static for direct unit testing.
*/
static resolveRateRegime(
rate: number,
previous: MarketContext['rateRegime'] | null,
): MarketContext['rateRegime'] {
const raw = BenchmarkProvider.rateRegime(rate);
if (!previous || raw === previous) return raw;
const h = BenchmarkProvider.REGIME_HYSTERESIS;
if (previous === REGIME.NORMAL && raw === REGIME.HIGH)
return rate > 5 + h ? REGIME.HIGH : REGIME.NORMAL;
if (previous === REGIME.HIGH && raw === REGIME.NORMAL)
return rate < 5 - h ? REGIME.NORMAL : REGIME.HIGH;
if (previous === REGIME.NORMAL && raw === REGIME.LOW)
return rate < 2 - h ? REGIME.LOW : REGIME.NORMAL;
if (previous === REGIME.LOW && raw === REGIME.NORMAL)
return rate > 2 + h ? REGIME.NORMAL : REGIME.LOW;
return raw; // LOW↔HIGH double jump — no damping
}
private static volRegime(vix: number): MarketContext['volatilityRegime'] {
return vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
}
@@ -34,6 +67,8 @@ export class BenchmarkProvider {
}
private cache: { data: MarketContext | null; expiresAt: number };
private logger: Logger;
/** Last known rate regime — survives cache expiry so hysteresis has memory. */
private lastRegime: MarketContext['rateRegime'] | null = null;
constructor(
private readonly client: YahooFinanceClient,
@@ -47,6 +82,8 @@ export class BenchmarkProvider {
try {
if (!existsSync(BenchmarkProvider.CACHE_PATH)) return { data: null, expiresAt: 0 };
const file = JSON.parse(readFileSync(BenchmarkProvider.CACHE_PATH, 'utf8')) as CacheFile;
// Even an expired cache remembers the previous regime for hysteresis
this.lastRegime = file.data?.rateRegime ?? null;
if (Date.now() < file.expiresAt) return { data: file.data, expiresAt: file.expiresAt };
} catch {
// corrupt or missing — ignore
@@ -95,7 +132,7 @@ export class BenchmarkProvider {
sp500Price,
riskFreeRate,
vixLevel,
rateRegime: BenchmarkProvider.rateRegime(riskFreeRate),
rateRegime: BenchmarkProvider.resolveRateRegime(riskFreeRate, this.lastRegime),
volatilityRegime: BenchmarkProvider.volRegime(vixLevel),
benchmarks: {
marketPE: BenchmarkProvider.pe(spy) ?? 22,
@@ -107,6 +144,7 @@ export class BenchmarkProvider {
const expiresAt = Date.now() + BenchmarkProvider.TTL_MS;
this.cache = { data: context, expiresAt };
this.lastRegime = context.rateRegime;
this.saveDiskCache(context, expiresAt);
return context;
} catch (err) {
@@ -45,12 +45,25 @@ export interface ScoreAudit {
breakdown?: Record<string, number>;
riskFlags?: string[] | null;
failures?: string[];
/** Data coverage: how many scoring factors had data vs. were defined. */
coverage?: { active: number; total: number };
}
/**
* Structured verdict tier — the machine-readable counterpart of `label`.
* Signal derivation and persistence MUST use this, never the label string.
* PASS = green (buy-quality), HOLD = yellow (neutral), REJECT = red (gate fail / negative).
*/
export type VerdictTier = 'PASS' | 'HOLD' | 'REJECT';
export interface ScoreResult {
label: string;
scoreSummary: string;
audit: ScoreAudit;
/** Machine-readable verdict tier. Use this for signal logic, not the label. */
tier: VerdictTier;
/** Numeric factor score. Null when gates failed (no score computed). */
score: number | null;
}
// AssetResult with runtime methods still attached — used at the HTTP boundary
+7 -1
View File
@@ -46,7 +46,13 @@ export type {
BondData,
BondMetrics,
} from './models.model';
export type { StoreData, PortfolioData, MarketCallRow, HoldingRow } from './repositories.model';
export type {
StoreData,
PortfolioData,
MarketCallRow,
HoldingRow,
SignalSnapshotRow,
} from './repositories.model';
export type { NumVal, SanitizedMetrics, SanitizedBondMetrics } from './scorers.model';
export type {
BenchmarkProviderOptions,
+12 -10
View File
@@ -86,20 +86,22 @@ export interface StockMetrics {
export interface EtfData {
ticker?: string;
currentPrice?: number;
expenseRatio?: string | number;
totalAssets?: string | number;
yield?: string | number;
volume?: string | number;
fiveYearReturn?: string | number;
expenseRatio?: string | number | null;
totalAssets?: string | number | null;
yield?: string | number | null;
volume?: string | number | null;
fiveYearReturn?: string | number | null;
[key: string]: unknown;
}
// Missing Yahoo data is preserved as null so EtfScorer skips the
// corresponding gate instead of auto-failing on a coerced 0.
export interface EtfMetrics {
expenseRatio: number;
totalAssets: number;
yield: number;
volume: number;
fiveYearReturn: number;
expenseRatio: number | null;
totalAssets: number | null;
yield: number | null;
volume: number | null;
fiveYearReturn: number | null;
}
// ── Bond ───────────────────────────────────────────────────────────────────
@@ -37,6 +37,28 @@ export interface HoldingRow {
source: string;
}
/**
* Raw database row from signal_snapshots table (P0.1 signal track record).
*/
export interface SignalSnapshotRow {
ticker: string;
snapshot_date: string;
asset_type: string;
price: number | null;
signal: string;
fundamental_tier: string;
fundamental_score: number | null;
fundamental_label: string | null;
inflated_tier: string;
inflated_score: number | null;
inflated_label: string | null;
coverage_active: number | null;
coverage_total: number | null;
risk_flags: string | null; // JSON array stringified
rate_regime: string | null;
created_at: string;
}
// ── Persistence Shapes (returned by repositories) ───────────────────────────
export interface StoreData {
@@ -0,0 +1,35 @@
import type { DatabaseConnection } from '../shared/db/index.js';
import { WATCHLIST_QUERIES } from '../shared/db/queries.constant.js';
export interface WatchlistEntry {
ticker: string;
pinnedAt: string;
}
export class WatchlistRepository {
constructor(private readonly db: DatabaseConnection) {}
list(userId: string): WatchlistEntry[] {
const rows = this.db.rawAll<{ ticker: string; pinned_at: string }>(
WATCHLIST_QUERIES.SELECT_ALL,
[userId],
);
return rows.map((r) => ({ ticker: r.ticker, pinnedAt: r.pinned_at }));
}
add(ticker: string, userId: string): void {
this.db.rawRun(WATCHLIST_QUERIES.INSERT, [
ticker.toUpperCase(),
userId,
new Date().toISOString(),
]);
}
remove(ticker: string, userId: string): void {
this.db.rawRun(WATCHLIST_QUERIES.DELETE, [ticker.toUpperCase(), userId]);
}
has(ticker: string, userId: string): boolean {
return !!this.db.rawGet(WATCHLIST_QUERIES.EXISTS, [ticker.toUpperCase(), userId]);
}
}
+2
View File
@@ -0,0 +1,2 @@
export { WatchlistController } from './watchlist.controller.js';
export { WatchlistRepository } from './WatchlistRepository.js';
@@ -0,0 +1,53 @@
import type { FastifyInstance, FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
import type { TokenPayload } from '../auth/index.js';
import { WatchlistRepository } from './WatchlistRepository.js';
type AuthedRequest = FastifyRequest & { user: TokenPayload };
interface WatchlistControllerOptions {
authGuard: preHandlerHookHandler;
}
export class WatchlistController {
readonly #guards: preHandlerHookHandler[];
constructor(
private readonly repo: WatchlistRepository,
options: WatchlistControllerOptions,
) {
this.#guards = [options.authGuard];
}
register(app: FastifyInstance): void {
const g = { preHandler: this.#guards };
app.get('/api/watchlist', g, this.list.bind(this));
app.post('/api/watchlist/:ticker', g, this.add.bind(this));
app.delete('/api/watchlist/:ticker', g, this.remove.bind(this));
}
private list(req: FastifyRequest): {
tickers: string[];
entries: { ticker: string; pinnedAt: string }[];
} {
const userId = (req as AuthedRequest).user.sub;
const entries = this.repo.list(userId);
return { tickers: entries.map((e) => e.ticker), entries };
}
private add(req: FastifyRequest, reply: FastifyReply): { ok: boolean } | FastifyReply {
const userId = (req as AuthedRequest).user.sub;
const ticker = (req.params as { ticker: string }).ticker?.toUpperCase();
if (!ticker || !/^[A-Z0-9.-]{1,12}$/.test(ticker)) {
return reply.code(400).send({ error: 'Invalid ticker' });
}
this.repo.add(ticker, userId);
return { ok: true };
}
private remove(req: FastifyRequest): { ok: boolean } {
const userId = (req as AuthedRequest).user.sub;
const ticker = (req.params as { ticker: string }).ticker?.toUpperCase();
this.repo.remove(ticker, userId);
return { ok: true };
}
}