Files
investor-flow/app/server/src/adapters/__tests__/OptionsAdapter.greeks.test.ts
T

206 lines
6.0 KiB
TypeScript
Raw Normal View History

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);
});