Codex changes
Co-authored-by: Codex
This commit is contained in:
parent
a82db56ab6
commit
aa0e651130
5 changed files with 431 additions and 243 deletions
17
services/compute/src/alert-scoring.ts
Normal file
17
services/compute/src/alert-scoring.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { ClassifierHitEvent, FlowPacket } from "@islandflow/types";
|
||||||
|
|
||||||
|
export const scoreAlert = (
|
||||||
|
packet: FlowPacket,
|
||||||
|
hits: ClassifierHitEvent[]
|
||||||
|
): { score: number; severity: string } => {
|
||||||
|
const premium =
|
||||||
|
typeof packet.features.total_premium === "number" ? packet.features.total_premium : 0;
|
||||||
|
const premiumScore = Math.min(70, Math.round(premium / 1000));
|
||||||
|
const maxConfidence = hits.reduce((max, hit) => Math.max(max, hit.confidence), 0);
|
||||||
|
const confidenceScore = Math.round(maxConfidence * 20);
|
||||||
|
const hitScore = Math.min(20, hits.length * 5);
|
||||||
|
const score = Math.max(0, Math.min(100, premiumScore + confidenceScore + hitScore));
|
||||||
|
const severity = score >= 80 ? "high" : score >= 45 ? "medium" : "low";
|
||||||
|
return { score, severity };
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -75,6 +75,7 @@ import {
|
||||||
shouldEmitStructurePacket,
|
shouldEmitStructurePacket,
|
||||||
type LegEvidence
|
type LegEvidence
|
||||||
} from "./structure-packets";
|
} from "./structure-packets";
|
||||||
|
import { scoreAlert } from "./alert-scoring";
|
||||||
|
|
||||||
const service = "compute";
|
const service = "compute";
|
||||||
const logger = createLogger({ service });
|
const logger = createLogger({ service });
|
||||||
|
|
@ -797,18 +798,6 @@ const flushCluster = async (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const scoreAlert = (packet: FlowPacket, hits: ClassifierHitEvent[]): { score: number; severity: string } => {
|
|
||||||
const premium =
|
|
||||||
typeof packet.features.total_premium === "number" ? packet.features.total_premium : 0;
|
|
||||||
const premiumScore = Math.min(70, Math.round(premium / 1000));
|
|
||||||
const maxConfidence = hits.reduce((max, hit) => Math.max(max, hit.confidence), 0);
|
|
||||||
const confidenceScore = Math.round(maxConfidence * 20);
|
|
||||||
const hitScore = Math.min(20, hits.length * 5);
|
|
||||||
const score = Math.max(0, Math.min(100, premiumScore + confidenceScore + hitScore));
|
|
||||||
const severity = score >= 80 ? "high" : score >= 45 ? "medium" : "low";
|
|
||||||
return { score, severity };
|
|
||||||
};
|
|
||||||
|
|
||||||
const emitClassifiers = async (
|
const emitClassifiers = async (
|
||||||
clickhouse: ReturnType<typeof createClickHouseClient>,
|
clickhouse: ReturnType<typeof createClickHouseClient>,
|
||||||
js: Awaited<ReturnType<typeof connectJetStreamWithRetry>>["js"],
|
js: Awaited<ReturnType<typeof connectJetStreamWithRetry>>["js"],
|
||||||
|
|
|
||||||
66
services/compute/tests/alert-scoring.test.ts
Normal file
66
services/compute/tests/alert-scoring.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import type { ClassifierHitEvent } from "@islandflow/types";
|
||||||
|
import { scoreAlert } from "../src/alert-scoring";
|
||||||
|
import { buildFlowPacket } from "./helpers";
|
||||||
|
|
||||||
|
const hit = (confidence: number): ClassifierHitEvent =>
|
||||||
|
({
|
||||||
|
source_ts: 1,
|
||||||
|
ingest_ts: 1,
|
||||||
|
seq: 1,
|
||||||
|
trace_id: `hit:${confidence}`,
|
||||||
|
classifier_id: "test",
|
||||||
|
confidence,
|
||||||
|
direction: "neutral",
|
||||||
|
explanations: ["test"]
|
||||||
|
}) satisfies ClassifierHitEvent;
|
||||||
|
|
||||||
|
describe("alert scoring", () => {
|
||||||
|
it("classifies <45 as low", () => {
|
||||||
|
const packet = buildFlowPacket({
|
||||||
|
features: {
|
||||||
|
total_premium: 44_000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = scoreAlert(packet, []);
|
||||||
|
expect(result.score).toBe(44);
|
||||||
|
expect(result.severity).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies >=45 as medium", () => {
|
||||||
|
const packet = buildFlowPacket({
|
||||||
|
features: {
|
||||||
|
total_premium: 45_000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = scoreAlert(packet, []);
|
||||||
|
expect(result.score).toBe(45);
|
||||||
|
expect(result.severity).toBe("medium");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies >=80 as high", () => {
|
||||||
|
const packet = buildFlowPacket({
|
||||||
|
features: {
|
||||||
|
total_premium: 65_000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = scoreAlert(packet, [hit(0.5)]);
|
||||||
|
expect(result.score).toBe(80);
|
||||||
|
expect(result.severity).toBe("high");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps 79 as medium", () => {
|
||||||
|
const packet = buildFlowPacket({
|
||||||
|
features: {
|
||||||
|
total_premium: 64_000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = scoreAlert(packet, [hit(0.5)]);
|
||||||
|
expect(result.score).toBe(79);
|
||||||
|
expect(result.severity).toBe("medium");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,249 +1,296 @@
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import type { FlowPacket } from "@islandflow/types";
|
import { evaluateClassifiers } from "../src/classifiers";
|
||||||
import { evaluateClassifiers, type ClassifierConfig } from "../src/classifiers";
|
import { buildFlowPacket, getHit, TEST_CLASSIFIER_CONFIG } from "./helpers";
|
||||||
|
|
||||||
const baseConfig: ClassifierConfig = {
|
const expectExplainable = (hit: NonNullable<ReturnType<typeof getHit>>) => {
|
||||||
sweepMinPremium: 40_000,
|
expect(hit.confidence).toBeGreaterThanOrEqual(0);
|
||||||
sweepMinCount: 3,
|
expect(hit.confidence).toBeLessThanOrEqual(1);
|
||||||
sweepMinPremiumZ: 2,
|
expect(hit.direction.length).toBeGreaterThan(0);
|
||||||
spikeMinPremium: 20_000,
|
expect(hit.explanations.length).toBeGreaterThan(0);
|
||||||
spikeMinSize: 400,
|
expect(hit.explanations.join(" ")).toMatch(/Likely|Consistent with|Unusual/i);
|
||||||
spikeMinPremiumZ: 2.5,
|
|
||||||
spikeMinSizeZ: 2,
|
|
||||||
zMinSamples: 12,
|
|
||||||
minNbboCoverage: 0.5,
|
|
||||||
minAggressorRatio: 0.55,
|
|
||||||
zeroDteMaxAtmPct: 0.01,
|
|
||||||
zeroDteMinPremium: 20_000,
|
|
||||||
zeroDteMinSize: 400
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_TS = Date.UTC(2024, 0, 2);
|
describe("compute classifiers", () => {
|
||||||
|
it("detects large bullish call sweep", () => {
|
||||||
const buildPacket = (
|
const packet = buildFlowPacket({
|
||||||
overrides: Record<string, string | number | boolean>
|
id: "flowpacket:sweep-call",
|
||||||
): FlowPacket => {
|
features: {
|
||||||
return {
|
option_contract_id: "SPY-2025-02-01-450-C",
|
||||||
source_ts: DEFAULT_TS,
|
count: 5,
|
||||||
ingest_ts: DEFAULT_TS,
|
window_ms: 500,
|
||||||
seq: 1,
|
total_size: 1200,
|
||||||
trace_id: "trace",
|
total_premium: 85_000,
|
||||||
id: "packet",
|
first_price: 1.0,
|
||||||
members: ["m1"],
|
last_price: 1.05,
|
||||||
features: {
|
nbbo_coverage_ratio: 0.9,
|
||||||
option_contract_id: "SPY-2025-01-17-450-C",
|
nbbo_aggressive_buy_ratio: 0.65,
|
||||||
count: 3,
|
nbbo_aggressive_sell_ratio: 0.15
|
||||||
total_premium: 1000,
|
}
|
||||||
total_size: 20,
|
|
||||||
first_price: 1,
|
|
||||||
last_price: 1.01,
|
|
||||||
start_ts: DEFAULT_TS - 500,
|
|
||||||
end_ts: DEFAULT_TS,
|
|
||||||
window_ms: 500,
|
|
||||||
...overrides
|
|
||||||
},
|
|
||||||
join_quality: {}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("classifier z-score behavior", () => {
|
|
||||||
test("spike hit triggers on z-score even when absolute thresholds fail", () => {
|
|
||||||
const packet = buildPacket({
|
|
||||||
total_premium_z: 3.2,
|
|
||||||
total_premium_baseline_n: 20,
|
|
||||||
total_size_z: 0.4,
|
|
||||||
total_size_baseline_n: 20
|
|
||||||
});
|
});
|
||||||
const hits = evaluateClassifiers(packet, baseConfig);
|
|
||||||
expect(hits.some((hit) => hit.classifier_id === "unusual_contract_spike")).toBe(true);
|
const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG);
|
||||||
|
const hit = getHit(hits, "large_bullish_call_sweep");
|
||||||
|
expect(hit).not.toBeNull();
|
||||||
|
expect(hit?.direction).toBe("bullish");
|
||||||
|
expectExplainable(hit!);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sweep hit triggers on premium z-score when baseline is ready", () => {
|
it("detects large bearish put sweep", () => {
|
||||||
const packet = buildPacket({
|
const packet = buildFlowPacket({
|
||||||
total_premium_z: 2.4,
|
id: "flowpacket:sweep-put",
|
||||||
total_premium_baseline_n: 20
|
features: {
|
||||||
|
option_contract_id: "SPY-2025-02-01-450-P",
|
||||||
|
count: 4,
|
||||||
|
window_ms: 420,
|
||||||
|
total_size: 900,
|
||||||
|
total_premium: 60_000,
|
||||||
|
first_price: 2.0,
|
||||||
|
last_price: 2.15,
|
||||||
|
nbbo_coverage_ratio: 0.85,
|
||||||
|
nbbo_aggressive_buy_ratio: 0.2,
|
||||||
|
nbbo_aggressive_sell_ratio: 0.7
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const hits = evaluateClassifiers(packet, baseConfig);
|
|
||||||
expect(hits.some((hit) => hit.classifier_id === "large_bullish_call_sweep")).toBe(true);
|
const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG);
|
||||||
|
const hit = getHit(hits, "large_bearish_put_sweep");
|
||||||
|
expect(hit).not.toBeNull();
|
||||||
|
expect(hit?.direction).toBe("bearish");
|
||||||
|
expectExplainable(hit!);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sweep hit does not trigger when baseline is insufficient", () => {
|
it("detects unusual contract spike", () => {
|
||||||
const packet = buildPacket({
|
const packet = buildFlowPacket({
|
||||||
total_premium_z: 3,
|
id: "flowpacket:spike",
|
||||||
total_premium_baseline_n: 4
|
features: {
|
||||||
|
option_contract_id: "NVDA-2025-02-21-600-C",
|
||||||
|
count: 2,
|
||||||
|
window_ms: 200,
|
||||||
|
total_size: 520,
|
||||||
|
total_premium: 30_000,
|
||||||
|
nbbo_coverage_ratio: 0.6,
|
||||||
|
nbbo_aggressive_buy_ratio: 0.6,
|
||||||
|
nbbo_aggressive_sell_ratio: 0.1
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const hits = evaluateClassifiers(packet, baseConfig);
|
|
||||||
expect(hits.some((hit) => hit.classifier_id === "large_bullish_call_sweep")).toBe(false);
|
const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG);
|
||||||
|
const hit = getHit(hits, "unusual_contract_spike");
|
||||||
|
expect(hit).not.toBeNull();
|
||||||
|
expect(hit?.direction).toBe("neutral");
|
||||||
|
expectExplainable(hit!);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("aggressor mix adjusts sweep confidence", () => {
|
it("detects large call sell overwrite (sell-side skew)", () => {
|
||||||
const basePacket = {
|
const packet = buildFlowPacket({
|
||||||
total_premium: 120_000,
|
id: "flowpacket:overwrite",
|
||||||
total_size: 900,
|
features: {
|
||||||
count: 4,
|
option_contract_id: "AAPL-2025-02-21-200-C",
|
||||||
nbbo_coverage_ratio: 0.8
|
count: 3,
|
||||||
};
|
window_ms: 300,
|
||||||
|
total_size: 900,
|
||||||
const lowAgg = buildPacket({
|
total_premium: 35_000,
|
||||||
...basePacket,
|
nbbo_coverage_ratio: 0.75,
|
||||||
nbbo_aggressive_buy_ratio: 0.2,
|
nbbo_aggressive_buy_ratio: 0.1,
|
||||||
nbbo_aggressive_sell_ratio: 0.2
|
nbbo_aggressive_sell_ratio: 0.75
|
||||||
});
|
}
|
||||||
const highAgg = buildPacket({
|
|
||||||
...basePacket,
|
|
||||||
nbbo_aggressive_buy_ratio: 0.7,
|
|
||||||
nbbo_aggressive_sell_ratio: 0.3
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const lowHit = evaluateClassifiers(lowAgg, baseConfig).find(
|
const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG);
|
||||||
(hit) => hit.classifier_id === "large_bullish_call_sweep"
|
const hit = getHit(hits, "large_call_sell_overwrite");
|
||||||
);
|
expect(hit).not.toBeNull();
|
||||||
const highHit = evaluateClassifiers(highAgg, baseConfig).find(
|
expect(hit?.direction).toBe("bearish");
|
||||||
(hit) => hit.classifier_id === "large_bullish_call_sweep"
|
expectExplainable(hit!);
|
||||||
);
|
});
|
||||||
|
|
||||||
expect(lowHit).toBeTruthy();
|
it("detects large put sell write (sell-side skew)", () => {
|
||||||
expect(highHit).toBeTruthy();
|
const packet = buildFlowPacket({
|
||||||
expect((highHit?.confidence ?? 0)).toBeGreaterThan(lowHit?.confidence ?? 0);
|
id: "flowpacket:put-write",
|
||||||
|
features: {
|
||||||
|
option_contract_id: "AAPL-2025-02-21-200-P",
|
||||||
|
count: 3,
|
||||||
|
window_ms: 300,
|
||||||
|
total_size: 850,
|
||||||
|
total_premium: 32_000,
|
||||||
|
nbbo_coverage_ratio: 0.75,
|
||||||
|
nbbo_aggressive_buy_ratio: 0.1,
|
||||||
|
nbbo_aggressive_sell_ratio: 0.72
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG);
|
||||||
|
const hit = getHit(hits, "large_put_sell_write");
|
||||||
|
expect(hit).not.toBeNull();
|
||||||
|
expect(hit?.direction).toBe("bullish");
|
||||||
|
expectExplainable(hit!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects far-dated conviction (>=60 DTE)", () => {
|
||||||
|
const packet = buildFlowPacket({
|
||||||
|
id: "flowpacket:far-dated",
|
||||||
|
source_ts: Date.parse("2025-01-01T14:30:00Z"),
|
||||||
|
features: {
|
||||||
|
option_contract_id: "SPY-2025-04-10-450-C",
|
||||||
|
count: 2,
|
||||||
|
window_ms: 250,
|
||||||
|
total_size: 650,
|
||||||
|
total_premium: 28_000,
|
||||||
|
nbbo_coverage_ratio: 0.7,
|
||||||
|
nbbo_aggressive_buy_ratio: 0.6,
|
||||||
|
nbbo_aggressive_sell_ratio: 0.2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG);
|
||||||
|
const hit = getHit(hits, "far_dated_conviction");
|
||||||
|
expect(hit).not.toBeNull();
|
||||||
|
expect(hit?.direction).toBe("bullish");
|
||||||
|
expectExplainable(hit!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects 0DTE gamma punch when expiry matches packet day and near ATM", () => {
|
||||||
|
const packet = buildFlowPacket({
|
||||||
|
id: "flowpacket:zero-dte",
|
||||||
|
source_ts: Date.parse("2025-01-17T15:30:00Z"),
|
||||||
|
features: {
|
||||||
|
option_contract_id: "SPY-2025-01-17-450-C",
|
||||||
|
count: 3,
|
||||||
|
window_ms: 350,
|
||||||
|
total_size: 800,
|
||||||
|
total_premium: 50_000,
|
||||||
|
underlying_mid: 450.5,
|
||||||
|
nbbo_coverage_ratio: 0.8,
|
||||||
|
nbbo_aggressive_buy_ratio: 0.65,
|
||||||
|
nbbo_aggressive_sell_ratio: 0.15
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG);
|
||||||
|
const hit = getHit(hits, "zero_dte_gamma_punch");
|
||||||
|
expect(hit).not.toBeNull();
|
||||||
|
expect(hit?.direction).toBe("bullish");
|
||||||
|
expectExplainable(hit!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects structure straddle and strangle packets", () => {
|
||||||
|
const base = {
|
||||||
|
packet_kind: "structure",
|
||||||
|
structure_legs: 4,
|
||||||
|
structure_strikes: 2,
|
||||||
|
structure_strike_span: 5,
|
||||||
|
total_size: 600,
|
||||||
|
total_premium: 30_000,
|
||||||
|
nbbo_coverage_ratio: 0.7,
|
||||||
|
nbbo_aggressive_buy_ratio: 0.55,
|
||||||
|
nbbo_aggressive_sell_ratio: 0.35
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const straddlePacket = buildFlowPacket({
|
||||||
|
id: "flowpacket:straddle",
|
||||||
|
features: {
|
||||||
|
...base,
|
||||||
|
structure_type: "straddle"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const stranglePacket = buildFlowPacket({
|
||||||
|
id: "flowpacket:strangle",
|
||||||
|
features: {
|
||||||
|
...base,
|
||||||
|
structure_type: "strangle",
|
||||||
|
structure_strike_span: 12
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const straddleHits = evaluateClassifiers(straddlePacket, TEST_CLASSIFIER_CONFIG);
|
||||||
|
const strangleHits = evaluateClassifiers(stranglePacket, TEST_CLASSIFIER_CONFIG);
|
||||||
|
|
||||||
|
const straddleHit = getHit(straddleHits, "straddle");
|
||||||
|
const strangleHit = getHit(strangleHits, "strangle");
|
||||||
|
|
||||||
|
expect(straddleHit).not.toBeNull();
|
||||||
|
expect(strangleHit).not.toBeNull();
|
||||||
|
expectExplainable(straddleHit!);
|
||||||
|
expectExplainable(strangleHit!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects vertical spread structure packets and infers direction from aggressor skew", () => {
|
||||||
|
const packet = buildFlowPacket({
|
||||||
|
id: "flowpacket:vertical",
|
||||||
|
features: {
|
||||||
|
packet_kind: "structure",
|
||||||
|
structure_type: "vertical",
|
||||||
|
structure_rights: "C",
|
||||||
|
structure_legs: 4,
|
||||||
|
structure_strikes: 2,
|
||||||
|
structure_strike_span: 10,
|
||||||
|
total_size: 900,
|
||||||
|
total_premium: 45_000,
|
||||||
|
nbbo_coverage_ratio: 0.8,
|
||||||
|
nbbo_aggressive_buy_ratio: 0.7,
|
||||||
|
nbbo_aggressive_sell_ratio: 0.2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG);
|
||||||
|
const hit = getHit(hits, "vertical_spread");
|
||||||
|
expect(hit).not.toBeNull();
|
||||||
|
expect(hit?.direction).toBe("bullish");
|
||||||
|
expectExplainable(hit!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects ladder accumulation packets", () => {
|
||||||
|
const packet = buildFlowPacket({
|
||||||
|
id: "flowpacket:ladder",
|
||||||
|
features: {
|
||||||
|
packet_kind: "structure",
|
||||||
|
structure_type: "ladder",
|
||||||
|
structure_rights: "C",
|
||||||
|
structure_legs: 6,
|
||||||
|
structure_strikes: 4,
|
||||||
|
structure_strike_span: 15,
|
||||||
|
total_size: 1200,
|
||||||
|
total_premium: 55_000,
|
||||||
|
nbbo_coverage_ratio: 0.65,
|
||||||
|
nbbo_aggressive_buy_ratio: 0.6,
|
||||||
|
nbbo_aggressive_sell_ratio: 0.2
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG);
|
||||||
|
const hit = getHit(hits, "ladder_accumulation");
|
||||||
|
expect(hit).not.toBeNull();
|
||||||
|
expect(hit?.direction).toBe("bullish");
|
||||||
|
expectExplainable(hit!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects roll up/down/out structure packets", () => {
|
||||||
|
const packet = buildFlowPacket({
|
||||||
|
id: "flowpacket:roll",
|
||||||
|
features: {
|
||||||
|
packet_kind: "structure",
|
||||||
|
structure_type: "roll",
|
||||||
|
structure_rights: "C",
|
||||||
|
underlying_id: "SPY",
|
||||||
|
roll_from_expiry: "2025-02-21",
|
||||||
|
roll_to_expiry: "2025-03-21",
|
||||||
|
roll_from_strike: 440,
|
||||||
|
roll_to_strike: 450,
|
||||||
|
roll_strike_delta: 10,
|
||||||
|
roll_expiry_days_delta: 28,
|
||||||
|
total_size: 700,
|
||||||
|
total_premium: 38_000,
|
||||||
|
nbbo_coverage_ratio: 0.7,
|
||||||
|
nbbo_aggressive_buy_ratio: 0.6,
|
||||||
|
nbbo_aggressive_sell_ratio: 0.3
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG);
|
||||||
|
const hit = getHit(hits, "roll_up_down_out");
|
||||||
|
expect(hit).not.toBeNull();
|
||||||
|
expect(hit?.direction).toBe("bullish");
|
||||||
|
expectExplainable(hit!);
|
||||||
|
expect(hit!.explanations[0]).toMatch(/Consistent with/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("classifier structure and positioning signals", () => {
|
|
||||||
test("call overwrite triggers on sell-side aggressor mix", () => {
|
|
||||||
const packet = buildPacket({
|
|
||||||
option_contract_id: "SPY-2024-03-15-450-C",
|
|
||||||
total_premium: 80_000,
|
|
||||||
total_size: 800,
|
|
||||||
nbbo_coverage_ratio: 0.9,
|
|
||||||
nbbo_aggressive_sell_ratio: 0.7,
|
|
||||||
nbbo_aggressive_buy_ratio: 0.3
|
|
||||||
});
|
|
||||||
const hits = evaluateClassifiers(packet, baseConfig);
|
|
||||||
expect(hits.some((hit) => hit.classifier_id === "large_call_sell_overwrite")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("put write triggers on sell-side aggressor mix", () => {
|
|
||||||
const packet = buildPacket({
|
|
||||||
option_contract_id: "SPY-2024-03-15-450-P",
|
|
||||||
total_premium: 75_000,
|
|
||||||
total_size: 700,
|
|
||||||
nbbo_coverage_ratio: 0.85,
|
|
||||||
nbbo_aggressive_sell_ratio: 0.68,
|
|
||||||
nbbo_aggressive_buy_ratio: 0.32
|
|
||||||
});
|
|
||||||
const hits = evaluateClassifiers(packet, baseConfig);
|
|
||||||
expect(hits.some((hit) => hit.classifier_id === "large_put_sell_write")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("straddle classifier triggers on structure tag", () => {
|
|
||||||
const packet = buildPacket({
|
|
||||||
packet_kind: "structure",
|
|
||||||
structure_type: "straddle",
|
|
||||||
structure_legs: 2,
|
|
||||||
structure_strikes: 1,
|
|
||||||
structure_rights: "C/P",
|
|
||||||
structure_strike_span: 0
|
|
||||||
});
|
|
||||||
const hits = evaluateClassifiers(packet, baseConfig);
|
|
||||||
expect(hits.some((hit) => hit.classifier_id === "straddle")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("structure classifiers are suppressed on per-contract packets", () => {
|
|
||||||
const packet = buildPacket({
|
|
||||||
structure_type: "straddle",
|
|
||||||
structure_legs: 2,
|
|
||||||
structure_strikes: 1,
|
|
||||||
structure_rights: "C/P",
|
|
||||||
structure_strike_span: 0
|
|
||||||
});
|
|
||||||
const hits = evaluateClassifiers(packet, baseConfig);
|
|
||||||
expect(hits.some((hit) => hit.classifier_id === "straddle")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("vertical spread infers direction from aggressor skew", () => {
|
|
||||||
const packet = buildPacket({
|
|
||||||
packet_kind: "structure",
|
|
||||||
structure_type: "vertical",
|
|
||||||
structure_legs: 2,
|
|
||||||
structure_strikes: 2,
|
|
||||||
structure_rights: "C",
|
|
||||||
structure_strike_span: 5,
|
|
||||||
total_premium: 55_000,
|
|
||||||
total_size: 600,
|
|
||||||
nbbo_coverage_ratio: 0.85,
|
|
||||||
nbbo_aggressive_buy_ratio: 0.7,
|
|
||||||
nbbo_aggressive_sell_ratio: 0.3
|
|
||||||
});
|
|
||||||
const hits = evaluateClassifiers(packet, baseConfig);
|
|
||||||
const hit = hits.find((candidate) => candidate.classifier_id === "vertical_spread");
|
|
||||||
expect(hit?.direction).toBe("bullish");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ladder accumulation triggers on multi-strike structures", () => {
|
|
||||||
const packet = buildPacket({
|
|
||||||
packet_kind: "structure",
|
|
||||||
structure_type: "ladder",
|
|
||||||
structure_legs: 3,
|
|
||||||
structure_strikes: 3,
|
|
||||||
structure_rights: "C",
|
|
||||||
structure_strike_span: 10,
|
|
||||||
total_premium: 60_000,
|
|
||||||
total_size: 650
|
|
||||||
});
|
|
||||||
const hits = evaluateClassifiers(packet, baseConfig);
|
|
||||||
expect(hits.some((hit) => hit.classifier_id === "ladder_accumulation")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("roll classifier triggers on cross-expiry structure packets", () => {
|
|
||||||
const packet = buildPacket({
|
|
||||||
packet_kind: "structure",
|
|
||||||
structure_type: "roll",
|
|
||||||
structure_legs: 2,
|
|
||||||
structure_strikes: 2,
|
|
||||||
structure_rights: "C",
|
|
||||||
structure_strike_span: 5,
|
|
||||||
total_premium: 70_000,
|
|
||||||
total_size: 800,
|
|
||||||
nbbo_coverage_ratio: 0.85,
|
|
||||||
nbbo_aggressive_buy_ratio: 0.7,
|
|
||||||
nbbo_aggressive_sell_ratio: 0.3,
|
|
||||||
roll_from_expiry: "2025-01-17",
|
|
||||||
roll_to_expiry: "2025-02-21",
|
|
||||||
roll_from_strike: 450,
|
|
||||||
roll_to_strike: 455,
|
|
||||||
roll_strike_delta: 5,
|
|
||||||
roll_expiry_days_delta: 35
|
|
||||||
});
|
|
||||||
const hits = evaluateClassifiers(packet, baseConfig);
|
|
||||||
const hit = hits.find((candidate) => candidate.classifier_id === "roll_up_down_out");
|
|
||||||
expect(hit).toBeTruthy();
|
|
||||||
expect(hit?.direction).toBe("bullish");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("far-dated conviction triggers on 60DTE threshold", () => {
|
|
||||||
const packet = buildPacket({
|
|
||||||
option_contract_id: "SPY-2024-04-19-450-C",
|
|
||||||
end_ts: DEFAULT_TS,
|
|
||||||
total_premium: 70_000,
|
|
||||||
total_size: 800
|
|
||||||
});
|
|
||||||
const hits = evaluateClassifiers(packet, baseConfig);
|
|
||||||
const hit = hits.find((candidate) => candidate.classifier_id === "far_dated_conviction");
|
|
||||||
expect(hit?.direction).toBe("bullish");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("zero dte gamma punch triggers when ATM and large", () => {
|
|
||||||
const packet = buildPacket({
|
|
||||||
option_contract_id: "SPY-2024-01-02-450-C",
|
|
||||||
total_premium: 35_000,
|
|
||||||
total_size: 600,
|
|
||||||
underlying_mid: 450,
|
|
||||||
nbbo_coverage_ratio: 0.8,
|
|
||||||
nbbo_aggressive_buy_ratio: 0.7,
|
|
||||||
nbbo_aggressive_sell_ratio: 0.3
|
|
||||||
});
|
|
||||||
const hits = evaluateClassifiers(packet, baseConfig);
|
|
||||||
const hit = hits.find((candidate) => candidate.classifier_id === "zero_dte_gamma_punch");
|
|
||||||
expect(hit?.direction).toBe("bullish");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
69
services/compute/tests/helpers.ts
Normal file
69
services/compute/tests/helpers.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import type { ClassifierConfig, ClassifierHit } from "../src/classifiers";
|
||||||
|
import type { FlowPacket } from "@islandflow/types";
|
||||||
|
|
||||||
|
export const TEST_CLASSIFIER_CONFIG: ClassifierConfig = {
|
||||||
|
sweepMinPremium: 40_000,
|
||||||
|
sweepMinCount: 3,
|
||||||
|
sweepMinPremiumZ: 2,
|
||||||
|
spikeMinPremium: 20_000,
|
||||||
|
spikeMinSize: 400,
|
||||||
|
spikeMinPremiumZ: 2.5,
|
||||||
|
spikeMinSizeZ: 2,
|
||||||
|
zMinSamples: 12,
|
||||||
|
minNbboCoverage: 0.5,
|
||||||
|
minAggressorRatio: 0.55,
|
||||||
|
zeroDteMaxAtmPct: 0.01,
|
||||||
|
zeroDteMinPremium: 20_000,
|
||||||
|
zeroDteMinSize: 400
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildFlowPacket = (opts: {
|
||||||
|
id?: string;
|
||||||
|
source_ts?: number;
|
||||||
|
ingest_ts?: number;
|
||||||
|
seq?: number;
|
||||||
|
trace_id?: string;
|
||||||
|
members?: string[];
|
||||||
|
features?: FlowPacket["features"];
|
||||||
|
join_quality?: FlowPacket["join_quality"];
|
||||||
|
} = {}): FlowPacket => {
|
||||||
|
const id = opts.id ?? "flowpacket:test";
|
||||||
|
const source_ts = opts.source_ts ?? Date.parse("2025-01-01T14:30:00Z");
|
||||||
|
const ingest_ts = opts.ingest_ts ?? source_ts;
|
||||||
|
const seq = opts.seq ?? 1;
|
||||||
|
const trace_id = opts.trace_id ?? `trace:${id}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
source_ts,
|
||||||
|
ingest_ts,
|
||||||
|
seq,
|
||||||
|
trace_id,
|
||||||
|
id,
|
||||||
|
members: opts.members ?? ["print:1", "print:2"],
|
||||||
|
features: {
|
||||||
|
count: 1,
|
||||||
|
window_ms: 250,
|
||||||
|
total_premium: 0,
|
||||||
|
total_size: 0,
|
||||||
|
first_price: 0,
|
||||||
|
last_price: 0,
|
||||||
|
total_premium_z: 0,
|
||||||
|
total_size_z: 0,
|
||||||
|
total_premium_baseline_n: 0,
|
||||||
|
total_size_baseline_n: 0,
|
||||||
|
nbbo_coverage_ratio: 0,
|
||||||
|
nbbo_aggressive_buy_ratio: 0,
|
||||||
|
nbbo_aggressive_sell_ratio: 0,
|
||||||
|
underlying_mid: 0,
|
||||||
|
...opts.features
|
||||||
|
},
|
||||||
|
join_quality: {
|
||||||
|
...opts.join_quality
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHit = (hits: ClassifierHit[], id: string): ClassifierHit | null => {
|
||||||
|
return hits.find((hit) => hit.classifier_id === id) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue