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
@@ -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 {