phase-8f: persistant cache locally

This commit is contained in:
Kazuma
2026-06-05 22:52:30 -04:00
committed by Kazuma
parent 7f2108129a
commit 8bdcb422aa
7 changed files with 551 additions and 24 deletions
+2 -3
View File
@@ -11,6 +11,5 @@ market-calls.json
ui/.svelte-kit ui/.svelte-kit
ui/build ui/build
# Reports # Runtime cache
screener-report.html .benchmark-cache.json
finance-report.html
+1 -12
View File
@@ -553,7 +553,7 @@ This section is the single reference for where code lives and how to add feature
- Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `serializeAssets()` in `screener.controller.ts`). - Class instances don't survive `JSON.stringify`. Call `getDisplayMetrics()` server-side before returning from API routes (see `serializeAssets()` in `screener.controller.ts`).
- Controllers use constructor injection — dependencies are wired in `server/app.ts`, not created inside handlers. - Controllers use constructor injection — dependencies are wired in `server/app.ts`, not created inside handlers.
- The `$types` alias in the UI resolves to `server/types/` — use it instead of duplicating type definitions. - The `$types` alias in the UI resolves to `server/types/` — use it instead of duplicating type definitions.
- Ticker normalisation (`BRK.B → BRK-B`) currently only happens in `FinanceController.normalizeYahoo()`. Submitting `BRK.B` directly to `/api/screen` will fail. Fix target: move normalisation into `YahooFinanceClient.fetchSummary()`. - Ticker normalisation (`BRK.B → BRK-B`) happens in `YahooFinanceClient.normalise()` and applies to all callers via `fetchSummary()` and `fetchCalendarEvents()`.
### Adding a new scoring metric — step-by-step ### Adding a new scoring metric — step-by-step
@@ -622,17 +622,6 @@ Add one Fastify `inject()` smoke test per route using a fixture for `ScreenerEng
`MarketCallRepository` has zero test coverage. Add `tests/MarketCallRepository.test.js` using a temp file path (inject via constructor or env var) to test `list`, `create`, `delete`, and concurrent-write safety. `MarketCallRepository` has zero test coverage. Add `tests/MarketCallRepository.test.js` using a temp file path (inject via constructor or env var) to test `list`, `create`, `delete`, and concurrent-write safety.
#### 8e — Ticker normalisation in `YahooFinanceClient`
`BRK.B → BRK-B` normalisation lives only in `FinanceController`. Move it to `YahooFinanceClient.fetchSummary()` so it applies to all callers including `/api/screen`.
```ts
async fetchSummary(ticker: string, ...): Promise<any> {
const normalized = ticker.replace(/\./g, '-');
return await this.lib.quoteSummary(normalized, { modules: YAHOO_MODULES });
}
```
#### 8f — Persistent benchmark cache #### 8f — Persistent benchmark cache
`BenchmarkProvider`'s 1-hour cache is in-memory only — cold start after every restart adds 24s latency to the first request. Write the cached `MarketContext` to `.benchmark-cache.json` (or a single-row SQLite table). Read it on boot; only re-fetch if stale. `BenchmarkProvider`'s 1-hour cache is in-memory only — cold start after every restart adds 24s latency to the first request. Write the cached `MarketContext` to `.benchmark-cache.json` (or a single-row SQLite table). Read it on boot; only re-fetch if stale.
+211
View File
@@ -0,0 +1,211 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Personal Finance — 2026-06-03</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; font-size: 13px; }
h1 { font-size: 20px; font-weight: 600; }
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #94a3b8; margin-bottom: 12px; }
.header { padding: 24px 32px 16px; border-bottom: 1px solid #1e293b; display: flex; align-items: center; gap: 16px; }
.pill { background: #1e293b; border-radius: 6px; padding: 4px 12px; font-size: 12px; color: #94a3b8; margin-left: auto; }
.pill span { color: #e2e8f0; font-weight: 600; margin-left: 4px; }
.content { padding: 24px 32px; }
.section { margin-bottom: 40px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px; }
.card { background: #1e293b; border-radius: 8px; padding: 14px 16px; }
.card-label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; }
.card-value { font-size: 20px; font-weight: 700; color: #f1f5f9; margin-top: 4px; }
.card-sub { font-size: 11px; color: #64748b; margin-top: 2px; }
table { width: 100%; border-collapse: collapse; }
thead th { text-align: left; padding: 8px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #64748b; border-bottom: 1px solid #1e293b; white-space: nowrap; }
tbody tr { border-bottom: 1px solid #1a2233; }
tbody tr:hover { background: #1e293b; }
tbody td { padding: 10px 12px; vertical-align: middle; }
.ticker { font-weight: 700; font-size: 14px; color: #f1f5f9; }
.green { color: #4ade80; }
.yellow { color: #facc15; }
.orange { color: #fb923c; }
.red { color: #f87171; }
.gray { color: #64748b; }
.advice-green { color: #4ade80; font-weight: 600; }
.advice-yellow { color: #facc15; font-weight: 600; }
.advice-orange { color: #fb923c; font-weight: 600; }
.advice-red { color: #f87171; font-weight: 600; }
.reason { color: #94a3b8; font-size: 11px; }
.bar-bg { background: #1e293b; border-radius: 4px; height: 8px; }
.bar-fill { background: #3b82f6; border-radius: 4px; height: 8px; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
</style>
</head>
<body>
<div class="header">
<h1>💰 Personal Finance</h1>
<div class="pill">Date <span>2026-06-03</span></div>
</div>
<div class="content">
<div class="section">
<h2>Portfolio — Hold / Sell / Add Advice</h2>
<div class="grid" style="margin-bottom:16px">
<div class="card">
<div class="card-label">Total Value</div>
<div class="card-value ">$41,451</div>
</div>
<div class="card">
<div class="card-label">Total Cost</div>
<div class="card-value ">$25,180</div>
</div>
<div class="card">
<div class="card-label">Total G/L</div>
<div class="card-value green">$16,271</div>
<div class="card-sub">64.6%</div>
</div>
<div class="card">
<div class="card-label">S&P 500 P/E</div>
<div class="card-value ">28.5x</div>
<div class="card-sub">Live benchmark</div>
</div>
</div>
<h2 style="margin-bottom:10px">Stocks &amp; ETFs</h2>
<table>
<thead><tr>
<th>Ticker</th><th>Source</th><th>Type</th><th>Shares</th>
<th>Cost Basis</th><th>Current</th><th>Value</th>
<th>G/L</th><th>Signal</th><th>Advice</th><th>Reason</th>
</tr></thead>
<tbody><tr>
<td class="ticker">AAPL</td>
<td><span style="background:#22c55e22;color:#22c55e;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Robinhood</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">stock</span></td>
<td>10</td>
<td>$150.00</td>
<td>$315.20</td>
<td>$3,152.00</td>
<td class="green">110.1%</td>
<td class="gray" style="font-size:11px">⚠️ Speculation</td>
<td class="advice-orange">🟠 Reduce Position</td>
<td class="reason">In profit on a speculative position — take partial profits.</td>
</tr><tr>
<td class="ticker">PLTR</td>
<td><span style="background:#22c55e22;color:#22c55e;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Robinhood</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">stock</span></td>
<td>50</td>
<td>$18.50</td>
<td>$152.17</td>
<td>$7,608.50</td>
<td class="green">722.5%</td>
<td class="gray" style="font-size:11px">❌ Avoid</td>
<td class="advice-red">🔴 Sell (Take Profits)</td>
<td class="reason">Fails both analyses — you're in profit, take it.</td>
</tr><tr>
<td class="ticker">TSLA</td>
<td><span style="background:#22c55e22;color:#22c55e;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Robinhood</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">stock</span></td>
<td>3</td>
<td>$200.00</td>
<td>$423.74</td>
<td>$1,271.22</td>
<td class="green">111.9%</td>
<td class="gray" style="font-size:11px">❌ Avoid</td>
<td class="advice-red">🔴 Sell (Take Profits)</td>
<td class="reason">Fails both analyses — you're in profit, take it.</td>
</tr><tr>
<td class="ticker">MSFT</td>
<td><span style="background:#22c55e22;color:#22c55e;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Robinhood</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">stock</span></td>
<td>5</td>
<td>$300.00</td>
<td>$441.31</td>
<td>$2,206.55</td>
<td class="green">47.1%</td>
<td class="gray" style="font-size:11px">✅ Strong Buy</td>
<td class="advice-green">🟢 Hold & Add</td>
<td class="reason">Passes both analyses. Strong conviction.</td>
</tr><tr>
<td class="ticker">VOO</td>
<td><span style="background:#3b82f622;color:#3b82f6;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Vanguard</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">etf</span></td>
<td>8</td>
<td>$380.00</td>
<td>$698.26</td>
<td>$5,586.08</td>
<td class="green">83.8%</td>
<td class="gray" style="font-size:11px">⚡ Momentum</td>
<td class="advice-yellow">🟡 Hold</td>
<td class="reason">Up significantly on momentum — consider partial profit-taking.</td>
</tr><tr>
<td class="ticker">BND</td>
<td><span style="background:#3b82f622;color:#3b82f6;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Vanguard</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">etf</span></td>
<td>15</td>
<td>$75.00</td>
<td>$73.20</td>
<td>$1,098.00</td>
<td class="red">-2.4%</td>
<td class="gray" style="font-size:11px">🔄 Neutral</td>
<td class="advice-yellow">🟡 Hold</td>
<td class="reason">No clear edge. Review on any catalyst.</td>
</tr><tr>
<td class="ticker">O</td>
<td><span style="background:#22c55e22;color:#22c55e;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Robinhood</span></td>
<td><span style="background:#1e293b;padding:2px 8px;border-radius:4px;font-size:11px">stock</span></td>
<td>20</td>
<td>$52.00</td>
<td>$59.91</td>
<td>$1,198.20</td>
<td class="green">15.2%</td>
<td class="gray" style="font-size:11px">✅ Strong Buy</td>
<td class="advice-green">🟢 Hold & Add</td>
<td class="reason">Passes both analyses. Strong conviction.</td>
</tr></tbody>
</table>
<h2 style="margin-top:24px;margin-bottom:10px">Crypto</h2>
<table>
<thead><tr>
<th>Ticker</th><th>Source</th><th>Shares</th>
<th>Cost Basis</th><th>Current</th><th>Value</th>
<th>G/L</th><th>Advice</th><th>Note</th>
</tr></thead>
<tbody><tr>
<td class="ticker">BTC-USD</td>
<td><span style="background:#8b5cf622;color:#8b5cf6;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Coinbase</span></td>
<td>0.25</td>
<td>$45,000.00</td>
<td>$66,289.92</td>
<td>$16,572.48</td>
<td class="green">47.3%</td>
<td class="advice-yellow">🟡 Hold</td>
<td class="reason">Crypto — no fundamental analysis. Track price and manage risk manually.</td>
</tr><tr>
<td class="ticker">ETH-USD</td>
<td><span style="background:#8b5cf622;color:#8b5cf6;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600">Coinbase</span></td>
<td>1.5</td>
<td>$2,800.00</td>
<td>$1,838.88</td>
<td>$2,758.32</td>
<td class="red">-34.3%</td>
<td class="advice-red">🔴 Review position</td>
<td class="reason">Crypto — no fundamental analysis. Track price and manage risk manually.</td>
</tr></tbody>
</table>
</div>
</div>
</body>
</html>
File diff suppressed because one or more lines are too long
+10 -2
View File
@@ -11,10 +11,16 @@ export class YahooFinanceClient {
}); });
} }
/** Normalise ticker before hitting Yahoo: BRK.B → BRK-B */
private static normalise(ticker: string): string {
return ticker.toUpperCase().replace(/\./g, '-');
}
async fetchSummary(ticker: string, retries = 3, backoff = 1000): Promise<any> { async fetchSummary(ticker: string, retries = 3, backoff = 1000): Promise<any> {
const normalised = YahooFinanceClient.normalise(ticker);
for (let attempt = 0; attempt < retries; attempt++) { for (let attempt = 0; attempt < retries; attempt++) {
try { try {
return await this.lib.quoteSummary(ticker, { modules: YAHOO_MODULES }); return await this.lib.quoteSummary(normalised, { modules: YAHOO_MODULES });
} catch (error) { } catch (error) {
if (attempt === retries - 1) throw error; if (attempt === retries - 1) throw error;
await new Promise<void>((resolve) => setTimeout(resolve, backoff * (attempt + 1))); await new Promise<void>((resolve) => setTimeout(resolve, backoff * (attempt + 1)));
@@ -24,7 +30,9 @@ export class YahooFinanceClient {
async fetchCalendarEvents(ticker: string): Promise<any | null> { async fetchCalendarEvents(ticker: string): Promise<any | null> {
try { try {
const result = await this.lib.quoteSummary(ticker, { modules: ['calendarEvents'] }); const result = await this.lib.quoteSummary(YahooFinanceClient.normalise(ticker), {
modules: ['calendarEvents'],
});
return result.calendarEvents ?? null; return result.calendarEvents ?? null;
} catch { } catch {
return null; return null;
+1 -5
View File
@@ -13,10 +13,6 @@ export class FinanceController {
private readonly advisor: PortfolioAdvisor, private readonly advisor: PortfolioAdvisor,
) {} ) {}
private static normalizeYahoo(ticker: string): string {
return ticker.toUpperCase().replace(/\./g, '-');
}
register(app: FastifyInstance): void { register(app: FastifyInstance): void {
app.get('/api/finance/portfolio', this.portfolio.bind(this)); app.get('/api/finance/portfolio', this.portfolio.bind(this));
app.post('/api/finance/holdings', { schema: holdingSchema }, this.addHolding.bind(this)); app.post('/api/finance/holdings', { schema: holdingSchema }, this.addHolding.bind(this));
@@ -38,7 +34,7 @@ export class FinanceController {
const screenable = holdings const screenable = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto') .filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => FinanceController.normalizeYahoo(h.ticker)); .map((h) => h.ticker.toUpperCase());
const results = const results =
screenable.length > 0 screenable.length > 0
+34 -2
View File
@@ -1,9 +1,16 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { YahooFinanceClient } from '../clients/YahooFinanceClient'; import { YahooFinanceClient } from '../clients/YahooFinanceClient';
import { REGIME } from '../config/constants'; import { REGIME } from '../config/constants';
import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types'; import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types';
interface CacheFile {
data: MarketContext;
expiresAt: number;
}
export class BenchmarkProvider { export class BenchmarkProvider {
private static readonly TTL_MS = 60 * 60 * 1000; private static readonly TTL_MS = 60 * 60 * 1000;
private static readonly CACHE_PATH = '.benchmark-cache.json';
private static readonly DEFAULTS: MarketContext = { private static readonly DEFAULTS: MarketContext = {
sp500Price: 5000, sp500Price: 5000,
@@ -32,10 +39,33 @@ export class BenchmarkProvider {
private readonly client: YahooFinanceClient, private readonly client: YahooFinanceClient,
{ logger }: BenchmarkProviderOptions = {}, { logger }: BenchmarkProviderOptions = {},
) { ) {
this.cache = { data: null, expiresAt: 0 }; this.cache = this.loadDiskCache();
this.logger = logger ?? (console as unknown as Logger); this.logger = logger ?? (console as unknown as Logger);
} }
private loadDiskCache(): { data: MarketContext | null; expiresAt: number } {
try {
if (!existsSync(BenchmarkProvider.CACHE_PATH)) return { data: null, expiresAt: 0 };
const file = JSON.parse(readFileSync(BenchmarkProvider.CACHE_PATH, 'utf8')) as CacheFile;
if (Date.now() < file.expiresAt) return { data: file.data, expiresAt: file.expiresAt };
} catch {
// corrupt or missing — ignore
}
return { data: null, expiresAt: 0 };
}
private saveDiskCache(data: MarketContext, expiresAt: number): void {
try {
writeFileSync(
BenchmarkProvider.CACHE_PATH,
JSON.stringify({ data, expiresAt } satisfies CacheFile, null, 2),
'utf8',
);
} catch {
// non-fatal — in-memory cache still works
}
}
async getMarketContext(): Promise<MarketContext> { async getMarketContext(): Promise<MarketContext> {
if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data; if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data;
@@ -75,7 +105,9 @@ export class BenchmarkProvider {
}, },
}; };
this.cache = { data: context, expiresAt: Date.now() + BenchmarkProvider.TTL_MS }; const expiresAt = Date.now() + BenchmarkProvider.TTL_MS;
this.cache = { data: context, expiresAt };
this.saveDiskCache(context, expiresAt);
return context; return context;
} catch (err) { } catch (err) {
this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message); this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message);