- Add await ctx.cache.subscribe() to addSymbol mutation so symbols added via the sidebar get registered in symbol_demand and yfinance jobs are queued immediately - Backfill PEP, WYNN, STZ, CELH into symbol_demand + adapter_queue - Upgrade yahoo-finance2 3.15.3 -> 3.15.4 and pass validateResult:false to quoteSummary() to handle Yahoo schema drift - Add error detail logging for analyst ratings schema failures - Update .gitignore with common ignores
206 lines
6.0 KiB
TypeScript
206 lines
6.0 KiB
TypeScript
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
|
|
import { OptionsAdapter, parseOptionChain, parseOptionChainRows, type OptionChainRow } from '../OptionsAdapter.ts';
|
|
import { createCacheRepository, type CacheScheduler, type CacheKey } from '../../cache/CacheRepository.ts';
|
|
import { createDb, initSchema } from '../../db/client.ts';
|
|
|
|
// ----- Test helpers: in-memory DB with full schema -----
|
|
|
|
function createTestDb(): DatabaseSync {
|
|
const db = createDb({ path: ':memory:' });
|
|
initSchema(db);
|
|
return db;
|
|
}
|
|
|
|
class NoopScheduler implements CacheScheduler {
|
|
async queue(_key: CacheKey): Promise<void> { /* no-op */ }
|
|
}
|
|
|
|
/** A stubbed yfinance2 client that returns canned data — no network. */
|
|
interface StubYf {
|
|
optionsExpiryDates: (symbol: string) => Promise<string[]>;
|
|
options: (symbol: string, expiry?: string) => Promise<Record<string, unknown>>;
|
|
}
|
|
|
|
/** Build an OptionsAdapter with a stubbed yf instance. */
|
|
function createStubbedAdapter(stubYf: StubYf): OptionsAdapter {
|
|
const adapter = new OptionsAdapter();
|
|
(adapter as any)._yf = Promise.resolve(stubYf as any);
|
|
return adapter;
|
|
}
|
|
|
|
function makeRawChainWithGreeks(): Record<string, unknown> {
|
|
return {
|
|
calls: [
|
|
{
|
|
contractSymbol: 'NVDA250717C00100000',
|
|
strike: 100,
|
|
expiration: '2026-07-17',
|
|
lastPrice: 45.5,
|
|
bid: 44.0,
|
|
ask: 46.0,
|
|
volume: 150,
|
|
openInterest: 1200,
|
|
impliedVolatility: 0.45,
|
|
inTheMoney: true,
|
|
delta: 0.85,
|
|
gamma: 0.012,
|
|
theta: -0.05,
|
|
vega: 0.30,
|
|
},
|
|
{
|
|
contractSymbol: 'NVDA250717C00120000',
|
|
strike: 120,
|
|
expiration: '2026-07-17',
|
|
lastPrice: 30.2,
|
|
bid: 29.0,
|
|
ask: 31.0,
|
|
volume: 80,
|
|
openInterest: 800,
|
|
impliedVolatility: 0.42,
|
|
inTheMoney: true,
|
|
delta: 0.65,
|
|
gamma: 0.018,
|
|
theta: -0.04,
|
|
vega: 0.25,
|
|
},
|
|
],
|
|
puts: [
|
|
{
|
|
contractSymbol: 'NVDA250717P00080000',
|
|
strike: 80,
|
|
right: 'put',
|
|
expiration: '2026-07-17',
|
|
lastPrice: 5.1,
|
|
bid: 4.8,
|
|
ask: 5.4,
|
|
volume: 50,
|
|
openInterest: 300,
|
|
impliedVolatility: 0.55,
|
|
inTheMoney: false,
|
|
delta: -0.15,
|
|
gamma: 0.010,
|
|
theta: -0.03,
|
|
vega: 0.20,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// ----- Tests for the new 'greeks' kind -----
|
|
|
|
test('fetchOne(greeks) returns single row for a specific strike', async () => {
|
|
const raw = makeRawChainWithGreeks();
|
|
const adapter = createStubbedAdapter({
|
|
optionsExpiryDates: async () => ['2026-07-17'],
|
|
options: async () => raw,
|
|
});
|
|
|
|
const result = await adapter.fetchOne('yfinance:greeks:NVDA:2026-07-17:100');
|
|
|
|
// Should return the row with strike=100
|
|
const row = result.value as OptionChainRow | null;
|
|
assert.ok(row !== null, 'greeks kind should return a row');
|
|
assert.equal(row!.strike, 100);
|
|
assert.equal(row!.right, 'call');
|
|
assert.equal(row!.greeks?.delta, 0.85);
|
|
assert.equal(result.ttlClass, 'options_snapshot');
|
|
});
|
|
|
|
test('fetchOne(greeks) returns first row when strike is 0', async () => {
|
|
const raw = makeRawChainWithGreeks();
|
|
const adapter = createStubbedAdapter({
|
|
optionsExpiryDates: async () => ['2026-07-17'],
|
|
options: async () => raw,
|
|
});
|
|
|
|
const result = await adapter.fetchOne('yfinance:greeks:NVDA:2026-07-17:0');
|
|
|
|
const row = result.value as OptionChainRow | null;
|
|
assert.ok(row !== null, 'greeks kind with strike=0 should return first row');
|
|
// First row after sort: put at strike 80
|
|
assert.equal(row!.strike, 80);
|
|
assert.equal(row!.right, 'put');
|
|
});
|
|
|
|
test('fetchOne(greeks) returns null when strike not found', async () => {
|
|
const raw = makeRawChainWithGreeks();
|
|
const adapter = createStubbedAdapter({
|
|
optionsExpiryDates: async () => ['2026-07-17'],
|
|
options: async () => raw,
|
|
});
|
|
|
|
const result = await adapter.fetchOne('yfinance:greeks:NVDA:2026-07-17:9999');
|
|
|
|
// Should return null since no row matches strike 9999
|
|
assert.equal(result.value, null);
|
|
});
|
|
|
|
test('greeks kind preserves ttlClass as options_snapshot', async () => {
|
|
const raw = makeRawChainWithGreeks();
|
|
const adapter = createStubbedAdapter({
|
|
optionsExpiryDates: async () => ['2026-07-17'],
|
|
options: async () => raw,
|
|
});
|
|
|
|
const result = await adapter.fetchOne('yfinance:greeks:NVDA:2026-07-17:100');
|
|
assert.equal(result.ttlClass, 'options_snapshot');
|
|
});
|
|
|
|
test('greeks kind round-trips through CacheRepository', async () => {
|
|
const db = createTestDb();
|
|
const scheduler = new NoopScheduler();
|
|
const repo = createCacheRepository({ db, scheduler });
|
|
|
|
const raw = makeRawChainWithGreeks();
|
|
const adapter = createStubbedAdapter({
|
|
optionsExpiryDates: async () => ['2026-07-17'],
|
|
options: async () => raw,
|
|
});
|
|
|
|
const key = 'yfinance:greeks:NVDA:2026-07-17:100';
|
|
const fetched = await adapter.fetchOne(key);
|
|
|
|
// Write to cache
|
|
await repo.set(key, fetched.value, fetched.ttlClass, fetched.provenance);
|
|
|
|
// Read back from cache — value should be present (staleness depends on time)
|
|
const cached = await repo.get(key);
|
|
const cachedRow = cached.value as OptionChainRow | null;
|
|
assert.ok(cachedRow !== null, 'cached greeks row should not be null');
|
|
assert.equal(cachedRow!.strike, 100);
|
|
});
|
|
|
|
test('greeks kind: row with no greeks returns null greeks field', async () => {
|
|
const raw = {
|
|
calls: [
|
|
{
|
|
contractSymbol: 'AAPL250717C00150000',
|
|
strike: 150,
|
|
expiration: '2026-07-17',
|
|
lastPrice: 8.5,
|
|
bid: 8.0,
|
|
ask: 9.0,
|
|
volume: 200,
|
|
openInterest: 500,
|
|
impliedVolatility: 0.35,
|
|
// No greeks at all
|
|
},
|
|
],
|
|
puts: [],
|
|
};
|
|
|
|
const adapter = createStubbedAdapter({
|
|
optionsExpiryDates: async () => ['2026-07-17'],
|
|
options: async () => raw,
|
|
});
|
|
|
|
const result = await adapter.fetchOne('yfinance:greeks:AAPL:2026-07-17:150');
|
|
const row = result.value as OptionChainRow | null;
|
|
assert.ok(row !== null);
|
|
assert.equal(row!.greeks, null);
|
|
assert.equal(row!.strike, 150);
|
|
});
|