slice 1a-1d: DB + CacheRepository + YFinance adapter + AdapterQueue
Node 26 + node:sqlite backend (zero native deps; runtime glue adapted from Bun-spec design, deep-module architecture unchanged). 29 tests green across schema/CacheRepository/YFinance-parse/AdapterQueue-dedupe.
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
Generated
+1510
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "investor-flow-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"description": "Investor Flow — Bun/SQLite backend (implemented on Node 26 + node:sqlite; DESIGN.md §1-§8). Runtime glue adapted from Bun; deep-module architecture unchanged.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch --experimental-strip-types src/index.ts",
|
||||||
|
"start": "node --experimental-strip-types src/index.ts",
|
||||||
|
"test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
|
||||||
|
"db:init": "node --experimental-strip-types src/db/client.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@trpc/server": "^11.0.0",
|
||||||
|
"yahoo-finance2": "^3.15.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Investor Flow — SourceAdapter contract (DESIGN.md §3a Module 2 + §5).
|
||||||
|
//
|
||||||
|
// Implementation split (vs the design's single SourceAdapter interface):
|
||||||
|
// - SourceFetch (here): the per-source fetch+parse contract. One concrete adapter per source.
|
||||||
|
// - AdapterQueue (slice 1d): the shared queue/dedupe/backoff/drain orchestrator that calls
|
||||||
|
// SourceFetch.fetchOne during drain and writes results to CacheRepository.
|
||||||
|
// This preserves the design's intent (one seam per source; shared dedupe) while keeping each
|
||||||
|
// micro-dispatch focused. The SPA never calls either directly — only CacheRepository.get →
|
||||||
|
// AdapterQueue.queue → (drain) → SourceFetch.fetchOne → CacheRepository.set.
|
||||||
|
import type { CacheKey, SourceKind, TtlClass, Provenance } from '../cache/CacheRepository.ts';
|
||||||
|
|
||||||
|
export interface FetchOpts { force?: boolean; } // force = bypass dedupe (admin.resetBackoff)
|
||||||
|
export interface AdapterHealth { queued: number; in_flight: number; backoff_until?: string | null; last_error?: string; }
|
||||||
|
export interface FetchResult { value: unknown; ttlClass: TtlClass; provenance: Provenance; }
|
||||||
|
|
||||||
|
/** Per-source fetch+parse. The AdapterQueue calls fetchOne(key) during drain. */
|
||||||
|
export interface SourceFetch {
|
||||||
|
readonly sourceKind: SourceKind;
|
||||||
|
fetchOne(key: CacheKey): Promise<FetchResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test double: deterministic canned responses per key. Implements SourceFetch.
|
||||||
|
* `calls` records every fetchOne invocation — used by dedupe tests
|
||||||
|
* ("two concurrent snapshot calls collapse into one fetch" => calls.length === 1).
|
||||||
|
*/
|
||||||
|
export class FakeSourceAdapter implements SourceFetch {
|
||||||
|
readonly sourceKind: SourceKind;
|
||||||
|
private readonly canned = new Map<CacheKey, FetchResult>();
|
||||||
|
public calls: CacheKey[] = [];
|
||||||
|
constructor(sourceKind: SourceKind = 'yfinance') { this.sourceKind = sourceKind; }
|
||||||
|
set(key: CacheKey, value: unknown, ttlClass: TtlClass, provenance?: Provenance): this {
|
||||||
|
this.canned.set(key, { value, ttlClass, provenance: provenance ?? { fetchedAt: new Date().toISOString(), sourceKind: this.sourceKind } });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
async fetchOne(key: CacheKey): Promise<FetchResult> {
|
||||||
|
this.calls.push(key);
|
||||||
|
const r = this.canned.get(key);
|
||||||
|
if (!r) throw new Error(`FakeSourceAdapter: no canned response for ${key}`);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Investor Flow — YFinanceAdapter (DESIGN.md §3a Module 2 + §5 yfinance policy).
|
||||||
|
// One concrete SourceFetch for yfinance: quote / candles / symbol(info+sector).
|
||||||
|
// yahoo-finance2 v3 is dynamically imported inside fetchOne so the module loads without the
|
||||||
|
// dependency and tests exercise only the pure parse functions (fixtures, no network).
|
||||||
|
import type { CacheKey, TtlClass, Provenance, Quote, PriceCandle, SymbolMeta, TickerKind } from '../cache/CacheRepository.ts';
|
||||||
|
import { parseCacheKey } from '../cache/CacheRepository.ts';
|
||||||
|
import type { SourceFetch, FetchResult } from './SourceAdapter.ts';
|
||||||
|
|
||||||
|
// Polite UA including operator contact (yfinance community norm; EDGAR requires similar).
|
||||||
|
const OPERATOR_EMAIL = process.env.YF_OPERATOR_EMAIL ?? 'operator@example.com';
|
||||||
|
|
||||||
|
export class YFinanceAdapter implements SourceFetch {
|
||||||
|
readonly sourceKind = 'yfinance' as const;
|
||||||
|
private _yf: unknown = null;
|
||||||
|
private async yf(): Promise<YFinanceLike> {
|
||||||
|
if (!this._yf) {
|
||||||
|
const mod = await import('yahoo-finance2');
|
||||||
|
this._yf = new mod.default({ agent: undefined } as never);
|
||||||
|
}
|
||||||
|
return this._yf as YFinanceLike;
|
||||||
|
}
|
||||||
|
async fetchOne(key: CacheKey): Promise<FetchResult> {
|
||||||
|
const { kind, id } = parseCacheKey(key);
|
||||||
|
const fetchedAt = new Date().toISOString();
|
||||||
|
const yf = await this.yf();
|
||||||
|
switch (kind) {
|
||||||
|
case 'quote': {
|
||||||
|
const raw = await yf.quote(id);
|
||||||
|
return { value: parseQuote(raw, id), ttlClass: 'live_quote', provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: id } };
|
||||||
|
}
|
||||||
|
case 'candles': {
|
||||||
|
const [symbol, timeframe] = id.split(':');
|
||||||
|
const interval = timeframeToInterval(timeframe);
|
||||||
|
const days = timeframe === '1wk' ? 365 : 95; // slice 1: recent range for sparkline; slice 4 backfills `max`
|
||||||
|
const raw = await yf.chart(symbol, { period1: new Date(Date.now() - days * 86_400_000), interval });
|
||||||
|
return { value: parseCandles(raw), ttlClass: 'daily_permanent', provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: `${symbol}:${timeframe}` } };
|
||||||
|
}
|
||||||
|
case 'symbol': {
|
||||||
|
const raw = await yf.quoteSummary(id, { modules: ['summaryProfile'] });
|
||||||
|
return { value: parseSymbol(raw, id), ttlClass: 'symbol_meta', provenance: { fetchedAt, sourceKind: 'yfinance', rawSourceId: id } };
|
||||||
|
}
|
||||||
|
default: throw new Error(`YFinanceAdapter: unknown kind '${kind}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YFinanceLike { quote(s: string): Promise<Record<string, unknown>>; chart(s: string, o: { period1: Date; interval: string }): Promise<Record<string, unknown>>; quoteSummary(s: string, o: { modules: string[] }): Promise<Record<string, unknown>>; }
|
||||||
|
|
||||||
|
// ----- Pure parse helpers (tested with recorded fixtures; no network) -----
|
||||||
|
|
||||||
|
export function parseQuote(raw: Record<string, unknown>, symbol: string): Quote {
|
||||||
|
const price = num(raw.regularMarketPrice);
|
||||||
|
if (price === null) throw new Error(`quote ${symbol}: missing regularMarketPrice`);
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
price,
|
||||||
|
bid: num(raw.bid),
|
||||||
|
ask: num(raw.ask),
|
||||||
|
change: num(raw.regularMarketChange),
|
||||||
|
changePercent: num(raw.regularMarketChangePercent),
|
||||||
|
iv: null, // IV comes from the options chain kind, not the quote
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCandles(raw: Record<string, unknown>): PriceCandle[] {
|
||||||
|
const quotes = raw.quotes;
|
||||||
|
if (!Array.isArray(quotes)) return [];
|
||||||
|
const out: PriceCandle[] = [];
|
||||||
|
for (const q of quotes as Array<Record<string, unknown> | null>) {
|
||||||
|
if (!q) continue;
|
||||||
|
const close = num(q.close);
|
||||||
|
if (close === null) continue; // skip null candles (non-trading days / gaps)
|
||||||
|
out.push({
|
||||||
|
ts: str(q.date) ?? '',
|
||||||
|
o: num(q.open) ?? 0,
|
||||||
|
h: num(q.high) ?? 0,
|
||||||
|
l: num(q.low) ?? 0,
|
||||||
|
c: close,
|
||||||
|
v: num(q.volume) ?? 0,
|
||||||
|
adjClose: num(q.adjclose),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSymbol(raw: Record<string, unknown>, symbol: string): SymbolMeta {
|
||||||
|
const sp = (raw.summaryProfile ?? {}) as Record<string, unknown>;
|
||||||
|
const price = (raw.price ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
name: str(sp.longName ?? price.longName ?? sp.shortName),
|
||||||
|
sector: str(sp.sector),
|
||||||
|
industry: str(sp.industry),
|
||||||
|
exchange: str(sp.exchange ?? price.exchange),
|
||||||
|
tickerKind: inferTickerKind(symbol),
|
||||||
|
peers: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRYPTO = new Set(['BTC', 'ETH', 'BNB', 'SOL', 'XRP', 'ADA', 'DOGE', 'DOT']);
|
||||||
|
const ETF = new Set(['SPY', 'QQQ', 'IWM', 'DIA', 'VTI', 'VOO', 'EEM']);
|
||||||
|
export function inferTickerKind(symbol: string): TickerKind {
|
||||||
|
if (CRYPTO.has(symbol)) return 'crypto';
|
||||||
|
if (ETF.has(symbol)) return 'etf';
|
||||||
|
if (symbol.startsWith('^')) return 'index';
|
||||||
|
return 'equity';
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeframeToInterval(tf: string): string { return tf; } // '1d'->'1d', '1wk'->'1wk', '1h'->'1h'
|
||||||
|
|
||||||
|
function num(v: unknown): number | null { return typeof v === 'number' && Number.isFinite(v) ? v : null; }
|
||||||
|
function str(v: unknown): string | null { return typeof v === 'string' && v.length > 0 ? v : null; }
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { parseQuote, parseCandles, parseSymbol, inferTickerKind } from '../YFinanceAdapter.ts';
|
||||||
|
import { FakeSourceAdapter } from '../SourceAdapter.ts';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const FIX = join(__dirname, 'fixtures');
|
||||||
|
const readFixture = (n: string) => JSON.parse(readFileSync(join(FIX, n), 'utf8'));
|
||||||
|
|
||||||
|
test('parseQuote extracts the live-quote fields from a recorded yf2 response', () => {
|
||||||
|
const raw = readFixture('nvda.quote.json');
|
||||||
|
const q = parseQuote(raw, 'NVDA');
|
||||||
|
assert.equal(q.symbol, 'NVDA');
|
||||||
|
assert.equal(q.price, raw.regularMarketPrice);
|
||||||
|
assert.equal(q.change, raw.regularMarketChange);
|
||||||
|
assert.equal(q.changePercent, raw.regularMarketChangePercent);
|
||||||
|
assert.equal(q.bid, raw.bid);
|
||||||
|
assert.equal(q.ask, raw.ask);
|
||||||
|
assert.equal(q.iv, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseQuote throws when regularMarketPrice is missing', () => {
|
||||||
|
assert.throws(() => parseQuote({ regularMarketPrice: undefined }, 'NVDA'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseCandles maps chart.quotes[] to typed candles, skipping nulls', () => {
|
||||||
|
const raw = readFixture('nvda.chart.json');
|
||||||
|
const candles = parseCandles(raw);
|
||||||
|
const expectedLen = (raw.quotes as Array<Record<string, unknown> | null>).filter((q) => q && q.close != null).length;
|
||||||
|
assert.equal(candles.length, expectedLen);
|
||||||
|
const first = candles[0];
|
||||||
|
assert.equal(first.ts, (raw.quotes[0] as { date: string }).date);
|
||||||
|
assert.equal(first.c, (raw.quotes[0] as { close: number }).close);
|
||||||
|
assert.equal(first.adjClose, (raw.quotes[0] as { adjclose: number }).adjclose);
|
||||||
|
for (const c of candles) {
|
||||||
|
assert.equal(typeof c.o, 'number'); assert.equal(typeof c.h, 'number');
|
||||||
|
assert.equal(typeof c.l, 'number'); assert.equal(typeof c.c, 'number');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseCandles returns [] when quotes is absent/empty', () => {
|
||||||
|
assert.deepEqual(parseCandles({}), []);
|
||||||
|
assert.deepEqual(parseCandles({ quotes: [] }), []);
|
||||||
|
assert.deepEqual(parseCandles({ quotes: [null, { close: null }] }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSymbol extracts sector/industry from summaryProfile', () => {
|
||||||
|
const raw = readFixture('nvda.summary.json');
|
||||||
|
const s = parseSymbol(raw, 'NVDA');
|
||||||
|
assert.equal(s.symbol, 'NVDA');
|
||||||
|
assert.equal(s.sector, (raw.summaryProfile as { sector: string }).sector);
|
||||||
|
assert.equal(s.industry, (raw.summaryProfile as { industry: string }).industry);
|
||||||
|
assert.equal(s.tickerKind, 'equity');
|
||||||
|
assert.equal(s.peers, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inferTickerKind: crypto/etf/index/equity', () => {
|
||||||
|
assert.equal(inferTickerKind('BTC'), 'crypto');
|
||||||
|
assert.equal(inferTickerKind('SPY'), 'etf');
|
||||||
|
assert.equal(inferTickerKind('^GSPC'), 'index');
|
||||||
|
assert.equal(inferTickerKind('NVDA'), 'equity');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FakeSourceAdapter returns canned responses and records calls', async () => {
|
||||||
|
const fake = new FakeSourceAdapter('yfinance');
|
||||||
|
fake.set('yfinance:quote:NVDA', { symbol: 'NVDA', price: 194.97 }, 'live_quote');
|
||||||
|
const r = await fake.fetchOne('yfinance:quote:NVDA');
|
||||||
|
assert.deepEqual(r.value, { symbol: 'NVDA', price: 194.97 });
|
||||||
|
assert.equal(r.ttlClass, 'live_quote');
|
||||||
|
assert.equal(r.provenance.sourceKind, 'yfinance');
|
||||||
|
assert.deepEqual(fake.calls, ['yfinance:quote:NVDA']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FakeSourceAdapter throws on missing canned key', async () => {
|
||||||
|
const fake = new FakeSourceAdapter();
|
||||||
|
await assert.rejects(() => fake.fetchOne('yfinance:quote:UNKNOWN'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"currency": "USD",
|
||||||
|
"symbol": "NVDA",
|
||||||
|
"exchangeName": "NMS",
|
||||||
|
"fullExchangeName": "NasdaqGS",
|
||||||
|
"instrumentType": "EQUITY",
|
||||||
|
"firstTradeDate": "1999-01-22T14:30:00.000Z",
|
||||||
|
"regularMarketTime": "2026-06-29T20:00:00.000Z",
|
||||||
|
"hasPrePostMarketData": true,
|
||||||
|
"gmtoffset": -14400,
|
||||||
|
"timezone": "EDT",
|
||||||
|
"exchangeTimezoneName": "America/New_York",
|
||||||
|
"regularMarketPrice": 194.97,
|
||||||
|
"fiftyTwoWeekHigh": 236.54,
|
||||||
|
"fiftyTwoWeekLow": 151.49,
|
||||||
|
"regularMarketDayHigh": 196.17,
|
||||||
|
"regularMarketDayLow": 189.8,
|
||||||
|
"regularMarketVolume": 146600013,
|
||||||
|
"longName": "NVIDIA Corporation",
|
||||||
|
"shortName": "NVIDIA Corporation",
|
||||||
|
"chartPreviousClose": 171.24,
|
||||||
|
"priceHint": 2,
|
||||||
|
"currentTradingPeriod": {
|
||||||
|
"pre": {
|
||||||
|
"timezone": "EDT",
|
||||||
|
"start": "2026-06-29T08:00:00.000Z",
|
||||||
|
"end": "2026-06-29T13:30:00.000Z",
|
||||||
|
"gmtoffset": -14400
|
||||||
|
},
|
||||||
|
"regular": {
|
||||||
|
"timezone": "EDT",
|
||||||
|
"start": "2026-06-29T13:30:00.000Z",
|
||||||
|
"end": "2026-06-29T20:00:00.000Z",
|
||||||
|
"gmtoffset": -14400
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"timezone": "EDT",
|
||||||
|
"start": "2026-06-29T20:00:00.000Z",
|
||||||
|
"end": "2026-06-30T00:00:00.000Z",
|
||||||
|
"gmtoffset": -14400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dataGranularity": "1d",
|
||||||
|
"range": "",
|
||||||
|
"validRanges": [
|
||||||
|
"1d",
|
||||||
|
"5d",
|
||||||
|
"1mo",
|
||||||
|
"3mo",
|
||||||
|
"6mo",
|
||||||
|
"1y",
|
||||||
|
"2y",
|
||||||
|
"5y",
|
||||||
|
"10y",
|
||||||
|
"ytd",
|
||||||
|
"max"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"quotes": [
|
||||||
|
{
|
||||||
|
"date": "2026-03-26T13:30:00.000Z",
|
||||||
|
"high": 176.50999450683594,
|
||||||
|
"volume": 186152200,
|
||||||
|
"open": 176.07000732421875,
|
||||||
|
"low": 171.13999938964844,
|
||||||
|
"close": 171.24000549316406,
|
||||||
|
"adjclose": 171.04066467285156
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-27T13:30:00.000Z",
|
||||||
|
"high": 170.97000122070312,
|
||||||
|
"volume": 196212700,
|
||||||
|
"open": 170,
|
||||||
|
"low": 167.00999450683594,
|
||||||
|
"close": 167.52000427246094,
|
||||||
|
"adjclose": 167.32498168945312
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-30T13:30:00.000Z",
|
||||||
|
"high": 169.4499969482422,
|
||||||
|
"volume": 185627000,
|
||||||
|
"open": 168.77999877929688,
|
||||||
|
"low": 164.27000427246094,
|
||||||
|
"close": 165.1699981689453,
|
||||||
|
"adjclose": 164.97772216796875
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-03-31T13:30:00.000Z",
|
||||||
|
"high": 174.6199951171875,
|
||||||
|
"volume": 226181300,
|
||||||
|
"open": 166.97000122070312,
|
||||||
|
"low": 166.9600067138672,
|
||||||
|
"close": 174.39999389648438,
|
||||||
|
"adjclose": 174.1969757080078
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-01T13:30:00.000Z",
|
||||||
|
"high": 177.3699951171875,
|
||||||
|
"volume": 168132000,
|
||||||
|
"open": 176,
|
||||||
|
"low": 174.75,
|
||||||
|
"close": 175.75,
|
||||||
|
"adjclose": 175.54541015625
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-02T13:30:00.000Z",
|
||||||
|
"high": 177.49000549316406,
|
||||||
|
"volume": 143143200,
|
||||||
|
"open": 172.17999267578125,
|
||||||
|
"low": 171.3699951171875,
|
||||||
|
"close": 177.38999938964844,
|
||||||
|
"adjclose": 177.18348693847656
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-06T13:30:00.000Z",
|
||||||
|
"high": 177.7899932861328,
|
||||||
|
"volume": 107564300,
|
||||||
|
"open": 177.16000366210938,
|
||||||
|
"low": 175.75999450683594,
|
||||||
|
"close": 177.63999938964844,
|
||||||
|
"adjclose": 177.43319702148438
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-07T13:30:00.000Z",
|
||||||
|
"high": 178.22999572753906,
|
||||||
|
"volume": 132534900,
|
||||||
|
"open": 175.72999572753906,
|
||||||
|
"low": 173.66000366210938,
|
||||||
|
"close": 178.10000610351562,
|
||||||
|
"adjclose": 177.89266967773438
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-08T13:30:00.000Z",
|
||||||
|
"high": 185.25999450683594,
|
||||||
|
"volume": 147732700,
|
||||||
|
"open": 184.5,
|
||||||
|
"low": 180.3000030517578,
|
||||||
|
"close": 182.0800018310547,
|
||||||
|
"adjclose": 181.8680419921875
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-09T13:30:00.000Z",
|
||||||
|
"high": 184.0800018310547,
|
||||||
|
"volume": 116428500,
|
||||||
|
"open": 181.83999633789062,
|
||||||
|
"low": 180.6199951171875,
|
||||||
|
"close": 183.91000366210938,
|
||||||
|
"adjclose": 183.69590759277344
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-10T13:30:00.000Z",
|
||||||
|
"high": 190,
|
||||||
|
"volume": 160459500,
|
||||||
|
"open": 184.30999755859375,
|
||||||
|
"low": 184.3000030517578,
|
||||||
|
"close": 188.6300048828125,
|
||||||
|
"adjclose": 188.41041564941406
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-13T13:30:00.000Z",
|
||||||
|
"high": 189.66000366210938,
|
||||||
|
"volume": 133648200,
|
||||||
|
"open": 186.02999877929688,
|
||||||
|
"low": 185.74000549316406,
|
||||||
|
"close": 189.30999755859375,
|
||||||
|
"adjclose": 189.08961486816406
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-14T13:30:00.000Z",
|
||||||
|
"high": 196.50999450683594,
|
||||||
|
"volume": 161307000,
|
||||||
|
"open": 190.83999633789062,
|
||||||
|
"low": 190.77000427246094,
|
||||||
|
"close": 196.50999450683594,
|
||||||
|
"adjclose": 196.28123474121094
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-15T13:30:00.000Z",
|
||||||
|
"high": 200.39999389648438,
|
||||||
|
"volume": 185338400,
|
||||||
|
"open": 196.5399932861328,
|
||||||
|
"low": 195.74000549316406,
|
||||||
|
"close": 198.8699951171875,
|
||||||
|
"adjclose": 198.63848876953125
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-16T13:30:00.000Z",
|
||||||
|
"high": 199.85000610351562,
|
||||||
|
"volume": 134012900,
|
||||||
|
"open": 197.42999267578125,
|
||||||
|
"low": 195.80999755859375,
|
||||||
|
"close": 198.35000610351562,
|
||||||
|
"adjclose": 198.1190948486328
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-17T13:30:00.000Z",
|
||||||
|
"high": 201.6999969482422,
|
||||||
|
"volume": 160324400,
|
||||||
|
"open": 199.89999389648438,
|
||||||
|
"low": 199.27000427246094,
|
||||||
|
"close": 201.67999267578125,
|
||||||
|
"adjclose": 201.44520568847656
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-20T13:30:00.000Z",
|
||||||
|
"high": 202.1699981689453,
|
||||||
|
"volume": 119381400,
|
||||||
|
"open": 199.97999572753906,
|
||||||
|
"low": 197.83999633789062,
|
||||||
|
"close": 202.05999755859375,
|
||||||
|
"adjclose": 201.82476806640625
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-21T13:30:00.000Z",
|
||||||
|
"high": 202.75,
|
||||||
|
"volume": 107945300,
|
||||||
|
"open": 202.1300048828125,
|
||||||
|
"low": 199,
|
||||||
|
"close": 199.8800048828125,
|
||||||
|
"adjclose": 199.64732360839844
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-22T13:30:00.000Z",
|
||||||
|
"high": 202.5,
|
||||||
|
"volume": 107501000,
|
||||||
|
"open": 200.99000549316406,
|
||||||
|
"low": 199,
|
||||||
|
"close": 202.5,
|
||||||
|
"adjclose": 202.26426696777344
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-23T13:30:00.000Z",
|
||||||
|
"high": 203.8300018310547,
|
||||||
|
"volume": 113561800,
|
||||||
|
"open": 202.4600067138672,
|
||||||
|
"low": 197.22000122070312,
|
||||||
|
"close": 199.63999938964844,
|
||||||
|
"adjclose": 199.4075927734375
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-24T13:30:00.000Z",
|
||||||
|
"high": 210.9499969482422,
|
||||||
|
"volume": 214134400,
|
||||||
|
"open": 199.9600067138672,
|
||||||
|
"low": 199.80999755859375,
|
||||||
|
"close": 208.27000427246094,
|
||||||
|
"adjclose": 208.0275421142578
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-27T13:30:00.000Z",
|
||||||
|
"high": 216.8300018310547,
|
||||||
|
"volume": 187172400,
|
||||||
|
"open": 209.64999389648438,
|
||||||
|
"low": 207.3800048828125,
|
||||||
|
"close": 216.61000061035156,
|
||||||
|
"adjclose": 216.3578338623047
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-28T13:30:00.000Z",
|
||||||
|
"high": 214.72999572753906,
|
||||||
|
"volume": 180275400,
|
||||||
|
"open": 209.49000549316406,
|
||||||
|
"low": 208.1999969482422,
|
||||||
|
"close": 213.1699981689453,
|
||||||
|
"adjclose": 212.92184448242188
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-29T13:30:00.000Z",
|
||||||
|
"high": 212.72000122070312,
|
||||||
|
"volume": 123711800,
|
||||||
|
"open": 212.6999969482422,
|
||||||
|
"low": 207.5800018310547,
|
||||||
|
"close": 209.25,
|
||||||
|
"adjclose": 209.00640869140625
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-04-30T13:30:00.000Z",
|
||||||
|
"high": 210.3000030517578,
|
||||||
|
"volume": 225239200,
|
||||||
|
"open": 209.92999267578125,
|
||||||
|
"low": 198.6999969482422,
|
||||||
|
"close": 199.57000732421875,
|
||||||
|
"adjclose": 199.33767700195312
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-01T13:30:00.000Z",
|
||||||
|
"high": 203,
|
||||||
|
"volume": 128647000,
|
||||||
|
"open": 201.27999877929688,
|
||||||
|
"low": 197.1199951171875,
|
||||||
|
"close": 198.4499969482422,
|
||||||
|
"adjclose": 198.21897888183594
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-04T13:30:00.000Z",
|
||||||
|
"high": 201.72999572753906,
|
||||||
|
"volume": 125368100,
|
||||||
|
"open": 199.5,
|
||||||
|
"low": 194.74000549316406,
|
||||||
|
"close": 198.47999572753906,
|
||||||
|
"adjclose": 198.24893188476562
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-05T13:30:00.000Z",
|
||||||
|
"high": 200.24000549316406,
|
||||||
|
"volume": 113406600,
|
||||||
|
"open": 199.3000030517578,
|
||||||
|
"low": 196.02999877929688,
|
||||||
|
"close": 196.5,
|
||||||
|
"adjclose": 196.271240234375
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-06T13:30:00.000Z",
|
||||||
|
"high": 208.27000427246094,
|
||||||
|
"volume": 188362800,
|
||||||
|
"open": 199.88999938964844,
|
||||||
|
"low": 198.61000061035156,
|
||||||
|
"close": 207.8300018310547,
|
||||||
|
"adjclose": 207.5880584716797
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-07T13:30:00.000Z",
|
||||||
|
"high": 214.1999969482422,
|
||||||
|
"volume": 168307900,
|
||||||
|
"open": 208.33999633789062,
|
||||||
|
"low": 206.5,
|
||||||
|
"close": 211.5,
|
||||||
|
"adjclose": 211.2537841796875
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-08T13:30:00.000Z",
|
||||||
|
"high": 217.8000030517578,
|
||||||
|
"volume": 136421400,
|
||||||
|
"open": 213.02999877929688,
|
||||||
|
"low": 212.88999938964844,
|
||||||
|
"close": 215.1999969482422,
|
||||||
|
"adjclose": 214.94947814941406
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-11T13:30:00.000Z",
|
||||||
|
"high": 222.3000030517578,
|
||||||
|
"volume": 160685800,
|
||||||
|
"open": 214.0399932861328,
|
||||||
|
"low": 213.88999938964844,
|
||||||
|
"close": 219.44000244140625,
|
||||||
|
"adjclose": 219.18453979492188
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-12T13:30:00.000Z",
|
||||||
|
"high": 223.75,
|
||||||
|
"volume": 159176600,
|
||||||
|
"open": 218.5500030517578,
|
||||||
|
"low": 214.9199981689453,
|
||||||
|
"close": 220.77999877929688,
|
||||||
|
"adjclose": 220.52297973632812
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-13T13:30:00.000Z",
|
||||||
|
"high": 227.83999633789062,
|
||||||
|
"volume": 150405400,
|
||||||
|
"open": 224.92999267578125,
|
||||||
|
"low": 221.57000732421875,
|
||||||
|
"close": 225.8300018310547,
|
||||||
|
"adjclose": 225.56710815429688
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-14T13:30:00.000Z",
|
||||||
|
"high": 236.5399932861328,
|
||||||
|
"volume": 180782900,
|
||||||
|
"open": 229.85000610351562,
|
||||||
|
"low": 229.3000030517578,
|
||||||
|
"close": 235.74000549316406,
|
||||||
|
"adjclose": 235.465576171875
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-15T13:30:00.000Z",
|
||||||
|
"high": 231.5,
|
||||||
|
"volume": 180977600,
|
||||||
|
"open": 229.75999450683594,
|
||||||
|
"low": 224.24000549316406,
|
||||||
|
"close": 225.32000732421875,
|
||||||
|
"adjclose": 225.05770874023438
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-18T13:30:00.000Z",
|
||||||
|
"high": 230,
|
||||||
|
"volume": 146280900,
|
||||||
|
"open": 229.8699951171875,
|
||||||
|
"low": 218.3699951171875,
|
||||||
|
"close": 222.32000732421875,
|
||||||
|
"adjclose": 222.0612030029297
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-19T13:30:00.000Z",
|
||||||
|
"high": 224.47999572753906,
|
||||||
|
"volume": 140948200,
|
||||||
|
"open": 219.6199951171875,
|
||||||
|
"low": 217.91000366210938,
|
||||||
|
"close": 220.61000061035156,
|
||||||
|
"adjclose": 220.35317993164062
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-20T13:30:00.000Z",
|
||||||
|
"high": 226.1300048828125,
|
||||||
|
"volume": 184201600,
|
||||||
|
"open": 223.17999267578125,
|
||||||
|
"low": 220.5,
|
||||||
|
"close": 223.47000122070312,
|
||||||
|
"adjclose": 223.20985412597656
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-21T13:30:00.000Z",
|
||||||
|
"high": 227.39999389648438,
|
||||||
|
"volume": 203381800,
|
||||||
|
"open": 222.2899932861328,
|
||||||
|
"low": 217.92999267578125,
|
||||||
|
"close": 219.50999450683594,
|
||||||
|
"adjclose": 219.25445556640625
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-22T13:30:00.000Z",
|
||||||
|
"high": 221.00999450683594,
|
||||||
|
"volume": 169275700,
|
||||||
|
"open": 220.89999389648438,
|
||||||
|
"low": 214.8000030517578,
|
||||||
|
"close": 215.3300018310547,
|
||||||
|
"adjclose": 215.07933044433594
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-26T13:30:00.000Z",
|
||||||
|
"high": 218.17999267578125,
|
||||||
|
"volume": 187202600,
|
||||||
|
"open": 216.5399932861328,
|
||||||
|
"low": 212,
|
||||||
|
"close": 214.86000061035156,
|
||||||
|
"adjclose": 214.60987854003906
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-27T13:30:00.000Z",
|
||||||
|
"high": 214.14999389648438,
|
||||||
|
"volume": 167601200,
|
||||||
|
"open": 214.1199951171875,
|
||||||
|
"low": 208.77999877929688,
|
||||||
|
"close": 212.60000610351562,
|
||||||
|
"adjclose": 212.35250854492188
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-28T13:30:00.000Z",
|
||||||
|
"high": 215.52000427246094,
|
||||||
|
"volume": 143996000,
|
||||||
|
"open": 211.27999877929688,
|
||||||
|
"low": 211.22000122070312,
|
||||||
|
"close": 214.25,
|
||||||
|
"adjclose": 214.00057983398438
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-05-29T13:30:00.000Z",
|
||||||
|
"high": 217.86000061035156,
|
||||||
|
"volume": 289410600,
|
||||||
|
"open": 214.5800018310547,
|
||||||
|
"low": 211.1300048828125,
|
||||||
|
"close": 211.13999938964844,
|
||||||
|
"adjclose": 210.8942108154297
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-01T13:30:00.000Z",
|
||||||
|
"high": 224.8699951171875,
|
||||||
|
"volume": 212850700,
|
||||||
|
"open": 215.72999572753906,
|
||||||
|
"low": 215.6999969482422,
|
||||||
|
"close": 224.36000061035156,
|
||||||
|
"adjclose": 224.09881591796875
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-02T13:30:00.000Z",
|
||||||
|
"high": 232.27999877929688,
|
||||||
|
"volume": 193362900,
|
||||||
|
"open": 227.17999267578125,
|
||||||
|
"low": 221.35000610351562,
|
||||||
|
"close": 222.82000732421875,
|
||||||
|
"adjclose": 222.56060791015625
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-03T13:30:00.000Z",
|
||||||
|
"high": 222.82000732421875,
|
||||||
|
"volume": 160907000,
|
||||||
|
"open": 221.72000122070312,
|
||||||
|
"low": 214.50999450683594,
|
||||||
|
"close": 214.75,
|
||||||
|
"adjclose": 214.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-04T13:30:00.000Z",
|
||||||
|
"high": 221.60000610351562,
|
||||||
|
"volume": 169022200,
|
||||||
|
"open": 213.91000366210938,
|
||||||
|
"low": 210.97000122070312,
|
||||||
|
"close": 218.66000366210938,
|
||||||
|
"adjclose": 218.66000366210938
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-05T13:30:00.000Z",
|
||||||
|
"high": 214.8699951171875,
|
||||||
|
"volume": 219655500,
|
||||||
|
"open": 214.52999877929688,
|
||||||
|
"low": 204.3300018310547,
|
||||||
|
"close": 205.10000610351562,
|
||||||
|
"adjclose": 205.10000610351562
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-08T13:30:00.000Z",
|
||||||
|
"high": 210.47000122070312,
|
||||||
|
"volume": 138372800,
|
||||||
|
"open": 210.17999267578125,
|
||||||
|
"low": 206,
|
||||||
|
"close": 208.63999938964844,
|
||||||
|
"adjclose": 208.63999938964844
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-09T13:30:00.000Z",
|
||||||
|
"high": 211.39999389648438,
|
||||||
|
"volume": 180962500,
|
||||||
|
"open": 210.6199951171875,
|
||||||
|
"low": 199.33999633789062,
|
||||||
|
"close": 208.19000244140625,
|
||||||
|
"adjclose": 208.19000244140625
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-10T13:30:00.000Z",
|
||||||
|
"high": 207.22000122070312,
|
||||||
|
"volume": 161746600,
|
||||||
|
"open": 204.42999267578125,
|
||||||
|
"low": 199.9199981689453,
|
||||||
|
"close": 200.4199981689453,
|
||||||
|
"adjclose": 200.4199981689453
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-11T13:30:00.000Z",
|
||||||
|
"high": 205.66000366210938,
|
||||||
|
"volume": 158643200,
|
||||||
|
"open": 201.49000549316406,
|
||||||
|
"low": 199.5399932861328,
|
||||||
|
"close": 204.8699951171875,
|
||||||
|
"adjclose": 204.8699951171875
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-12T13:30:00.000Z",
|
||||||
|
"high": 207.07000732421875,
|
||||||
|
"volume": 112001800,
|
||||||
|
"open": 204.86000061035156,
|
||||||
|
"low": 203.44000244140625,
|
||||||
|
"close": 205.19000244140625,
|
||||||
|
"adjclose": 205.19000244140625
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-15T13:30:00.000Z",
|
||||||
|
"high": 212.7100067138672,
|
||||||
|
"volume": 149936700,
|
||||||
|
"open": 208.9199981689453,
|
||||||
|
"low": 208.33999633789062,
|
||||||
|
"close": 212.4499969482422,
|
||||||
|
"adjclose": 212.4499969482422
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-16T13:30:00.000Z",
|
||||||
|
"high": 211.49000549316406,
|
||||||
|
"volume": 125694100,
|
||||||
|
"open": 211.17999267578125,
|
||||||
|
"low": 207.2899932861328,
|
||||||
|
"close": 207.41000366210938,
|
||||||
|
"adjclose": 207.41000366210938
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-17T13:30:00.000Z",
|
||||||
|
"high": 209.2100067138672,
|
||||||
|
"volume": 128363500,
|
||||||
|
"open": 208.52999877929688,
|
||||||
|
"low": 203.0800018310547,
|
||||||
|
"close": 204.64999389648438,
|
||||||
|
"adjclose": 204.64999389648438
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-18T13:30:00.000Z",
|
||||||
|
"high": 211.38999938964844,
|
||||||
|
"volume": 241272000,
|
||||||
|
"open": 207.3300018310547,
|
||||||
|
"low": 206.5,
|
||||||
|
"close": 210.69000244140625,
|
||||||
|
"adjclose": 210.69000244140625
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-22T13:30:00.000Z",
|
||||||
|
"high": 213.99000549316406,
|
||||||
|
"volume": 122041400,
|
||||||
|
"open": 211.44000244140625,
|
||||||
|
"low": 207.72000122070312,
|
||||||
|
"close": 208.64999389648438,
|
||||||
|
"adjclose": 208.64999389648438
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-23T13:30:00.000Z",
|
||||||
|
"high": 203.77000427246094,
|
||||||
|
"volume": 153496200,
|
||||||
|
"open": 202.1699981689453,
|
||||||
|
"low": 200,
|
||||||
|
"close": 200.0399932861328,
|
||||||
|
"adjclose": 200.0399932861328
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-24T13:30:00.000Z",
|
||||||
|
"high": 201.6699981689453,
|
||||||
|
"volume": 151810700,
|
||||||
|
"open": 200.1199951171875,
|
||||||
|
"low": 196.5800018310547,
|
||||||
|
"close": 199,
|
||||||
|
"adjclose": 199
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-25T13:30:00.000Z",
|
||||||
|
"high": 200.8000030517578,
|
||||||
|
"volume": 149550000,
|
||||||
|
"open": 200.0800018310547,
|
||||||
|
"low": 192.1300048828125,
|
||||||
|
"close": 195.74000549316406,
|
||||||
|
"adjclose": 195.74000549316406
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-26T13:30:00.000Z",
|
||||||
|
"high": 195.5500030517578,
|
||||||
|
"volume": 178906300,
|
||||||
|
"open": 193.1199951171875,
|
||||||
|
"low": 191.22000122070312,
|
||||||
|
"close": 192.52999877929688,
|
||||||
|
"adjclose": 192.52999877929688
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-06-29T13:30:00.000Z",
|
||||||
|
"high": 196.1699981689453,
|
||||||
|
"volume": 146600013,
|
||||||
|
"open": 193.78500366210938,
|
||||||
|
"low": 189.8000030517578,
|
||||||
|
"close": 194.97000122070312,
|
||||||
|
"adjclose": 194.97000122070312
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"events": {
|
||||||
|
"dividends": [
|
||||||
|
{
|
||||||
|
"amount": 0.25,
|
||||||
|
"date": "2026-06-04T13:30:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"language": "en-US",
|
||||||
|
"region": "US",
|
||||||
|
"quoteType": "EQUITY",
|
||||||
|
"typeDisp": "Equity",
|
||||||
|
"quoteSourceName": "Nasdaq Real Time Price",
|
||||||
|
"triggerable": true,
|
||||||
|
"customPriceAlertConfidence": "HIGH",
|
||||||
|
"currency": "USD",
|
||||||
|
"corporateActions": [],
|
||||||
|
"postMarketTime": "2026-06-29T21:04:54.000Z",
|
||||||
|
"regularMarketTime": "2026-06-29T20:00:00.000Z",
|
||||||
|
"exchange": "NMS",
|
||||||
|
"messageBoardId": "finmb_32307",
|
||||||
|
"exchangeTimezoneName": "America/New_York",
|
||||||
|
"exchangeTimezoneShortName": "EDT",
|
||||||
|
"gmtOffSetMilliseconds": -14400000,
|
||||||
|
"market": "us_market",
|
||||||
|
"esgPopulated": false,
|
||||||
|
"regularMarketChangePercent": 1.2673362,
|
||||||
|
"regularMarketPrice": 194.97,
|
||||||
|
"marketState": "POST",
|
||||||
|
"shortName": "NVIDIA Corporation",
|
||||||
|
"longName": "NVIDIA Corporation",
|
||||||
|
"epsTrailingTwelveMonths": 6.53,
|
||||||
|
"epsForward": 12.76443,
|
||||||
|
"epsCurrentYear": 8.96197,
|
||||||
|
"priceEpsCurrentYear": 21.75526,
|
||||||
|
"sharesOutstanding": 24221000000,
|
||||||
|
"impliedSharesOutstanding": 24221000000,
|
||||||
|
"bookValue": 8.07,
|
||||||
|
"fiftyDayAverage": 210.086,
|
||||||
|
"fiftyDayAverageChange": -15.115997,
|
||||||
|
"fiftyDayAverageChangePercent": -0.07195147,
|
||||||
|
"twoHundredDayAverage": 190.6402,
|
||||||
|
"twoHundredDayAverageChange": 4.3298035,
|
||||||
|
"twoHundredDayAverageChangePercent": 0.022711912,
|
||||||
|
"marketCap": 4722368446464,
|
||||||
|
"forwardPE": 15.274478,
|
||||||
|
"priceToBook": 24.159853,
|
||||||
|
"sourceInterval": 15,
|
||||||
|
"exchangeDataDelayedBy": 0,
|
||||||
|
"prevName": "Usual Stablecoin",
|
||||||
|
"nameChangeDate": "2026-06-29T00:00:00.000Z",
|
||||||
|
"averageAnalystRating": "1.3 - Strong Buy",
|
||||||
|
"tradeable": false,
|
||||||
|
"cryptoTradeable": false,
|
||||||
|
"hasPrePostMarketData": true,
|
||||||
|
"firstTradeDateMilliseconds": "1999-01-22T14:30:00.000Z",
|
||||||
|
"priceHint": 2,
|
||||||
|
"postMarketChangePercent": -0.025646536,
|
||||||
|
"postMarketPrice": 194.92,
|
||||||
|
"postMarketChange": -0.05000305,
|
||||||
|
"regularMarketChange": 2.4400024,
|
||||||
|
"regularMarketDayHigh": 196.17,
|
||||||
|
"regularMarketDayRange": {
|
||||||
|
"low": 189.8,
|
||||||
|
"high": 196.17
|
||||||
|
},
|
||||||
|
"regularMarketDayLow": 189.8,
|
||||||
|
"regularMarketVolume": 146600013,
|
||||||
|
"regularMarketPreviousClose": 192.53,
|
||||||
|
"bid": 194.26,
|
||||||
|
"ask": 195.95,
|
||||||
|
"bidSize": 40,
|
||||||
|
"askSize": 40,
|
||||||
|
"fullExchangeName": "NasdaqGS",
|
||||||
|
"financialCurrency": "USD",
|
||||||
|
"regularMarketOpen": 193.85,
|
||||||
|
"averageDailyVolume3Month": 160883770,
|
||||||
|
"averageDailyVolume10Day": 151307270,
|
||||||
|
"fiftyTwoWeekLowChange": 43.479996,
|
||||||
|
"fiftyTwoWeekLowChangePercent": 0.28701562,
|
||||||
|
"fiftyTwoWeekRange": {
|
||||||
|
"low": 151.49,
|
||||||
|
"high": 236.54
|
||||||
|
},
|
||||||
|
"fiftyTwoWeekHighChange": -41.569992,
|
||||||
|
"fiftyTwoWeekHighChangePercent": -0.17574191,
|
||||||
|
"fiftyTwoWeekLow": 151.49,
|
||||||
|
"fiftyTwoWeekHigh": 236.54,
|
||||||
|
"fiftyTwoWeekChangePercent": 21.862137,
|
||||||
|
"dividendDate": "2026-06-26T00:00:00.000Z",
|
||||||
|
"earningsTimestamp": "2026-08-26T20:00:00.000Z",
|
||||||
|
"earningsTimestampStart": "2026-08-26T20:00:00.000Z",
|
||||||
|
"earningsTimestampEnd": "2026-08-26T20:00:00.000Z",
|
||||||
|
"earningsCallTimestampStart": "2026-05-20T21:00:00.000Z",
|
||||||
|
"earningsCallTimestampEnd": "2026-05-20T21:00:00.000Z",
|
||||||
|
"isEarningsDateEstimate": false,
|
||||||
|
"trailingAnnualDividendRate": 0.04,
|
||||||
|
"trailingPE": 29.85758,
|
||||||
|
"dividendRate": 1,
|
||||||
|
"trailingAnnualDividendYield": 0.00020775983,
|
||||||
|
"dividendYield": 0.52,
|
||||||
|
"displayName": "NVIDIA",
|
||||||
|
"symbol": "NVDA"
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"summaryProfile": {
|
||||||
|
"address1": "2788 San Tomas Expressway",
|
||||||
|
"city": "Santa Clara",
|
||||||
|
"state": "CA",
|
||||||
|
"zip": "95051",
|
||||||
|
"country": "United States",
|
||||||
|
"phone": "408 486 2000",
|
||||||
|
"website": "https://www.nvidia.com",
|
||||||
|
"industry": "Semiconductors",
|
||||||
|
"industryKey": "semiconductors",
|
||||||
|
"industryDisp": "Semiconductors",
|
||||||
|
"sector": "Technology",
|
||||||
|
"sectorKey": "technology",
|
||||||
|
"sectorDisp": "Technology",
|
||||||
|
"longBusinessSummary": "NVIDIA Corporation operates as a data center scale AI infrastructure company in the United States, Taiwan, China, Hong Kong, Europe, and internationally. It operates through Compute & Networking, and Graphics segments. The Compute & Networking segment provides data center accelerated computing and networking platforms and artificial intelligence solutions and software, and automotive platforms and autonomous and electric vehicle solutions, including software. The Graphics segment offers GeForce GPUs for gaming and PCs; Quadro/NVIDIA RTX GPUs for enterprise workstation graphics. The company's products are used in gaming, professional visualization, data center, and automotive markets. It sells its products to original equipment manufacturers, original device manufacturers, system integrators and distributors, independent software vendors, cloud service providers, add-in board manufacturers, distributors, automotive manufacturers and tier-1 automotive suppliers, and other ecosystem participants. NVIDIA Corporation was incorporated in 1993 and is headquartered in Santa Clara, California.",
|
||||||
|
"fullTimeEmployees": 42000,
|
||||||
|
"companyOfficers": [],
|
||||||
|
"irWebsite": "http://phx.corporate-ir.net/phoenix.zhtml?c=116466&p=irol-IRHome",
|
||||||
|
"executiveTeam": [],
|
||||||
|
"maxAge": 86400
|
||||||
|
}
|
||||||
|
}
|
||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
// Investor Flow — CacheRepository deep module (DESIGN.md §3a Module 1).
|
||||||
|
// The ONLY interface the SPA (via tRPC) touches for cached data. Owns staleness windows,
|
||||||
|
// refcount/demand-set, and stale-while-revalidate. Does NOT talk to external sources —
|
||||||
|
// that is SourceAdapter's job; CacheRepository only schedules background refreshes via
|
||||||
|
// the injected scheduler (SourceAdapter/AdapterQueue satisfy `CacheScheduler`).
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { db as defaultDb } from '../db/client.ts';
|
||||||
|
|
||||||
|
export type SourceKind = 'yfinance' | 'sec' | 'reddit' | 'x' | 'macro' | 'llm';
|
||||||
|
export type TickerKind = 'equity' | 'crypto' | 'etf' | 'index';
|
||||||
|
export type CacheKey = string; // `${SourceKind}:${kind}:${id}` e.g. 'yfinance:quote:NVDA', 'yfinance:candles:NVDA:1d'
|
||||||
|
export type TtlClass =
|
||||||
|
| 'live_quote' | 'intraday' | 'daily_permanent' | 'options_snapshot'
|
||||||
|
| 'filing_immutable' | 'quarterly_immutable' | 'thread_7d'
|
||||||
|
| 'macro_event' | 'regime_classification' | 'llm_summary' | 'symbol_meta';
|
||||||
|
|
||||||
|
export interface Provenance { fetchedAt: string; sourceKind: SourceKind; rawSourceId?: string; }
|
||||||
|
|
||||||
|
export interface Quote { symbol: string; price: number; bid?: number | null; ask?: number | null; change?: number | null; changePercent?: number | null; iv?: number | null; }
|
||||||
|
export interface PriceCandle { ts: string; o: number; h: number; l: number; c: number; v: number; adjClose?: number | null; }
|
||||||
|
export interface SymbolMeta { symbol: string; name?: string | null; sector?: string | null; industry?: string | null; exchange?: string | null; tickerKind: TickerKind; peers?: string[] | null; }
|
||||||
|
|
||||||
|
/** Port CacheRepository depends on to schedule background refreshes. SourceAdapter/AdapterQueue satisfy this. */
|
||||||
|
export interface CacheScheduler { queue(key: CacheKey): Promise<void>; }
|
||||||
|
|
||||||
|
export interface CacheEntry<T> { value: T | null; provenance: Provenance | null; isStale: boolean; }
|
||||||
|
|
||||||
|
export const TTL_MS: Record<TtlClass, number> = {
|
||||||
|
live_quote: 60_000, // 1min (mkt hrs); after-hours 15min refined in adapter slice
|
||||||
|
intraday: 5 * 60_000,
|
||||||
|
daily_permanent: Number.POSITIVE_INFINITY, // immutable once fetched; stale only when absent
|
||||||
|
options_snapshot: 15 * 60_000,
|
||||||
|
filing_immutable: Number.POSITIVE_INFINITY,
|
||||||
|
quarterly_immutable: Number.POSITIVE_INFINITY,
|
||||||
|
thread_7d: 7 * 24 * 60 * 60_000,
|
||||||
|
macro_event: Number.POSITIVE_INFINITY,
|
||||||
|
regime_classification: 24 * 60 * 60_000,
|
||||||
|
llm_summary: Number.POSITIVE_INFINITY, // cached forever by prompt-hash
|
||||||
|
symbol_meta: 7 * 24 * 60 * 60_000, // weekly (slow-changing sector/industry)
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parse 'source:kind:id...' into { source, kind, id } (id may contain colons). */
|
||||||
|
export function parseCacheKey(key: CacheKey): { source: SourceKind; kind: string; id: string } {
|
||||||
|
const parts = key.split(':');
|
||||||
|
if (parts.length < 3) throw new Error(`invalid cache key: ${key}`);
|
||||||
|
const source = parts[0] as SourceKind;
|
||||||
|
const kind = parts[1];
|
||||||
|
const id = parts.slice(2).join(':');
|
||||||
|
return { source, kind, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KindHandler {
|
||||||
|
ttlClass: TtlClass;
|
||||||
|
/** Read cached value + the timestamp to judge staleness against. null if not cached. */
|
||||||
|
read(d: DatabaseSync, id: string): { value: unknown; stalenessTs: string } | null;
|
||||||
|
/** Write value to cache, stamping provenance. */
|
||||||
|
write(d: DatabaseSync, id: string, value: unknown, provenance: Provenance): void;
|
||||||
|
/** Staleness verdict given the staleness timestamp (null = never cached) and now. */
|
||||||
|
isStale(stalenessTs: string | null, now: number): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tsAgeMs(ts: string | null, now: number): number {
|
||||||
|
if (!ts) return Number.POSITIVE_INFINITY;
|
||||||
|
const t = Date.parse(ts);
|
||||||
|
if (Number.isNaN(t)) return Number.POSITIVE_INFINITY;
|
||||||
|
return now - t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Kind handlers (slice 1: quote, candles, symbol). Later slices register more. -----
|
||||||
|
const quoteHandler: KindHandler = {
|
||||||
|
ttlClass: 'live_quote',
|
||||||
|
read(d, symbol) {
|
||||||
|
const r = d.prepare('SELECT symbol,price,bid,ask,change,change_pct,iv,observed_at FROM quotes WHERE symbol=?').get(symbol) as Record<string, unknown> | undefined;
|
||||||
|
if (!r) return null;
|
||||||
|
return {
|
||||||
|
value: { symbol: r.symbol, price: r.price, bid: r.bid, ask: r.ask, change: r.change, changePercent: r.change_pct, iv: r.iv } as Quote,
|
||||||
|
stalenessTs: r.observed_at as string,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
write(d, symbol, value, provenance) {
|
||||||
|
const q = value as Quote;
|
||||||
|
d.prepare('INSERT OR REPLACE INTO quotes (symbol,price,bid,ask,change,change_pct,iv,observed_at) VALUES (?,?,?,?,?,?,?,?)')
|
||||||
|
.run(symbol, q.price, q.bid ?? null, q.ask ?? null, q.change ?? null, q.changePercent ?? null, q.iv ?? null, provenance.fetchedAt);
|
||||||
|
},
|
||||||
|
isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.live_quote; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const candlesHandler: KindHandler = {
|
||||||
|
ttlClass: 'daily_permanent',
|
||||||
|
read(d, id) {
|
||||||
|
const [symbol, timeframe] = id.split(':');
|
||||||
|
if (!timeframe) return null;
|
||||||
|
const rows = d.prepare('SELECT ts,o,h,l,c,v,adj_close,observed_at FROM price_candles WHERE symbol=? AND timeframe=? ORDER BY ts ASC').all(symbol, timeframe) as Array<Record<string, unknown>>;
|
||||||
|
if (!rows.length) return null;
|
||||||
|
const value: PriceCandle[] = rows.map((r) => ({ ts: r.ts as string, o: r.o as number, h: r.h as number, l: r.l as number, c: r.c as number, v: r.v as number, adjClose: r.adj_close as number | null }));
|
||||||
|
return { value, stalenessTs: rows[rows.length - 1].observed_at as string };
|
||||||
|
},
|
||||||
|
write(d, id, value, provenance) {
|
||||||
|
const [symbol, timeframe] = id.split(':');
|
||||||
|
const ins = d.prepare('INSERT OR REPLACE INTO price_candles (symbol,timeframe,ts,o,h,l,c,v,adj_close,observed_at) VALUES (?,?,?,?,?,?,?,?,?,?)');
|
||||||
|
for (const c of value as PriceCandle[]) ins.run(symbol, timeframe, c.ts, c.o, c.h, c.l, c.c, c.v, c.adjClose ?? null, provenance.fetchedAt);
|
||||||
|
},
|
||||||
|
isStale(ts) { return ts === null; }, // permanent: stale only when absent
|
||||||
|
};
|
||||||
|
|
||||||
|
const symbolHandler: KindHandler = {
|
||||||
|
ttlClass: 'symbol_meta',
|
||||||
|
read(d, symbol) {
|
||||||
|
const r = d.prepare('SELECT symbol,name,sector,industry,exchange,ticker_kind,peers,updated_at FROM symbols WHERE symbol=?').get(symbol) as Record<string, unknown> | undefined;
|
||||||
|
if (!r) return null;
|
||||||
|
let peers: string[] | null = null;
|
||||||
|
if (typeof r.peers === 'string') { try { peers = JSON.parse(r.peers); } catch { peers = null; } }
|
||||||
|
return {
|
||||||
|
value: { symbol: r.symbol, name: r.name, sector: r.sector, industry: r.industry, exchange: r.exchange, tickerKind: r.ticker_kind, peers } as SymbolMeta,
|
||||||
|
stalenessTs: r.updated_at as string,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
write(d, symbol, value, provenance) {
|
||||||
|
const s = value as SymbolMeta;
|
||||||
|
d.prepare('INSERT OR REPLACE INTO symbols (symbol,name,sector,industry,exchange,ticker_kind,peers,updated_at) VALUES (?,?,?,?,?,?,?,?)')
|
||||||
|
.run(symbol, s.name ?? null, s.sector ?? null, s.industry ?? null, s.exchange ?? null, s.tickerKind, s.peers ? JSON.stringify(s.peers) : null, provenance.fetchedAt);
|
||||||
|
},
|
||||||
|
isStale(ts, now) { return tsAgeMs(ts, now) > TTL_MS.symbol_meta; },
|
||||||
|
};
|
||||||
|
|
||||||
|
const HANDLERS = new Map<string, KindHandler>([
|
||||||
|
['quote', quoteHandler],
|
||||||
|
['candles', candlesHandler],
|
||||||
|
['symbol', symbolHandler],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface CacheRepository {
|
||||||
|
get<T>(key: CacheKey): Promise<CacheEntry<T>>;
|
||||||
|
set<T>(key: CacheKey, value: T, ttlClass: TtlClass, provenance: Provenance): Promise<void>;
|
||||||
|
stale(key: CacheKey): boolean;
|
||||||
|
subscribe(symbol: string, tickerKind: TickerKind): Promise<void>;
|
||||||
|
unsubscribe(symbol: string): Promise<void>;
|
||||||
|
demandSet(): Promise<string[]>;
|
||||||
|
getMany<T>(keys: CacheKey[]): Promise<Array<{ key: CacheKey; value: T | null; isStale: boolean }>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CacheRepositoryImpl implements CacheRepository {
|
||||||
|
private readonly _db: DatabaseSync;
|
||||||
|
private readonly _scheduler: CacheScheduler;
|
||||||
|
constructor(opts: { db: DatabaseSync; scheduler: CacheScheduler }) {
|
||||||
|
this._db = opts.db;
|
||||||
|
this._scheduler = opts.scheduler;
|
||||||
|
}
|
||||||
|
private handler(kind: string): KindHandler {
|
||||||
|
const h = HANDLERS.get(kind);
|
||||||
|
if (!h) throw new Error(`unknown cache kind: ${kind}`);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
async get<T>(key: CacheKey): Promise<CacheEntry<T>> {
|
||||||
|
const { source, kind, id } = parseCacheKey(key);
|
||||||
|
const h = this.handler(kind);
|
||||||
|
const row = h.read(this._db, id);
|
||||||
|
const now = Date.now();
|
||||||
|
const stale = h.isStale(row ? row.stalenessTs : null, now);
|
||||||
|
if (stale) {
|
||||||
|
try { await this._scheduler.queue(key); } catch { /* background refresh; never block readers */ }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: (row ? row.value : null) as T | null,
|
||||||
|
provenance: row ? { fetchedAt: row.stalenessTs, sourceKind: source } : null,
|
||||||
|
isStale: stale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async set<T>(key: CacheKey, value: T, ttlClass: TtlClass, provenance: Provenance): Promise<void> {
|
||||||
|
const { kind, id } = parseCacheKey(key);
|
||||||
|
const h = this.handler(kind);
|
||||||
|
if (h.ttlClass !== ttlClass) throw new Error(`ttlClass mismatch for kind '${kind}': expected ${h.ttlClass}, got ${ttlClass}`);
|
||||||
|
h.write(this._db, id, value, provenance);
|
||||||
|
}
|
||||||
|
stale(key: CacheKey): boolean {
|
||||||
|
const { kind, id } = parseCacheKey(key);
|
||||||
|
const h = this.handler(kind);
|
||||||
|
const row = h.read(this._db, id);
|
||||||
|
return h.isStale(row ? row.stalenessTs : null, Date.now());
|
||||||
|
}
|
||||||
|
async subscribe(symbol: string, tickerKind: TickerKind): Promise<void> {
|
||||||
|
const d = this._db;
|
||||||
|
d.prepare('INSERT OR IGNORE INTO symbol_demand (symbol,refcount,ticker_kind,in_demand,last_refreshed_at) VALUES (?,?,?,?,?)').run(symbol, 0, tickerKind, 1, null);
|
||||||
|
const prev = d.prepare('SELECT refcount FROM symbol_demand WHERE symbol=?').get(symbol) as { refcount: number } | undefined;
|
||||||
|
const before = prev?.refcount ?? 0;
|
||||||
|
d.prepare('UPDATE symbol_demand SET refcount = refcount + 1, in_demand = 1 WHERE symbol=?').run(symbol);
|
||||||
|
if (before === 0) {
|
||||||
|
// First demand: schedule initial cache population (slice 1: yfinance quote + symbol meta)
|
||||||
|
for (const k of [`yfinance:quote:${symbol}`, `yfinance:symbol:${symbol}`]) {
|
||||||
|
try { await this._scheduler.queue(k); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async unsubscribe(symbol: string): Promise<void> {
|
||||||
|
const d = this._db;
|
||||||
|
d.prepare('UPDATE symbol_demand SET refcount = MAX(refcount - 1, 0) WHERE symbol=?').run(symbol);
|
||||||
|
d.prepare('UPDATE symbol_demand SET in_demand = 0 WHERE symbol=? AND refcount = 0').run(symbol);
|
||||||
|
}
|
||||||
|
async demandSet(): Promise<string[]> {
|
||||||
|
return (this._db.prepare('SELECT symbol FROM symbol_demand WHERE refcount > 0 ORDER BY symbol').all() as Array<{ symbol: string }>).map((r) => r.symbol);
|
||||||
|
}
|
||||||
|
async getMany<T>(keys: CacheKey[]): Promise<Array<{ key: CacheKey; value: T | null; isStale: boolean }>> {
|
||||||
|
return Promise.all(keys.map(async (key) => {
|
||||||
|
const e = await this.get<T>(key);
|
||||||
|
return { key, value: e.value, isStale: e.isStale };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCacheRepository(opts: { db: DatabaseSync; scheduler: CacheScheduler }): CacheRepository {
|
||||||
|
return new CacheRepositoryImpl(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prod singleton — wired in slice 1f once AdapterQueue (the scheduler) exists.
|
||||||
|
let _cache: CacheRepository | null = null;
|
||||||
|
export function cacheRepository(scheduler: CacheScheduler): CacheRepository {
|
||||||
|
if (!_cache) _cache = createCacheRepository({ db: defaultDb(), scheduler });
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { createDb, initSchema } from '../../db/client.ts';
|
||||||
|
import {
|
||||||
|
createCacheRepository,
|
||||||
|
type CacheScheduler, type CacheKey, type Quote, type PriceCandle, type SymbolMeta,
|
||||||
|
} from '../CacheRepository.ts';
|
||||||
|
|
||||||
|
class FakeScheduler { queued: CacheKey[] = []; async queue(key: CacheKey): Promise<void> { this.queued.push(key); } reset() { this.queued = []; } }
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const db = createDb({ path: ':memory:' });
|
||||||
|
initSchema(db);
|
||||||
|
const scheduler = new FakeScheduler();
|
||||||
|
const repo = createCacheRepository({ db, scheduler });
|
||||||
|
return { db, scheduler, repo };
|
||||||
|
}
|
||||||
|
const iso = (offsetMs = 0) => new Date(Date.now() + offsetMs).toISOString();
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
|
||||||
|
test('get returns fresh cached value and does NOT schedule a refresh', async () => {
|
||||||
|
const { repo, scheduler } = setup();
|
||||||
|
const q: Quote = { symbol: 'NVDA', price: 131.1, change: 1.2, changePercent: 0.92 };
|
||||||
|
await repo.set('yfinance:quote:NVDA', q, 'live_quote', { fetchedAt: iso(), sourceKind: 'yfinance' });
|
||||||
|
scheduler.reset();
|
||||||
|
const e = await repo.get<Quote>('yfinance:quote:NVDA');
|
||||||
|
assert.equal(e.isStale, false);
|
||||||
|
assert.equal(e.value?.price, 131.1);
|
||||||
|
assert.equal(scheduler.queued.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get returns stale value (stale-while-revalidate) AND schedules refresh', async () => {
|
||||||
|
const { repo, scheduler } = setup();
|
||||||
|
await repo.set('yfinance:quote:NVDA', { symbol: 'NVDA', price: 131.1 } as Quote, 'live_quote', { fetchedAt: iso(-120_000), sourceKind: 'yfinance' });
|
||||||
|
scheduler.reset();
|
||||||
|
const e = await repo.get<Quote>('yfinance:quote:NVDA');
|
||||||
|
assert.equal(e.isStale, true);
|
||||||
|
assert.equal(e.value?.price, 131.1); // stale value still served
|
||||||
|
assert.ok(scheduler.queued.includes('yfinance:quote:NVDA'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get on never-cached key returns null + schedules refresh', async () => {
|
||||||
|
const { repo, scheduler } = setup();
|
||||||
|
const e = await repo.get<Quote>('yfinance:quote:NVDA');
|
||||||
|
assert.equal(e.value, null);
|
||||||
|
assert.equal(e.isStale, true);
|
||||||
|
assert.ok(scheduler.queued.includes('yfinance:quote:NVDA'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscribe bumps refcount and schedules fetches on FIRST demand only', async () => {
|
||||||
|
const { repo, scheduler, db } = setup();
|
||||||
|
await repo.subscribe('NVDA', 'equity');
|
||||||
|
let r = db.prepare('SELECT refcount,in_demand FROM symbol_demand WHERE symbol=?').get('NVDA') as { refcount: number; in_demand: number };
|
||||||
|
assert.equal(r.refcount, 1);
|
||||||
|
assert.equal(r.in_demand, 1);
|
||||||
|
assert.ok(scheduler.queued.includes('yfinance:quote:NVDA'));
|
||||||
|
assert.ok(scheduler.queued.includes('yfinance:symbol:NVDA'));
|
||||||
|
scheduler.reset();
|
||||||
|
await repo.subscribe('NVDA', 'equity'); // second subscribe: no new initial fetches
|
||||||
|
r = db.prepare('SELECT refcount FROM symbol_demand WHERE symbol=?').get('NVDA') as { refcount: number };
|
||||||
|
assert.equal(r.refcount, 2);
|
||||||
|
assert.equal(scheduler.queued.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsubscribe decrements; refcount->0 sets in_demand=0 (halts refresh)', async () => {
|
||||||
|
const { repo, db } = setup();
|
||||||
|
await repo.subscribe('NVDA', 'equity');
|
||||||
|
await repo.subscribe('NVDA', 'equity'); // refcount=2
|
||||||
|
await repo.unsubscribe('NVDA'); // ->1
|
||||||
|
let r = db.prepare('SELECT refcount,in_demand FROM symbol_demand WHERE symbol=?').get('NVDA') as { refcount: number; in_demand: number };
|
||||||
|
assert.equal(r.refcount, 1);
|
||||||
|
assert.equal(r.in_demand, 1);
|
||||||
|
await repo.unsubscribe('NVDA'); // ->0
|
||||||
|
r = db.prepare('SELECT refcount,in_demand FROM symbol_demand WHERE symbol=?').get('NVDA') as { refcount: number; in_demand: number };
|
||||||
|
assert.equal(r.refcount, 0);
|
||||||
|
assert.equal(r.in_demand, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('demandSet lists only symbols with refcount>0', async () => {
|
||||||
|
const { repo } = setup();
|
||||||
|
await repo.subscribe('NVDA', 'equity');
|
||||||
|
await repo.subscribe('AAPL', 'equity');
|
||||||
|
await repo.unsubscribe('AAPL'); // ->0
|
||||||
|
const set = await repo.demandSet();
|
||||||
|
assert.deepEqual(set, ['NVDA']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('candles are permanent: not stale once present, stale when absent', async () => {
|
||||||
|
const { repo, scheduler } = setup();
|
||||||
|
let e = await repo.get<PriceCandle[]>('yfinance:candles:NVDA:1d');
|
||||||
|
assert.equal(e.isStale, true);
|
||||||
|
assert.equal(e.value, null);
|
||||||
|
assert.ok(scheduler.queued.includes('yfinance:candles:NVDA:1d'));
|
||||||
|
scheduler.reset();
|
||||||
|
const candles: PriceCandle[] = [{ ts: '2026-01-02', o: 130, h: 132, l: 129, c: 131, v: 1e6, adjClose: 131 }];
|
||||||
|
await repo.set('yfinance:candles:NVDA:1d', candles, 'daily_permanent', { fetchedAt: iso(-90 * DAY), sourceKind: 'yfinance' });
|
||||||
|
e = await repo.get<PriceCandle[]>('yfinance:candles:NVDA:1d');
|
||||||
|
assert.equal(e.isStale, false);
|
||||||
|
assert.equal(e.value?.length, 1);
|
||||||
|
assert.equal(scheduler.queued.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('symbol_meta stale after 7d, fresh within', async () => {
|
||||||
|
const { repo, scheduler } = setup();
|
||||||
|
const s: SymbolMeta = { symbol: 'NVDA', name: 'NVIDIA', sector: 'Technology', tickerKind: 'equity' };
|
||||||
|
await repo.set('yfinance:symbol:NVDA', s, 'symbol_meta', { fetchedAt: iso(-8 * DAY), sourceKind: 'yfinance' });
|
||||||
|
scheduler.reset();
|
||||||
|
let e = await repo.get<SymbolMeta>('yfinance:symbol:NVDA');
|
||||||
|
assert.equal(e.isStale, true);
|
||||||
|
assert.ok(scheduler.queued.includes('yfinance:symbol:NVDA'));
|
||||||
|
await repo.set('yfinance:symbol:NVDA', s, 'symbol_meta', { fetchedAt: iso(-DAY), sourceKind: 'yfinance' });
|
||||||
|
scheduler.reset();
|
||||||
|
e = await repo.get<SymbolMeta>('yfinance:symbol:NVDA');
|
||||||
|
assert.equal(e.isStale, false);
|
||||||
|
assert.equal(scheduler.queued.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale() checks staleness WITHOUT scheduling a refresh', async () => {
|
||||||
|
const { repo, scheduler } = setup();
|
||||||
|
await repo.set('yfinance:quote:NVDA', { symbol: 'NVDA', price: 131 } as Quote, 'live_quote', { fetchedAt: iso(-120_000), sourceKind: 'yfinance' });
|
||||||
|
scheduler.reset();
|
||||||
|
assert.equal(repo.stale('yfinance:quote:NVDA'), true);
|
||||||
|
assert.equal(scheduler.queued.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getMany coalesces multiple reads (mixed hit/miss)', async () => {
|
||||||
|
const { repo } = setup();
|
||||||
|
await repo.set('yfinance:quote:NVDA', { symbol: 'NVDA', price: 131 } as Quote, 'live_quote', { fetchedAt: iso(), sourceKind: 'yfinance' });
|
||||||
|
const res = await repo.getMany<Quote>(['yfinance:quote:NVDA', 'yfinance:quote:AAPL']);
|
||||||
|
assert.equal(res.length, 2);
|
||||||
|
assert.equal(res[0].value?.price, 131);
|
||||||
|
assert.equal(res[1].value, null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { createDb, initSchema } from '../client.ts';
|
||||||
|
|
||||||
|
function freshDb() {
|
||||||
|
const d = createDb({ path: ':memory:' });
|
||||||
|
initSchema(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every table the locked schema (DESIGN.md §1 + §1.5 + §2.6 + deltas) must define.
|
||||||
|
const EXPECTED_TABLES = [
|
||||||
|
'users', 'sessions', 'sizing_unlocks',
|
||||||
|
'symbols', 'price_candles', 'quotes', 'options_chains', 'filings',
|
||||||
|
'institution_filings', 'insider_transactions', 'sector_map',
|
||||||
|
'macro_events', 'macro_regimes', 'price_adjustments',
|
||||||
|
'threads', 'adapter_queue', 'symbol_demand',
|
||||||
|
'watchlists', 'portfolio_holdings', 'trades', 'strategies',
|
||||||
|
'screener_filters', 'reports', 'alerts', 'trusted_accounts',
|
||||||
|
'saved_posts', 'trade_key_levels',
|
||||||
|
'llm_summaries', 'rotation_signals', 'llm_providers', 'llm_dispatch_audit',
|
||||||
|
];
|
||||||
|
|
||||||
|
test('schema creates all locked tables', () => {
|
||||||
|
const d = freshDb();
|
||||||
|
const names = (d.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as Array<{ name: string }>).map((r) => r.name);
|
||||||
|
for (const t of EXPECTED_TABLES) {
|
||||||
|
assert.ok(names.includes(t), `missing table: ${t}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('users -> sessions FK cascades on delete', () => {
|
||||||
|
const d = freshDb();
|
||||||
|
d.prepare('INSERT INTO users (id,email,pw_hash,created_at) VALUES (?,?,?,?)').run('u1', 'a@b.co', 'hash', '2026-01-01T00:00:00Z');
|
||||||
|
d.prepare('INSERT INTO sessions (id,user_id,expires_at,created_at) VALUES (?,?,?,?)').run('s1', 'u1', '2026-12-31T00:00:00Z', '2026-01-01T00:00:00Z');
|
||||||
|
assert.equal((d.prepare('SELECT COUNT(*) AS c FROM sessions WHERE user_id=?').get('u1') as { c: number }).c, 1);
|
||||||
|
d.prepare('DELETE FROM users WHERE id=?').run('u1');
|
||||||
|
assert.equal((d.prepare('SELECT COUNT(*) AS c FROM sessions').get() as { c: number }).c, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('symbol_demand defaults in_demand=1, refcount=0 (demand-set gate)', () => {
|
||||||
|
const d = freshDb();
|
||||||
|
d.prepare('INSERT INTO symbol_demand (symbol,refcount,ticker_kind) VALUES (?,?,?)').run('NVDA', 0, 'equity');
|
||||||
|
const r = d.prepare('SELECT in_demand, refcount FROM symbol_demand WHERE symbol=?').get('NVDA') as { in_demand: number; refcount: number };
|
||||||
|
assert.equal(r.in_demand, 1);
|
||||||
|
assert.equal(r.refcount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('price_candles PK dedupes (symbol,timeframe,ts) — backtest integrity', () => {
|
||||||
|
const d = freshDb();
|
||||||
|
const ins = d.prepare('INSERT INTO price_candles (symbol,timeframe,ts,o,h,l,c,v,adj_close,observed_at) VALUES (?,?,?,?,?,?,?,?,?,?)');
|
||||||
|
ins.run('NVDA', '1d', '2026-01-02', 130, 132, 129, 131, 1e6, 131, '2026-01-02T22:00:00Z');
|
||||||
|
assert.throws(() => ins.run('NVDA', '1d', '2026-01-02', 130, 132, 129, 131, 1e6, 131, '2026-01-02T22:00:00Z'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('schema.sql is idempotent (re-init does not error)', () => {
|
||||||
|
const d = freshDb();
|
||||||
|
// re-applying must not throw (CREATE ... IF NOT EXISTS)
|
||||||
|
assert.doesNotThrow(() => initSchema(d));
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Investor Flow — SQLite DB client (node:sqlite). DESIGN.md §1 schema seam.
|
||||||
|
//
|
||||||
|
// Runtime adaptation (reversible): design specified Bun + bun:sqlite. Implemented on
|
||||||
|
// Node 26 native TS (type-stripping) + node:sqlite (built-in) so the backend has ZERO
|
||||||
|
// native dependencies. Deep-module architecture (CacheRepository, etc.) is unchanged.
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { readFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SCHEMA_PATH = join(__dirname, 'schema.sql');
|
||||||
|
const DEFAULT_DB_DIR = join(__dirname, '..', '..', 'data');
|
||||||
|
const DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, 'investor-flow.db');
|
||||||
|
|
||||||
|
export interface DbOptions {
|
||||||
|
/** SQLite file path. Defaults to $IFLOW_DB_PATH or <server>/data/investor-flow.db. Use ':memory:' for tests. */
|
||||||
|
path?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a SQLite connection with foreign keys + WAL enabled. */
|
||||||
|
export function createDb(opts: DbOptions = {}): DatabaseSync {
|
||||||
|
const path = opts.path ?? process.env.IFLOW_DB_PATH ?? DEFAULT_DB_PATH;
|
||||||
|
if (!opts.readonly && path !== ':memory:') {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
}
|
||||||
|
const database = new DatabaseSync(path, {
|
||||||
|
readOnly: opts.readonly ?? false,
|
||||||
|
enableForeignKeyConstraints: true,
|
||||||
|
});
|
||||||
|
// WAL is persistent on the file; setting per-connection is harmless and ensures it.
|
||||||
|
try {
|
||||||
|
database.exec('PRAGMA journal_mode = WAL;');
|
||||||
|
} catch {
|
||||||
|
/* readOnly or disallowed — ignore */
|
||||||
|
}
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply the full schema (CREATE TABLE IF NOT EXISTS). Idempotent. */
|
||||||
|
export function initSchema(database: DatabaseSync): void {
|
||||||
|
const sql = readFileSync(SCHEMA_PATH, 'utf8');
|
||||||
|
database.exec(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _db: DatabaseSync | null = null;
|
||||||
|
/** Process-wide singleton connection (auto-creates + inits schema on first call). */
|
||||||
|
export function db(): DatabaseSync {
|
||||||
|
if (!_db) {
|
||||||
|
_db = createDb();
|
||||||
|
initSchema(_db);
|
||||||
|
}
|
||||||
|
return _db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CLI entry: `node src/db/client.ts` initializes the on-disk database. */
|
||||||
|
const isMain = process.argv[1] ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false;
|
||||||
|
if (isMain) {
|
||||||
|
const d = createDb();
|
||||||
|
initSchema(d);
|
||||||
|
const path = process.env.IFLOW_DB_PATH ?? DEFAULT_DB_PATH;
|
||||||
|
const rows = d.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as Array<{ name: string }>;
|
||||||
|
console.log(`[investor-flow] schema initialized at ${path} (${rows.length} tables)`);
|
||||||
|
d.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
-- Investor Flow — SQLite schema. Engineering contract: DESIGN.md §1 (Tier A/B/C/D),
|
||||||
|
-- §1.5 (LLM provenance), §2.6 (ticker-data dedupe), + locked review deltas (price_adjustments, trade_key_levels).
|
||||||
|
-- Tiers: A shared market cache (ownerId NULL) | B shared content | C per-user (ownerId NOT NULL) | D system.
|
||||||
|
-- Conventions: timestamps ISO8601 TEXT; observed_at/fetched_at provenance per P4.
|
||||||
|
-- Idempotent: CREATE ... IF NOT EXISTS. Safe to re-run on every boot.
|
||||||
|
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
-- ===== Tier D — System / Identity =====
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY, -- uuid
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
pw_hash TEXT NOT NULL, -- scrypt(verifiable); design specified argon2id, adapted to built-in scrypt (reversible)
|
||||||
|
totp_secret TEXT, -- 2FA secret, encrypted at rest (slice 2)
|
||||||
|
is_2fa_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
oauth_subject TEXT, -- nullable; social OAuth link (slice 2)
|
||||||
|
oauth_provider TEXT, -- 'github' | 'google' | NULL
|
||||||
|
complexity TEXT NOT NULL DEFAULT 'beginner', -- beginner|intermediate|advanced
|
||||||
|
risk_tolerance TEXT NOT NULL DEFAULT 'moderate',
|
||||||
|
convexity_posture TEXT NOT NULL DEFAULT 'off', -- off|covered_income|cash_secured_entry|insurance_sleeve|leaps_conviction
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sizing_unlocks (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
unlock TEXT NOT NULL, -- 'tier_a_star' | 'tier_a' | ('covered_income'|...)
|
||||||
|
earned_at TEXT NOT NULL,
|
||||||
|
evidence TEXT, -- stats that justified unlock
|
||||||
|
PRIMARY KEY (user_id, unlock)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ===== Tier A — Shared market cache (no ownerId) =====
|
||||||
|
CREATE TABLE IF NOT EXISTS symbols (
|
||||||
|
symbol TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
sector TEXT,
|
||||||
|
industry TEXT,
|
||||||
|
exchange TEXT,
|
||||||
|
ticker_kind TEXT NOT NULL, -- equity|crypto|etf|index (gates module applicability)
|
||||||
|
peers TEXT, -- JSON array
|
||||||
|
updated_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS price_candles (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
timeframe TEXT NOT NULL, -- '1m'|'5m'|'1h'|'1d'|'1w'
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
o REAL, h REAL, l REAL, c REAL, v REAL,
|
||||||
|
adj_close REAL, -- continuous split+dividend-adjusted close for backtests
|
||||||
|
observed_at TEXT NOT NULL, -- when fetched (provenance)
|
||||||
|
PRIMARY KEY (symbol, timeframe, ts)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS quotes (
|
||||||
|
symbol TEXT PRIMARY KEY,
|
||||||
|
price REAL,
|
||||||
|
bid REAL,
|
||||||
|
ask REAL,
|
||||||
|
change REAL,
|
||||||
|
change_pct REAL,
|
||||||
|
iv REAL, -- implied vol when available
|
||||||
|
observed_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS options_chains (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
expiry TEXT NOT NULL,
|
||||||
|
strike REAL NOT NULL,
|
||||||
|
type TEXT NOT NULL, -- call|put
|
||||||
|
bid REAL, ask REAL, iv REAL,
|
||||||
|
delta REAL, gamma REAL, theta REAL, vega REAL, rho REAL,
|
||||||
|
open_interest INTEGER, volume INTEGER,
|
||||||
|
ts TEXT NOT NULL, -- snapshot timestamp
|
||||||
|
PRIMARY KEY (symbol, expiry, strike, type, ts)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS filings (
|
||||||
|
accession TEXT PRIMARY KEY, -- SEC accession number
|
||||||
|
form TEXT NOT NULL, -- 10-K|10-Q|8-K|13F-HR|13D|13G|4|...
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
filed_at TEXT NOT NULL,
|
||||||
|
period TEXT,
|
||||||
|
primary_doc_url TEXT,
|
||||||
|
items TEXT, -- JSON: list of items (8-K materiality heuristics)
|
||||||
|
fetched_at TEXT NOT NULL,
|
||||||
|
raw_payload TEXT -- cached full XBRL/parsed doc
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS institution_filings (
|
||||||
|
filer_cik TEXT NOT NULL, -- institution's CIK
|
||||||
|
filer_name TEXT,
|
||||||
|
filer_sic TEXT, -- SIC code -> classifies MM/broker-dealer (ADR Q2)
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
form TEXT NOT NULL, -- 13F-HR|13D|13G
|
||||||
|
shares REAL, -- shares reported
|
||||||
|
value_usd REAL,
|
||||||
|
reported_quarter TEXT NOT NULL, -- calendar quarter positions are "as of"
|
||||||
|
filed_at TEXT NOT NULL,
|
||||||
|
fetched_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS insider_transactions (
|
||||||
|
form4_id TEXT NOT NULL, -- accession + offset
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
insider_name TEXT NOT NULL,
|
||||||
|
insider_role TEXT, -- officer|director|ten_pct_holder
|
||||||
|
tx_date TEXT NOT NULL,
|
||||||
|
tx_code TEXT NOT NULL, -- P|S|M|A|... (SEC codes)
|
||||||
|
tx_type TEXT NOT NULL, -- buy|sell|exercise|grant|vesting
|
||||||
|
shares REAL,
|
||||||
|
price REAL,
|
||||||
|
is_10b5_1 INTEGER NOT NULL DEFAULT 0, -- 1 = under 10b5-1 plan (Routine)
|
||||||
|
classification TEXT NOT NULL, -- informed_buy|informed_sell|routine
|
||||||
|
filed_at TEXT NOT NULL,
|
||||||
|
fetched_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (form4_id, symbol, insider_name, tx_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sector_map (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
sector TEXT NOT NULL,
|
||||||
|
as_of TEXT NOT NULL,
|
||||||
|
rs_ratio REAL, -- relative strength vs SPX
|
||||||
|
rs_slope REAL, -- slope over N weeks (incipient detection)
|
||||||
|
breadth_thrust REAL, -- % members beating SPX
|
||||||
|
PRIMARY KEY (symbol, sector, as_of)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS macro_events (
|
||||||
|
event_id TEXT PRIMARY KEY, -- e.g. '2026-06-30-FOMC'
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
event_ts TEXT NOT NULL,
|
||||||
|
impact TEXT, -- high|medium|low
|
||||||
|
forecast TEXT,
|
||||||
|
previous TEXT,
|
||||||
|
actual TEXT, -- populated after release
|
||||||
|
source TEXT -- 'FRED'|'econoday'|...
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS macro_regimes (
|
||||||
|
as_of TEXT PRIMARY KEY,
|
||||||
|
regime TEXT NOT NULL, -- trending_up|trending_down|range_bound|structurally_shifting
|
||||||
|
signals TEXT -- JSON: yield curve, inflation, rate path, liquidity, breadth
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS price_adjustments (
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
ex_date TEXT NOT NULL, -- date the price line shifted
|
||||||
|
type TEXT NOT NULL, -- 'split'|'dividend'
|
||||||
|
ratio REAL NOT NULL, -- split ratio (3.0 = 3-for-1) or dividend amount ($/share)
|
||||||
|
PRIMARY KEY (symbol, ex_date, type)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ===== Tier B — Shared content =====
|
||||||
|
CREATE TABLE IF NOT EXISTS threads (
|
||||||
|
source TEXT NOT NULL, -- x|reddit
|
||||||
|
source_id TEXT NOT NULL, -- tweet id / reddit post id
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
sentiment TEXT, -- bullish|bearish|neutral (computed)
|
||||||
|
llm_summary_hash TEXT, -- cross-ref to llm_summaries
|
||||||
|
fetched_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (source, source_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adapter_queue (
|
||||||
|
key TEXT PRIMARY KEY, -- e.g. "yfinance:quote:NVDA"
|
||||||
|
status TEXT NOT NULL, -- pending|in_flight|done|backoff
|
||||||
|
last_attempt TEXT,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
backoff_until TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS symbol_demand (
|
||||||
|
symbol TEXT PRIMARY KEY,
|
||||||
|
refcount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ticker_kind TEXT NOT NULL,
|
||||||
|
in_demand INTEGER NOT NULL DEFAULT 1, -- gate flag for the adapter queue
|
||||||
|
last_refreshed_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ===== Tier C — Per-user (ownerId NOT NULL) =====
|
||||||
|
CREATE TABLE IF NOT EXISTS watchlists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
symbols TEXT NOT NULL, -- JSON array
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS portfolio_holdings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
qty REAL NOT NULL,
|
||||||
|
avg_cost REAL NOT NULL,
|
||||||
|
acquired_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open' -- open|closed
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trades (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
trade_id TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL, -- planned|active|closed
|
||||||
|
environment TEXT, -- trending|ranging|transitioning
|
||||||
|
tier TEXT NOT NULL, -- A_STAR|A|B|C (Conviction Tier)
|
||||||
|
strategy_id TEXT, -- ref to strategies (nullable)
|
||||||
|
confluence_score INTEGER,
|
||||||
|
risk_pct REAL,
|
||||||
|
position_size REAL, -- computed by SizingEngine
|
||||||
|
entry_price REAL,
|
||||||
|
stop_loss REAL,
|
||||||
|
targets TEXT, -- JSON [{price, reason}]
|
||||||
|
justifications TEXT, -- JSON {entryWhy, stopWhy, targetWhy}
|
||||||
|
checklist_completed INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS strategies (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
regime_gate TEXT NOT NULL, -- JSON: gate config
|
||||||
|
setup TEXT NOT NULL, -- JSON: entry/exit checklist w/ params
|
||||||
|
risk_policy TEXT NOT NULL, -- JSON: sizing rules
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
is_public INTEGER NOT NULL DEFAULT 0 -- shareable read-only (later phase)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS screener_filters (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
expressions TEXT NOT NULL, -- JSON: filter expression set
|
||||||
|
universe_kind TEXT NOT NULL, -- watchlist|portfolio|broader_sector
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reports (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
scope TEXT NOT NULL, -- symbol|watchlist|portfolio|rotation|sizing_year|risk_posture
|
||||||
|
range_start TEXT,
|
||||||
|
range_end TEXT,
|
||||||
|
format TEXT NOT NULL, -- html|markdown|csv|json
|
||||||
|
payload_ref TEXT NOT NULL, -- file path in local reports dir
|
||||||
|
generated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
symbol TEXT,
|
||||||
|
alert_type TEXT NOT NULL, -- informed_buy|informed_sell|new_13da|rotation_incipient|regime_shift|conviction_unlock|...
|
||||||
|
params TEXT NOT NULL, -- JSON thresholds
|
||||||
|
last_fired TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trusted_accounts (
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
source TEXT NOT NULL, -- x|reddit
|
||||||
|
handle TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_id, source, handle)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_posts (
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
source_id TEXT NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
linked_trade_id TEXT REFERENCES trades(id) ON DELETE SET NULL,
|
||||||
|
saved_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_id, source, source_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trade_key_levels (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL, -- swing_high|swing_low|supply|demand|support|resistance
|
||||||
|
price REAL NOT NULL,
|
||||||
|
timeframe TEXT, -- '1H'|'4H'|'1D'|...
|
||||||
|
strength TEXT, -- low|medium|high
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ===== Shared LLM cache (output deterministic by prompt-hash; not per-user) =====
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_summaries (
|
||||||
|
hash TEXT PRIMARY KEY, -- hash(prompt + source-payload + complexity)
|
||||||
|
feature TEXT NOT NULL, -- filing_summary|post_summary|dashboard_rollup|explainer|qa|sizing_explain|macro_commentary|...
|
||||||
|
complexity TEXT NOT NULL,
|
||||||
|
output TEXT NOT NULL,
|
||||||
|
cited_sources TEXT, -- JSON refs to cached rows (provenance)
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rotation_signals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
sector TEXT NOT NULL,
|
||||||
|
detected_at TEXT NOT NULL,
|
||||||
|
phase TEXT NOT NULL, -- accumulation|expansion|distribution|markdown
|
||||||
|
confidence REAL,
|
||||||
|
price_resolved INTEGER NOT NULL DEFAULT 0, -- follows through within N=4 weeks?
|
||||||
|
inst_resolved INTEGER NOT NULL DEFAULT 0, -- 13F/insider flow later confirms?
|
||||||
|
resolved_at TEXT,
|
||||||
|
verdict TEXT -- real|false_alarm (gamma two-stage)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ===== Section 1.5 — LLM provenance (ADR-0006). Operator-managed, NOT user-editable. =====
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_providers (
|
||||||
|
id TEXT PRIMARY KEY, -- 'local_ollama' | 'local_vllm' | 'zen_glm_5_2' | ...
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
is_local INTEGER NOT NULL DEFAULT 0, -- 1 if localhost/127.0.0.1/::1/LAN subnet
|
||||||
|
review_adr_ref TEXT, -- ADR-XXXX or note documenting provenance review date
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0 -- only one with enabled=1 by default (local)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_dispatch_audit (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
feature TEXT NOT NULL,
|
||||||
|
payload_class TEXT NOT NULL, -- public_safe|sensitive
|
||||||
|
provider_id TEXT,
|
||||||
|
status TEXT NOT NULL, -- sent_local|sent_external|blocked_sensitive_external
|
||||||
|
ts TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ===== Indexes =====
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pc_symbol_tf_ts ON price_candles(symbol, timeframe, ts);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quote_obs ON quotes(observed_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_filing_symbol_form ON filings(symbol, form);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_instfil_sym_q ON institution_filings(symbol, reported_quarter);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_insider_sym_date ON insider_transactions(symbol, tx_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_threads_symbol_ts ON threads(symbol, ts);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trades_owner ON trades(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_alerts_owner_sym ON alerts(owner_id, symbol);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signals_sector ON rotation_signals(sector);
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// Investor Flow — AdapterQueue (DESIGN.md §5). Shared queue for all sources.
|
||||||
|
// Implements CacheScheduler (so CacheRepository.get schedules refreshes through it).
|
||||||
|
// Dedupe: adapter_queue PRIMARY KEY(key) + status check collapses concurrent same-key jobs
|
||||||
|
// to one fetch (ADR-0004). Token-bucket per source. Exponential backoff on failure.
|
||||||
|
import type { DatabaseSync } from 'node:sqlite';
|
||||||
|
import type { CacheKey, SourceKind, CacheRepository, CacheScheduler } from '../cache/CacheRepository.ts';
|
||||||
|
import { parseCacheKey } from '../cache/CacheRepository.ts';
|
||||||
|
import type { SourceFetch, AdapterHealth } from '../adapters/SourceAdapter.ts';
|
||||||
|
|
||||||
|
export interface AdapterQueueOptions {
|
||||||
|
db: DatabaseSync;
|
||||||
|
adapters: Map<SourceKind, SourceFetch>;
|
||||||
|
/** Override per-source min-interval (ms); tests pass 0 to skip throttling. */
|
||||||
|
rateLimitMs?: Partial<Record<SourceKind, number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_RATE_MS: Record<SourceKind, number> = { yfinance: 1000, sec: 125, reddit: 1000, x: 3000, macro: 1000, llm: 0 };
|
||||||
|
const BACKOFF_MS = [2000, 4000, 8000, 16000, 60000]; // yfinance policy §5 (2s..60s ceiling)
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
export class AdapterQueue implements CacheScheduler {
|
||||||
|
private readonly _db: DatabaseSync;
|
||||||
|
private readonly _adapters: Map<SourceKind, SourceFetch>;
|
||||||
|
private readonly _rate: Record<SourceKind, number>;
|
||||||
|
private _cache: CacheRepository | null = null;
|
||||||
|
private _lastFetchAt: Partial<Record<SourceKind, number>> = {};
|
||||||
|
private _lastError: string | null = null;
|
||||||
|
|
||||||
|
constructor(opts: AdapterQueueOptions) {
|
||||||
|
this._db = opts.db;
|
||||||
|
this._adapters = opts.adapters;
|
||||||
|
this._rate = { ...DEFAULT_RATE_MS, ...(opts.rateLimitMs ?? {}) } as Record<SourceKind, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link the cache (results are written here after a successful fetch). Breaks the cache<->queue cycle. */
|
||||||
|
set cache(c: CacheRepository) { this._cache = c; }
|
||||||
|
|
||||||
|
/** Enqueue a fetch. Dedupes against pending/in_flight; respects active backoff. force bypasses dedupe. */
|
||||||
|
async queue(key: CacheKey): Promise<void> {
|
||||||
|
const row = this._db.prepare('SELECT status, backoff_until FROM adapter_queue WHERE key=?').get(key) as { status?: string; backoff_until?: string | null } | undefined;
|
||||||
|
if (row) {
|
||||||
|
if (row.status === 'pending' || row.status === 'in_flight') return; // dedupe: already queued
|
||||||
|
if (row.status === 'backoff' && row.backoff_until && Date.parse(row.backoff_until) > Date.now()) return; // still backing off
|
||||||
|
}
|
||||||
|
this._db.prepare('INSERT OR REPLACE INTO adapter_queue (key,status,last_attempt,retry_count,backoff_until) VALUES (?,?,?,?,?)').run(key, 'pending', null, 0, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process pending/backoff-expired jobs: fetch via the source adapter, write to cache, set done/backoff/failed. */
|
||||||
|
async drain(): Promise<void> {
|
||||||
|
if (!this._cache) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const jobs = this._db.prepare(
|
||||||
|
"SELECT key, retry_count, backoff_until FROM adapter_queue WHERE status IN ('pending','backoff') ORDER BY (last_attempt IS NULL) DESC, last_attempt ASC",
|
||||||
|
).all() as Array<{ key: string; retry_count: number; backoff_until?: string | null }>;
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
if (job.backoff_until && Date.parse(job.backoff_until) > now) continue; // backoff not yet expired
|
||||||
|
const { source } = parseCacheKey(job.key);
|
||||||
|
const adapter = this._adapters.get(source);
|
||||||
|
if (!adapter) { this._setStatus(job.key, 'done'); continue; } // no adapter registered -> don't leave stuck
|
||||||
|
// token bucket: enforce per-source min interval
|
||||||
|
const last = this._lastFetchAt[source] ?? 0;
|
||||||
|
const wait = (this._rate[source] ?? 0) - (Date.now() - last);
|
||||||
|
if (wait > 0) await sleep(wait);
|
||||||
|
this._lastFetchAt[source] = Date.now();
|
||||||
|
this._setStatus(job.key, 'in_flight');
|
||||||
|
try {
|
||||||
|
const res = await adapter.fetchOne(job.key);
|
||||||
|
await this._cache.set(job.key, res.value, res.ttlClass, res.provenance);
|
||||||
|
this._db.prepare("UPDATE adapter_queue SET status='done', last_attempt=? WHERE key=?").run(new Date().toISOString(), job.key);
|
||||||
|
} catch (e) {
|
||||||
|
const retry = job.retry_count + 1;
|
||||||
|
this._lastError = e instanceof Error ? e.message : String(e);
|
||||||
|
if (retry >= MAX_ATTEMPTS) {
|
||||||
|
this._db.prepare("UPDATE adapter_queue SET status='failed', last_attempt=?, retry_count=?, backoff_until=NULL WHERE key=?").run(new Date().toISOString(), retry, job.key);
|
||||||
|
} else {
|
||||||
|
const bo = BACKOFF_MS[Math.min(retry - 1, BACKOFF_MS.length - 1)];
|
||||||
|
this._db.prepare("UPDATE adapter_queue SET status='backoff', last_attempt=?, retry_count=?, backoff_until=? WHERE key=?").run(new Date().toISOString(), retry, new Date(Date.now() + bo).toISOString(), job.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
health(): AdapterHealth {
|
||||||
|
const row = this._db.prepare("SELECT SUM(status='pending') AS q, SUM(status='in_flight') AS i, MAX(backoff_until) AS bu FROM adapter_queue").get() as { q: number | null; i: number | null; bu: string | null };
|
||||||
|
return { queued: row.q ?? 0, in_flight: row.i ?? 0, backoff_until: row.bu ?? null, last_error: this._lastError ?? undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setStatus(key: string, status: string): void {
|
||||||
|
this._db.prepare('UPDATE adapter_queue SET status=? WHERE key=?').run(status, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wire queue + cache together (breaks the cache<->scheduler cycle). Used by slice 1f. */
|
||||||
|
export function createAdapterQueue(opts: AdapterQueueOptions): AdapterQueue {
|
||||||
|
return new AdapterQueue(opts);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { createDb, initSchema } from '../../db/client.ts';
|
||||||
|
import { createCacheRepository, type Quote } from '../../cache/CacheRepository.ts';
|
||||||
|
import { FakeSourceAdapter } from '../../adapters/SourceAdapter.ts';
|
||||||
|
import { AdapterQueue } from '../AdapterQueue.ts';
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const db = createDb({ path: ':memory:' });
|
||||||
|
initSchema(db);
|
||||||
|
const fake = new FakeSourceAdapter('yfinance')
|
||||||
|
.set('yfinance:quote:NVDA', { symbol: 'NVDA', price: 194.97 } as Quote, 'live_quote')
|
||||||
|
.set('yfinance:quote:AAPL', { symbol: 'AAPL', price: 230.0 } as Quote, 'live_quote');
|
||||||
|
const adapters = new Map([['yfinance' as const, fake as unknown as import('../../adapters/SourceAdapter.ts').SourceFetch]]);
|
||||||
|
const queue = new AdapterQueue({ db, adapters, rateLimitMs: { yfinance: 0 } });
|
||||||
|
const cache = createCacheRepository({ db, scheduler: queue });
|
||||||
|
queue.cache = cache;
|
||||||
|
return { db, fake, queue, cache };
|
||||||
|
}
|
||||||
|
const count = (db: import('node:sqlite').DatabaseSync, where: string) => (db.prepare(`SELECT COUNT(*) AS c FROM adapter_queue WHERE ${where}`).get() as { c: number }).c;
|
||||||
|
const statusOf = (db: import('node:sqlite').DatabaseSync, key: string) => (db.prepare('SELECT status, retry_count, backoff_until FROM adapter_queue WHERE key=?').get(key) as { status: string; retry_count: number; backoff_until?: string | null });
|
||||||
|
|
||||||
|
test('dedupe: two concurrent queue() calls for the same key produce ONE job', async () => {
|
||||||
|
const { db, queue } = setup();
|
||||||
|
await Promise.all([queue.queue('yfinance:quote:NVDA'), queue.queue('yfinance:quote:NVDA')]);
|
||||||
|
assert.equal(count(db, "key='yfinance:quote:NVDA'"), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drain fetches once (dedupe) and populates the cache', async () => {
|
||||||
|
const { db, fake, queue, cache } = setup();
|
||||||
|
await queue.queue('yfinance:quote:NVDA');
|
||||||
|
await queue.queue('yfinance:quote:NVDA'); // dedupe
|
||||||
|
await queue.drain();
|
||||||
|
assert.equal(fake.calls.length, 1, 'only one fetch despite two queue calls');
|
||||||
|
assert.equal(statusOf(db, 'yfinance:quote:NVDA').status, 'done');
|
||||||
|
const e = await cache.get<Quote>('yfinance:quote:NVDA');
|
||||||
|
assert.equal(e.isStale, false);
|
||||||
|
assert.equal(e.value?.price, 194.97);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distinct keys each fetch', async () => {
|
||||||
|
const { db, fake, queue } = setup();
|
||||||
|
await queue.queue('yfinance:quote:NVDA');
|
||||||
|
await queue.queue('yfinance:quote:AAPL');
|
||||||
|
await queue.drain();
|
||||||
|
assert.equal(fake.calls.length, 2);
|
||||||
|
assert.equal(count(db, "status='done'"), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failure sets backoff (exponential) and does NOT retry while backoff active', async () => {
|
||||||
|
const { db, fake, queue } = setup();
|
||||||
|
// No canned response -> fetchOne throws
|
||||||
|
await queue.queue('yfinance:quote:UNKNOWN');
|
||||||
|
await queue.drain();
|
||||||
|
const s = statusOf(db, 'yfinance:quote:UNKNOWN');
|
||||||
|
assert.equal(s.status, 'backoff');
|
||||||
|
assert.equal(s.retry_count, 1);
|
||||||
|
assert.ok(s.backoff_until, 'backoff_until must be set');
|
||||||
|
// second drain while backoff active -> no new fetch
|
||||||
|
await queue.drain();
|
||||||
|
assert.equal(fake.calls.filter((k) => k === 'yfinance:quote:UNKNOWN').length, 1, 'no retry while backing off');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after backoff expires, retries; eventually FAILED at MAX_ATTEMPTS', async () => {
|
||||||
|
const { db, fake, queue } = setup();
|
||||||
|
await queue.queue('yfinance:quote:UNKNOWN');
|
||||||
|
// Repeatedly force backoff to expire, then drain, until FAILED.
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
db.prepare("UPDATE adapter_queue SET backoff_until=? WHERE key='yfinance:quote:UNKNOWN'").run(new Date(Date.now() - 1000).toISOString());
|
||||||
|
await queue.drain();
|
||||||
|
const s = statusOf(db, 'yfinance:quote:UNKNOWN');
|
||||||
|
if (s.status === 'failed') {
|
||||||
|
assert.equal(s.retry_count, 5, 'failed at MAX_ATTEMPTS=5');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.fail('did not reach failed within 10 drain cycles');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('health() reports queued/in_flight/backoff counts', async () => {
|
||||||
|
const { db, queue } = setup();
|
||||||
|
await queue.queue('yfinance:quote:NVDA');
|
||||||
|
await queue.queue('yfinance:quote:AAPL');
|
||||||
|
const h1 = queue.health();
|
||||||
|
assert.equal(h1.queued, 2);
|
||||||
|
assert.equal(h1.in_flight, 0);
|
||||||
|
await queue.drain();
|
||||||
|
const h2 = queue.health();
|
||||||
|
assert.equal(h2.queued, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user