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:
Investor Flow Build
2026-07-23 18:02:24 -04:00
parent 5ef2b2f060
commit e262187c3c
204 changed files with 25014 additions and 2934 deletions
@@ -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",