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 { /* no-op */ } } /** A stubbed yfinance2 client that returns canned data — no network. */ interface StubYf { optionsExpiryDates: (symbol: string) => Promise; options: (symbol: string, expiry?: string) => Promise>; } /** 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 { 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); });