Add multi-leg structure tagging for flow packets
This commit is contained in:
parent
163ab1039e
commit
0b0ffa651e
8 changed files with 291 additions and 93 deletions
|
|
@ -1,11 +1,5 @@
|
|||
import type { ClassifierHit, FlowPacket } from "@islandflow/types";
|
||||
|
||||
type ParsedContract = {
|
||||
root: string;
|
||||
expiry: string;
|
||||
strike: number;
|
||||
right: "C" | "P";
|
||||
};
|
||||
import { parseContractId, type ParsedContract } from "./contracts";
|
||||
|
||||
export type ClassifierConfig = {
|
||||
sweepMinPremium: number;
|
||||
|
|
@ -32,81 +26,6 @@ const formatUsd = (value: number): string => {
|
|||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const parseDashedContract = (value: string): ParsedContract | null => {
|
||||
const parts = value.split("-");
|
||||
if (parts.length < 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rightRaw = parts.at(-1) ?? "";
|
||||
if (rightRaw !== "C" && rightRaw !== "P") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const strikeRaw = parts.at(-2) ?? "";
|
||||
const strike = Number(strikeRaw);
|
||||
const expiryParts = parts.slice(-5, -2);
|
||||
const expiry = expiryParts.join("-");
|
||||
const root = parts.slice(0, -5).join("-");
|
||||
|
||||
if (!root || !expiry || !Number.isFinite(strike)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
expiry,
|
||||
strike,
|
||||
right: rightRaw
|
||||
};
|
||||
};
|
||||
|
||||
const parseOccContract = (value: string): ParsedContract | null => {
|
||||
if (value.length < 15) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tail = value.slice(-15);
|
||||
const root = value.slice(0, -15).trim();
|
||||
const expiryRaw = tail.slice(0, 6);
|
||||
const right = tail.slice(6, 7);
|
||||
const strikeRaw = tail.slice(7);
|
||||
|
||||
if (!/^\d{6}$/.test(expiryRaw) || !/^\d{8}$/.test(strikeRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (right !== "C" && right !== "P") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = 2000 + Number(expiryRaw.slice(0, 2));
|
||||
const month = Number(expiryRaw.slice(2, 4)) - 1;
|
||||
const day = Number(expiryRaw.slice(4, 6));
|
||||
const expiryDate = new Date(Date.UTC(year, month, day));
|
||||
const expiry = expiryDate.toISOString().slice(0, 10);
|
||||
const strike = Number(strikeRaw) / 1000;
|
||||
|
||||
if (!root || !Number.isFinite(strike)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
expiry,
|
||||
strike,
|
||||
right
|
||||
};
|
||||
};
|
||||
|
||||
const parseContractId = (value: string | undefined): ParsedContract | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseDashedContract(value) ?? parseOccContract(value);
|
||||
};
|
||||
|
||||
const getNumberFeature = (packet: FlowPacket, key: string): number => {
|
||||
const value = packet.features[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
|
|
|
|||
81
services/compute/src/contracts.ts
Normal file
81
services/compute/src/contracts.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
export type ParsedContract = {
|
||||
root: string;
|
||||
expiry: string;
|
||||
strike: number;
|
||||
right: "C" | "P";
|
||||
};
|
||||
|
||||
const parseDashedContract = (value: string): ParsedContract | null => {
|
||||
const parts = value.split("-");
|
||||
if (parts.length < 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rightRaw = parts.at(-1) ?? "";
|
||||
if (rightRaw !== "C" && rightRaw !== "P") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const strikeRaw = parts.at(-2) ?? "";
|
||||
const strike = Number(strikeRaw);
|
||||
const expiryParts = parts.slice(-5, -2);
|
||||
const expiry = expiryParts.join("-");
|
||||
const root = parts.slice(0, -5).join("-");
|
||||
|
||||
if (!root || !expiry || !Number.isFinite(strike)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
expiry,
|
||||
strike,
|
||||
right: rightRaw
|
||||
};
|
||||
};
|
||||
|
||||
const parseOccContract = (value: string): ParsedContract | null => {
|
||||
if (value.length < 15) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tail = value.slice(-15);
|
||||
const root = value.slice(0, -15).trim();
|
||||
const expiryRaw = tail.slice(0, 6);
|
||||
const right = tail.slice(6, 7);
|
||||
const strikeRaw = tail.slice(7);
|
||||
|
||||
if (!/^\d{6}$/.test(expiryRaw) || !/^\d{8}$/.test(strikeRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (right !== "C" && right !== "P") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = 2000 + Number(expiryRaw.slice(0, 2));
|
||||
const month = Number(expiryRaw.slice(2, 4)) - 1;
|
||||
const day = Number(expiryRaw.slice(4, 6));
|
||||
const expiryDate = new Date(Date.UTC(year, month, day));
|
||||
const expiry = expiryDate.toISOString().slice(0, 10);
|
||||
const strike = Number(strikeRaw) / 1000;
|
||||
|
||||
if (!root || !Number.isFinite(strike)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
expiry,
|
||||
strike,
|
||||
right
|
||||
};
|
||||
};
|
||||
|
||||
export const parseContractId = (value: string | undefined): ParsedContract | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseDashedContract(value) ?? parseOccContract(value);
|
||||
};
|
||||
|
|
@ -40,7 +40,9 @@ import {
|
|||
} from "@islandflow/types";
|
||||
import { z } from "zod";
|
||||
import { evaluateClassifiers, type ClassifierConfig } from "./classifiers";
|
||||
import { parseContractId } from "./contracts";
|
||||
import { createRedisClient, updateRollingStats, type RollingStatsConfig } from "./rolling-stats";
|
||||
import { summarizeStructure, type ContractLeg } from "./structures";
|
||||
|
||||
const service = "compute";
|
||||
const logger = createLogger({ service });
|
||||
|
|
@ -142,11 +144,77 @@ type ClusterState = {
|
|||
|
||||
const clusters = new Map<string, ClusterState>();
|
||||
const nbboCache = new Map<string, OptionNBBO>();
|
||||
const recentLegsByKey = new Map<string, ContractLeg[]>();
|
||||
|
||||
const MAX_RECENT_LEGS = 20;
|
||||
|
||||
const rollingKey = (metric: string, contractId: string): string => {
|
||||
return `rolling:${metric}:${contractId}`;
|
||||
};
|
||||
|
||||
const buildLegFromCluster = (cluster: ClusterState): ContractLeg | null => {
|
||||
const parsed = parseContractId(cluster.contractId);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
contractId: cluster.contractId,
|
||||
startTs: cluster.startTs,
|
||||
endTs: cluster.endTs
|
||||
};
|
||||
};
|
||||
|
||||
const buildLegKey = (leg: ContractLeg): string => {
|
||||
return `${leg.root}:${leg.expiry}`;
|
||||
};
|
||||
|
||||
const isWithinStructureWindow = (anchorTs: number, candidateTs: number): boolean => {
|
||||
return Math.abs(anchorTs - candidateTs) <= env.CLUSTER_WINDOW_MS;
|
||||
};
|
||||
|
||||
const collectRecentLegs = (key: string, anchorTs: number, excludeId: string): ContractLeg[] => {
|
||||
const recent = recentLegsByKey.get(key) ?? [];
|
||||
const filtered = recent.filter(
|
||||
(leg) => leg.contractId !== excludeId && isWithinStructureWindow(anchorTs, leg.endTs)
|
||||
);
|
||||
recentLegsByKey.set(key, filtered);
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const storeRecentLeg = (leg: ContractLeg, anchorTs: number): void => {
|
||||
const key = buildLegKey(leg);
|
||||
const recent = collectRecentLegs(key, anchorTs, "");
|
||||
const next = [leg, ...recent].slice(0, MAX_RECENT_LEGS);
|
||||
recentLegsByKey.set(key, next);
|
||||
};
|
||||
|
||||
const collectActiveLegs = (
|
||||
key: string,
|
||||
anchorTs: number,
|
||||
excludeId: string
|
||||
): ContractLeg[] => {
|
||||
const legs: ContractLeg[] = [];
|
||||
for (const [contractId, cluster] of clusters) {
|
||||
if (contractId === excludeId) {
|
||||
continue;
|
||||
}
|
||||
const leg = buildLegFromCluster(cluster);
|
||||
if (!leg) {
|
||||
continue;
|
||||
}
|
||||
if (buildLegKey(leg) !== key) {
|
||||
continue;
|
||||
}
|
||||
if (!isWithinStructureWindow(anchorTs, leg.endTs)) {
|
||||
continue;
|
||||
}
|
||||
legs.push(leg);
|
||||
}
|
||||
return legs;
|
||||
};
|
||||
|
||||
const applyDeliverPolicy = (
|
||||
opts: ReturnType<typeof buildDurableConsumer>,
|
||||
policy: typeof env.COMPUTE_DELIVER_POLICY
|
||||
|
|
@ -275,6 +343,25 @@ const flushCluster = async (
|
|||
await addRollingSnapshot("premium", totalPremium, "total_premium");
|
||||
await addRollingSnapshot("size", cluster.totalSize, "total_size");
|
||||
|
||||
const currentLeg = buildLegFromCluster(cluster);
|
||||
if (currentLeg) {
|
||||
const key = buildLegKey(currentLeg);
|
||||
const anchorTs = cluster.endTs;
|
||||
const candidates = [
|
||||
...collectRecentLegs(key, anchorTs, currentLeg.contractId),
|
||||
...collectActiveLegs(key, anchorTs, currentLeg.contractId)
|
||||
];
|
||||
const summary = summarizeStructure([currentLeg, ...candidates]);
|
||||
if (summary) {
|
||||
features.structure_type = summary.type;
|
||||
features.structure_legs = summary.legs;
|
||||
features.structure_strikes = summary.strikes;
|
||||
features.structure_strike_span = roundTo(summary.strikeSpan);
|
||||
features.structure_rights = summary.rights;
|
||||
}
|
||||
storeRecentLeg(currentLeg, anchorTs);
|
||||
}
|
||||
|
||||
if (!nbboJoin.nbbo) {
|
||||
joinQuality.nbbo_missing = 1;
|
||||
} else {
|
||||
|
|
|
|||
46
services/compute/src/structures.ts
Normal file
46
services/compute/src/structures.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { ParsedContract } from "./contracts";
|
||||
|
||||
export type ContractLeg = ParsedContract & {
|
||||
contractId: string;
|
||||
startTs: number;
|
||||
endTs: number;
|
||||
};
|
||||
|
||||
export type StructureSummary = {
|
||||
type: string;
|
||||
legs: number;
|
||||
strikes: number;
|
||||
strikeSpan: number;
|
||||
rights: string;
|
||||
contractIds: string[];
|
||||
};
|
||||
|
||||
export const summarizeStructure = (legs: ContractLeg[]): StructureSummary | null => {
|
||||
if (legs.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const strikes = Array.from(new Set(legs.map((leg) => leg.strike))).sort((a, b) => a - b);
|
||||
const rights = new Set(legs.map((leg) => leg.right));
|
||||
const strikeSpan = strikes.length >= 2 ? strikes[strikes.length - 1] - strikes[0] : 0;
|
||||
|
||||
let type = "multi_leg";
|
||||
if (rights.size === 2 && strikes.length === 1) {
|
||||
type = "straddle";
|
||||
} else if (rights.size === 2 && strikes.length >= 2) {
|
||||
type = "strangle";
|
||||
} else if (rights.size === 1 && strikes.length === 2) {
|
||||
type = "vertical";
|
||||
} else if (rights.size === 1 && strikes.length >= 3) {
|
||||
type = "ladder";
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
legs: legs.length,
|
||||
strikes: strikes.length,
|
||||
strikeSpan,
|
||||
rights: rights.size === 2 ? "C/P" : Array.from(rights)[0] ?? "",
|
||||
contractIds: legs.map((leg) => leg.contractId)
|
||||
};
|
||||
};
|
||||
43
services/compute/tests/structures.test.ts
Normal file
43
services/compute/tests/structures.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { summarizeStructure, type ContractLeg } from "../src/structures";
|
||||
|
||||
const leg = (contractId: string, right: "C" | "P", strike: number): ContractLeg => ({
|
||||
contractId,
|
||||
root: "SPY",
|
||||
expiry: "2025-01-17",
|
||||
right,
|
||||
strike,
|
||||
startTs: 0,
|
||||
endTs: 0
|
||||
});
|
||||
|
||||
describe("structure summaries", () => {
|
||||
test("detects verticals", () => {
|
||||
const summary = summarizeStructure([leg("c1", "C", 100), leg("c2", "C", 105)]);
|
||||
expect(summary?.type).toBe("vertical");
|
||||
expect(summary?.legs).toBe(2);
|
||||
expect(summary?.strikes).toBe(2);
|
||||
});
|
||||
|
||||
test("detects ladders", () => {
|
||||
const summary = summarizeStructure([
|
||||
leg("c1", "C", 100),
|
||||
leg("c2", "C", 105),
|
||||
leg("c3", "C", 110)
|
||||
]);
|
||||
expect(summary?.type).toBe("ladder");
|
||||
expect(summary?.strikes).toBe(3);
|
||||
});
|
||||
|
||||
test("detects straddles", () => {
|
||||
const summary = summarizeStructure([leg("c1", "C", 100), leg("p1", "P", 100)]);
|
||||
expect(summary?.type).toBe("straddle");
|
||||
expect(summary?.rights).toBe("C/P");
|
||||
});
|
||||
|
||||
test("detects strangles", () => {
|
||||
const summary = summarizeStructure([leg("c1", "C", 105), leg("p1", "P", 95)]);
|
||||
expect(summary?.type).toBe("strangle");
|
||||
expect(summary?.strikes).toBe(2);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue