UI enhancemnts
This commit is contained in:
+7
-1
@@ -10,6 +10,7 @@ import { PortfolioAdvisor } from './domains/portfolio';
|
||||
import { CallsController, CalendarService } from './domains/calls';
|
||||
import { AuthController, AuthService, UserStore, verifyJwt } from './domains/auth';
|
||||
import type { TokenPayload } from './domains/auth';
|
||||
import { WatchlistController, WatchlistRepository } from './domains/watchlist';
|
||||
|
||||
// Shared infrastructure
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
LLMAnalyst,
|
||||
MarketCallRepository,
|
||||
PortfolioRepository,
|
||||
SignalSnapshotRepository,
|
||||
createDb,
|
||||
DatabaseConnection,
|
||||
QueryAudit,
|
||||
@@ -124,12 +126,14 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
|
||||
const innerWidth = Math.max(line1.length, line2.length) + 2;
|
||||
const hr = '─'.repeat(innerWidth);
|
||||
const pad = (s: string) => `│ ${s}${' '.repeat(innerWidth - 1 - s.length)}│`;
|
||||
/* eslint-disable no-console -- boot-time invite code must reach the operator's terminal */
|
||||
console.log(`\n┌${hr}┐`);
|
||||
console.log(pad(''));
|
||||
console.log(pad(line1));
|
||||
console.log(pad(line2));
|
||||
console.log(pad(''));
|
||||
console.log(`└${hr}┘\n`);
|
||||
/* eslint-enable no-console */
|
||||
|
||||
const userStore = new UserStore(db);
|
||||
const authService = new AuthService(userStore, JWT_SECRET);
|
||||
@@ -137,7 +141,7 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
|
||||
|
||||
// Register controllers
|
||||
// Public routes (GET) remain open; write routes require JWT + trader role
|
||||
new ScreenerController(engine, catalystCache).register(app);
|
||||
new ScreenerController(engine, catalystCache, new SignalSnapshotRepository(db)).register(app);
|
||||
new FinanceController(engine, new PortfolioRepository(db), advisor, {
|
||||
authGuard,
|
||||
traderGuard,
|
||||
@@ -148,6 +152,8 @@ export async function buildApp({ logger = true, db: injectedDb }: BuildAppOption
|
||||
}).register(app);
|
||||
new AnalyzeController(catalystCache, llm).register(app);
|
||||
|
||||
new WatchlistController(new WatchlistRepository(db), { authGuard }).register(app);
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
|
||||
return app;
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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 } },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) ────────────────────────
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user