Add synthetic print and structure features

- Export synthetic market types
- Track special print conditions and derived cluster features
- Add same-size leg symmetry to structure packets
This commit is contained in:
dirtydishes 2026-05-13 22:36:13 -04:00
parent 8dcbcd2201
commit 9076d3b395
4 changed files with 103 additions and 0 deletions

View file

@ -2,3 +2,4 @@ export * from "./events";
export * from "./live";
export * from "./options-flow";
export * from "./sp500";
export * from "./synthetic-market";

View file

@ -271,6 +271,14 @@ type ClusterState = {
totalPremium: number;
firstPrice: number;
lastPrice: number;
conditions: Set<string>;
specialPrintCount: number;
firstExecutionIv: number | null;
lastExecutionIv: number | null;
minExecutionIv: number | null;
maxExecutionIv: number | null;
firstUnderlyingMid: number | null;
lastUnderlyingMid: number | null;
placements: NbboPlacementCounts;
flushed: boolean;
};
@ -329,6 +337,29 @@ const createPlacementCounts = (): NbboPlacementCounts => ({
stale: 0
});
const SPECIAL_PRINT_CONDITIONS = new Set(["AUCTION", "CROSS", "OPENING", "CLOSING", "COMPLEX", "SPREAD"]);
const SYNTHETIC_EVENT_CONDITION_RE = /^EVENT_(\d+)D$/i;
const normalizeConditions = (conditions: readonly string[] | undefined): string[] =>
(conditions ?? []).map((condition) => condition.trim().toUpperCase()).filter(Boolean);
const hasSpecialCondition = (conditions: readonly string[] | undefined): boolean =>
normalizeConditions(conditions).some((condition) => SPECIAL_PRINT_CONDITIONS.has(condition));
const parseSyntheticEventOffsetDays = (conditions: Iterable<string>): number | null => {
for (const condition of conditions) {
const match = SYNTHETIC_EVENT_CONDITION_RE.exec(condition);
if (!match) {
continue;
}
const days = Number(match[1]);
if (Number.isFinite(days) && days > 0) {
return days;
}
}
return null;
};
const recordPlacement = (counts: NbboPlacementCounts, placement: NbboPlacement): void => {
switch (placement) {
case "AA":
@ -569,6 +600,12 @@ const applyDeliverPolicy = (
const buildCluster = (print: OptionPrint): ClusterState => {
const placements = createPlacementCounts();
const normalizedConditions = normalizeConditions(print.conditions);
const executionIv = typeof print.execution_iv === "number" && Number.isFinite(print.execution_iv) ? print.execution_iv : null;
const executionUnderlyingMid =
typeof print.execution_underlying_mid === "number" && Number.isFinite(print.execution_underlying_mid)
? print.execution_underlying_mid
: null;
recordPlacement(placements, classifyPlacement(print.price, selectNbbo(print.option_contract_id, print.ts)));
return {
contractId: print.option_contract_id,
@ -585,6 +622,14 @@ const buildCluster = (print: OptionPrint): ClusterState => {
totalPremium: print.price * print.size,
firstPrice: print.price,
lastPrice: print.price,
conditions: new Set(normalizedConditions),
specialPrintCount: hasSpecialCondition(print.conditions) ? 1 : 0,
firstExecutionIv: executionIv,
lastExecutionIv: executionIv,
minExecutionIv: executionIv,
maxExecutionIv: executionIv,
firstUnderlyingMid: executionUnderlyingMid,
lastUnderlyingMid: executionUnderlyingMid,
placements,
flushed: false
};
@ -607,6 +652,25 @@ const updateCluster = (cluster: ClusterState, print: OptionPrint): ClusterState
cluster.totalSize += print.size;
cluster.totalPremium += print.price * print.size;
cluster.lastPrice = print.price;
for (const condition of normalizeConditions(print.conditions)) {
cluster.conditions.add(condition);
}
if (hasSpecialCondition(print.conditions)) {
cluster.specialPrintCount += 1;
}
if (typeof print.execution_iv === "number" && Number.isFinite(print.execution_iv)) {
cluster.lastExecutionIv = print.execution_iv;
cluster.minExecutionIv =
cluster.minExecutionIv === null ? print.execution_iv : Math.min(cluster.minExecutionIv, print.execution_iv);
cluster.maxExecutionIv =
cluster.maxExecutionIv === null ? print.execution_iv : Math.max(cluster.maxExecutionIv, print.execution_iv);
}
if (typeof print.execution_underlying_mid === "number" && Number.isFinite(print.execution_underlying_mid)) {
if (cluster.firstUnderlyingMid === null) {
cluster.firstUnderlyingMid = print.execution_underlying_mid;
}
cluster.lastUnderlyingMid = print.execution_underlying_mid;
}
recordPlacement(
cluster.placements,
classifyPlacement(print.price, selectNbbo(print.option_contract_id, print.ts))
@ -836,6 +900,27 @@ const flushCluster = async (
if (cluster.isEtf !== null) {
features.is_etf = cluster.isEtf;
}
if (cluster.conditions.size > 0) {
features.conditions = Array.from(cluster.conditions).sort().join(",");
}
if (cluster.specialPrintCount > 0) {
features.special_print_count = cluster.specialPrintCount;
}
if (cluster.minExecutionIv !== null && cluster.maxExecutionIv !== null) {
features.execution_iv_shock = roundTo(Math.max(0, cluster.maxExecutionIv - cluster.minExecutionIv));
}
if (
cluster.firstUnderlyingMid !== null &&
cluster.lastUnderlyingMid !== null &&
cluster.firstUnderlyingMid > 0
) {
const moveBps = ((cluster.lastUnderlyingMid - cluster.firstUnderlyingMid) / cluster.firstUnderlyingMid) * 10_000;
features.underlying_move_bps = roundTo(moveBps);
}
const syntheticEventOffsetDays = parseSyntheticEventOffsetDays(cluster.conditions);
if (syntheticEventOffsetDays !== null) {
features.corporate_event_ts = cluster.endTs + syntheticEventOffsetDays * 86_400_000;
}
const placementTotal =
cluster.placements.aa +

View file

@ -46,6 +46,7 @@ export type StructurePacketPlan = {
nbboAggressiveBuyRatio: number;
nbboAggressiveSellRatio: number;
nbboAggressiveRatio: number;
sameSizeLegSymmetry: number;
source_ts: number;
ingest_ts: number;
seq: number;
@ -132,6 +133,19 @@ const dayDiff = (from: string | null, to: string | null): number | null => {
return Math.round(diffMs / 86_400_000);
};
const sameSizeLegSymmetry = (legs: LegEvidence[]): number => {
const sizes = legs.map((leg) => leg.totalSize).filter((value) => Number.isFinite(value) && value > 0);
if (sizes.length < 2) {
return 0;
}
const min = Math.min(...sizes);
const max = Math.max(...sizes);
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= 0) {
return 0;
}
return min / max;
};
export const shouldEmitStructurePacket = (legs: LegEvidence[], currentLegContractId: string): boolean => {
if (legs.length < 2) {
return false;
@ -250,6 +264,7 @@ export const planStructurePacket = (
nbboAggressiveBuyRatio,
nbboAggressiveSellRatio,
nbboAggressiveRatio,
sameSizeLegSymmetry: roundTo(sameSizeLegSymmetry(legs)),
source_ts: Number.isFinite(source_ts) ? source_ts : 0,
ingest_ts,
seq
@ -320,6 +335,7 @@ export const buildStructureFlowPacket = (
features.nbbo_aggressive_buy_ratio = roundTo(plan.nbboAggressiveBuyRatio);
features.nbbo_aggressive_sell_ratio = roundTo(plan.nbboAggressiveSellRatio);
features.nbbo_aggressive_ratio = roundTo(plan.nbboAggressiveRatio);
features.same_size_leg_symmetry = roundTo(plan.sameSizeLegSymmetry);
const join_quality: Record<string, number> = {
nbbo_coverage_ratio: roundTo(plan.nbboCoverageRatio)

View file

@ -130,6 +130,7 @@ describe("structure packet planning", () => {
expect(packet.features.nbbo_bb_count).toBe(1);
expect(packet.features.nbbo_mid_count).toBe(1);
expect(packet.features.nbbo_coverage_ratio).toBeCloseTo(1, 6);
expect(packet.features.same_size_leg_symmetry).toBeCloseTo(0.5, 4);
// 2 aggressive (AA + BB) out of 3 classified (AA + BB + MID)
expect(packet.features.nbbo_aggressive_ratio).toBeCloseTo(2 / 3, 4);