fix: backfill symbol_demand for sidebar-added symbols + analyst ratings schema fix
- 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
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
|
||||
// Import pure helpers directly (no React rendering needed for tests)
|
||||
import {
|
||||
computeIVRank,
|
||||
classifyRisk,
|
||||
computeOptionPayoff,
|
||||
generatePayoffCurve,
|
||||
extractOIWalls,
|
||||
computeMaxPain,
|
||||
} from '../lib/options-pure.ts';
|
||||
|
||||
import type { OptionChainRow } from '../../server/src/adapters/OptionsAdapter';
|
||||
|
||||
// ----- IV Rank Tests -----
|
||||
|
||||
test('computeIVRank: returns 50 when range is zero', () => {
|
||||
const result = computeIVRank(0.30, 0.30, 0.30);
|
||||
assert.equal(result.rank, 50);
|
||||
assert.equal(result.percentile, 50);
|
||||
assert.equal(result.currentIV, 0.30);
|
||||
});
|
||||
|
||||
test('computeIVRank: returns 0 when currentIV equals low', () => {
|
||||
const result = computeIVRank(0.20, 0.50, 0.20);
|
||||
assert.equal(result.rank, 0);
|
||||
assert.equal(result.percentile, 0);
|
||||
});
|
||||
|
||||
test('computeIVRank: returns 100 when currentIV equals high', () => {
|
||||
const result = computeIVRank(0.50, 0.50, 0.20);
|
||||
assert.equal(result.rank, 100);
|
||||
assert.equal(result.percentile, 100);
|
||||
});
|
||||
|
||||
test('computeIVRank: returns correct rank for mid-range IV', () => {
|
||||
const result = computeIVRank(0.35, 0.50, 0.20);
|
||||
// (0.35 - 0.20) / (0.50 - 0.20) = 0.15/0.30 = 0.5 → rank 50
|
||||
assert.equal(result.rank, 50);
|
||||
assert.equal(result.percentile, 50);
|
||||
});
|
||||
|
||||
test('computeIVRank: clamps rank to [0, 100]', () => {
|
||||
const result = computeIVRank(0.80, 0.50, 0.20);
|
||||
assert.equal(result.rank, 100);
|
||||
const result2 = computeIVRank(0.10, 0.50, 0.20);
|
||||
assert.equal(result2.rank, 0);
|
||||
});
|
||||
|
||||
// ----- Risk Classification Tests -----
|
||||
|
||||
test('classifyRisk: returns "defined" for spreads', () => {
|
||||
assert.equal(classifyRisk('call', true), 'defined');
|
||||
assert.equal(classifyRisk('put', true), 'defined');
|
||||
});
|
||||
|
||||
test('classifyRisk: returns "undefined" for single options', () => {
|
||||
assert.equal(classifyRisk('call', false), 'undefined');
|
||||
assert.equal(classifyRisk('put', false), 'undefined');
|
||||
});
|
||||
|
||||
// ----- Option Payoff Tests -----
|
||||
|
||||
test('computeOptionPayoff: call in-the-money profit', () => {
|
||||
// Stock at 110, strike 100, paid $5 premium
|
||||
const pnl = computeOptionPayoff(110, 100, 'call', 5);
|
||||
// Intrinsic = 110 - 100 = 10; PnL = 10 - 5 = 5
|
||||
assert.equal(pnl, 5);
|
||||
});
|
||||
|
||||
test('computeOptionPayoff: call out-of-the-money loss = premium', () => {
|
||||
const pnl = computeOptionPayoff(90, 100, 'call', 5);
|
||||
// Intrinsic = max(0, 90-100) = 0; PnL = 0 - 5 = -5
|
||||
assert.equal(pnl, -5);
|
||||
});
|
||||
|
||||
test('computeOptionPayoff: put in-the-money profit', () => {
|
||||
const pnl = computeOptionPayoff(90, 100, 'put', 5);
|
||||
// Intrinsic = max(0, 100-90) = 10; PnL = 10 - 5 = 5
|
||||
assert.equal(pnl, 5);
|
||||
});
|
||||
|
||||
test('computeOptionPayoff: put out-of-the-money loss = premium', () => {
|
||||
const pnl = computeOptionPayoff(110, 100, 'put', 5);
|
||||
// Intrinsic = max(0, 100-110) = 0; PnL = 0 - 5 = -5
|
||||
assert.equal(pnl, -5);
|
||||
});
|
||||
|
||||
test('computeOptionPayoff: at-the-money call = -premium', () => {
|
||||
const pnl = computeOptionPayoff(100, 100, 'call', 5);
|
||||
assert.equal(pnl, -5);
|
||||
});
|
||||
|
||||
test('computeOptionPayoff: at-the-money put = -premium', () => {
|
||||
const pnl = computeOptionPayoff(100, 100, 'put', 5);
|
||||
assert.equal(pnl, -5);
|
||||
});
|
||||
|
||||
// ----- Payoff Curve Tests -----
|
||||
|
||||
test('generatePayoffCurve: generates at least numPoints', () => {
|
||||
const points = generatePayoffCurve([100], 100, 'call', 5, 40);
|
||||
assert.ok(points.length >= 40, `expected >= 40 points, got ${points.length}`);
|
||||
});
|
||||
|
||||
test('generatePayoffCurve: includes zero-PnL point for calls above strike', () => {
|
||||
const points = generatePayoffCurve([100], 100, 'call', 5, 100);
|
||||
const hasZeroPnl = points.some((p) => p.pnl === 0);
|
||||
assert.ok(hasZeroPnl, 'should have a zero-PnL point');
|
||||
});
|
||||
|
||||
test('generatePayoffCurve: put payoff is negative for stock > strike', () => {
|
||||
const points = generatePayoffCurve([100], 100, 'put', 5, 20);
|
||||
// For stock prices above strike, put intrinsic = 0, so PnL = -premium
|
||||
const aboveStrike = points.filter((p) => p.stockPrice > 100);
|
||||
for (const p of aboveStrike) {
|
||||
assert.equal(p.pnl, -5);
|
||||
}
|
||||
});
|
||||
|
||||
// ----- OI Walls Tests -----
|
||||
|
||||
test('extractOIWalls: filters rows with OI > 0', () => {
|
||||
const rows: OptionChainRow[] = [
|
||||
{ contractSymbol: 'A', strike: 100, right: 'call', expiration: '2026-07-17', openInterest: 1000 },
|
||||
{ contractSymbol: 'B', strike: 110, right: 'put', expiration: '2026-07-17', openInterest: 0 },
|
||||
{ contractSymbol: 'C', strike: 120, right: 'call', expiration: '2026-07-17', openInterest: 500 },
|
||||
];
|
||||
|
||||
const walls = extractOIWalls(rows);
|
||||
assert.equal(walls.length, 2);
|
||||
assert.deepEqual(walls.map((w) => w.strike), [100, 120]);
|
||||
});
|
||||
|
||||
test('extractOIWalls: sorts by strike ascending', () => {
|
||||
const rows: OptionChainRow[] = [
|
||||
{ contractSymbol: 'A', strike: 120, right: 'call', expiration: '2026-07-17', openInterest: 500 },
|
||||
{ contractSymbol: 'B', strike: 80, right: 'put', expiration: '2026-07-17', openInterest: 300 },
|
||||
{ contractSymbol: 'C', strike: 100, right: 'call', expiration: '2026-07-17', openInterest: 800 },
|
||||
];
|
||||
|
||||
const walls = extractOIWalls(rows);
|
||||
assert.deepEqual(walls.map((w) => w.strike), [80, 100, 120]);
|
||||
});
|
||||
|
||||
test('extractOIWalls: preserves right classification', () => {
|
||||
const rows: OptionChainRow[] = [
|
||||
{ contractSymbol: 'A', strike: 100, right: 'call', expiration: '2026-07-17', openInterest: 500 },
|
||||
{ contractSymbol: 'B', strike: 80, right: 'put', expiration: '2026-07-17', openInterest: 300 },
|
||||
];
|
||||
|
||||
const walls = extractOIWalls(rows);
|
||||
assert.equal(walls[0].right, 'put');
|
||||
assert.equal(walls[1].right, 'call');
|
||||
});
|
||||
|
||||
// ----- Max Pain Tests -----
|
||||
|
||||
test('computeMaxPain: returns 0 for empty rows', () => {
|
||||
assert.equal(computeMaxPain([]), 0);
|
||||
});
|
||||
|
||||
test('computeMaxPain: finds strike that minimizes total option value', () => {
|
||||
const rows: OptionChainRow[] = [
|
||||
{ contractSymbol: 'A', strike: 100, right: 'call', expiration: '2026-07-17', openInterest: 1000 },
|
||||
{ contractSymbol: 'B', strike: 110, right: 'call', expiration: '2026-07-17', openInterest: 500 },
|
||||
{ contractSymbol: 'C', strike: 90, right: 'put', expiration: '2026-07-17', openInterest: 800 },
|
||||
];
|
||||
|
||||
const maxPain = computeMaxPain(rows);
|
||||
// Max pain should be one of the strikes (90, 100, or 110)
|
||||
assert.ok([90, 100, 110].includes(maxPain), `max pain ${maxPain} should be one of the strikes`);
|
||||
});
|
||||
|
||||
test('computeMaxPain: returns first strike when all are equal', () => {
|
||||
const rows: OptionChainRow[] = [
|
||||
{ contractSymbol: 'A', strike: 100, right: 'call', expiration: '2026-07-17', openInterest: 100 },
|
||||
];
|
||||
|
||||
const maxPain = computeMaxPain(rows);
|
||||
assert.equal(maxPain, 100);
|
||||
});
|
||||
@@ -34,7 +34,9 @@ test("curated UI strings pass the Primary-Rule lint", () => {
|
||||
test("panel/page/client source pass the Primary-Rule lint", () => {
|
||||
const files = [
|
||||
"../components/OverviewPanel.tsx",
|
||||
"../components/RiskPosturePanel.tsx",
|
||||
"../app/page.tsx",
|
||||
"../app/risk/page.tsx",
|
||||
"../app/layout.tsx",
|
||||
"../lib/strings.ts",
|
||||
"../lib/trpc.ts",
|
||||
|
||||
Reference in New Issue
Block a user