UI enhancemnts
This commit is contained in:
@@ -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) ────────────────────────
|
||||
|
||||
@@ -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, '%'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (2–5%), VIX 20 ⇒ NORMAL (15–25).
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user