329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
import {
|
|
SmartMoneyEventSchema,
|
|
type ClassifierHit,
|
|
type FlowPacket,
|
|
type SmartMoneyDirection,
|
|
type SmartMoneyEvent,
|
|
type SmartMoneyFeatures,
|
|
type SmartMoneyProfileId,
|
|
type SmartMoneyProfileScore
|
|
} from "@islandflow/types";
|
|
import type { EventCalendarMatch } from "@islandflow/refdata/event-calendar";
|
|
import { parseContractId } from "./contracts";
|
|
|
|
const MS_PER_DAY = 86_400_000;
|
|
const SPECIAL_CONDITIONS = new Set(["AUCTION", "CROSS", "OPENING", "CLOSING", "COMPLEX", "SPREAD"]);
|
|
|
|
const clamp = (value: number, min = 0, max = 1): number => {
|
|
if (!Number.isFinite(value)) {
|
|
return min;
|
|
}
|
|
return Math.max(min, Math.min(max, value));
|
|
};
|
|
|
|
const numberFeature = (packet: FlowPacket, key: string): number => {
|
|
const value = packet.features[key];
|
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
};
|
|
|
|
const stringFeature = (packet: FlowPacket, key: string): string => {
|
|
const value = packet.features[key];
|
|
return typeof value === "string" ? value : "";
|
|
};
|
|
|
|
const boolFeature = (packet: FlowPacket, key: string): boolean | null => {
|
|
const value = packet.features[key];
|
|
return typeof value === "boolean" ? value : null;
|
|
};
|
|
|
|
const confidenceBand = (probability: number): SmartMoneyProfileScore["confidence_band"] => {
|
|
if (probability >= 0.72) {
|
|
return "high";
|
|
}
|
|
if (probability >= 0.52) {
|
|
return "medium";
|
|
}
|
|
return "low";
|
|
};
|
|
|
|
const score = (
|
|
profile_id: SmartMoneyProfileId,
|
|
probability: number,
|
|
direction: SmartMoneyDirection,
|
|
reasons: string[]
|
|
): SmartMoneyProfileScore => ({
|
|
profile_id,
|
|
probability: clamp(probability),
|
|
confidence_band: confidenceBand(probability),
|
|
direction,
|
|
reasons
|
|
});
|
|
|
|
const getReferenceTs = (packet: FlowPacket): number => {
|
|
return numberFeature(packet, "end_ts") || packet.source_ts;
|
|
};
|
|
|
|
const getDteDays = (packet: FlowPacket): number | null => {
|
|
const contract = parseContractId(stringFeature(packet, "option_contract_id"));
|
|
if (!contract) {
|
|
return null;
|
|
}
|
|
const expiryTs = Date.parse(`${contract.expiry}T00:00:00Z`);
|
|
if (!Number.isFinite(expiryTs)) {
|
|
return null;
|
|
}
|
|
const diff = expiryTs - getReferenceTs(packet);
|
|
return diff >= 0 ? Math.ceil(diff / MS_PER_DAY) : null;
|
|
};
|
|
|
|
const inferDirection = (packet: FlowPacket): SmartMoneyDirection => {
|
|
const structureRights = stringFeature(packet, "structure_rights");
|
|
const optionType = stringFeature(packet, "option_type") || parseContractId(stringFeature(packet, "option_contract_id"))?.right;
|
|
const buy = numberFeature(packet, "nbbo_aggressive_buy_ratio");
|
|
const sell = numberFeature(packet, "nbbo_aggressive_sell_ratio");
|
|
const sellDominant = sell >= buy + 0.12;
|
|
|
|
if (structureRights === "C") {
|
|
return sellDominant ? "bearish" : "bullish";
|
|
}
|
|
if (structureRights === "P") {
|
|
return sellDominant ? "bullish" : "bearish";
|
|
}
|
|
if (optionType === "C") {
|
|
return sellDominant ? "bearish" : "bullish";
|
|
}
|
|
if (optionType === "P") {
|
|
return sellDominant ? "bullish" : "bearish";
|
|
}
|
|
return "neutral";
|
|
};
|
|
|
|
export type SmartMoneyParentEventOptions = {
|
|
eventCalendarMatch?: EventCalendarMatch | null;
|
|
};
|
|
|
|
const buildFeatures = (packet: FlowPacket, options: SmartMoneyParentEventOptions = {}): SmartMoneyFeatures => {
|
|
const contractId = stringFeature(packet, "option_contract_id");
|
|
const contract = parseContractId(contractId);
|
|
const underlyingMid = numberFeature(packet, "underlying_mid");
|
|
const quoteAge = numberFeature(packet, "nbbo_age_ms") || numberFeature(packet, "underlying_quote_age_ms");
|
|
const printCount = Math.max(0, Math.round(numberFeature(packet, "count") || packet.members.length));
|
|
const staleCount = numberFeature(packet, "nbbo_stale_count");
|
|
const missingCount = numberFeature(packet, "nbbo_missing_count");
|
|
const structureLegs = Math.max(0, Math.round(numberFeature(packet, "structure_legs")));
|
|
const strikeCount = Math.max(1, Math.round(numberFeature(packet, "structure_strikes") || (contract ? 1 : 0)));
|
|
const specialCount = numberFeature(packet, "special_print_count");
|
|
const calendarEventTs = options.eventCalendarMatch?.event_ts ?? null;
|
|
const eventTs = calendarEventTs ?? numberFeature(packet, "corporate_event_ts");
|
|
const referenceTs = getReferenceTs(packet);
|
|
const expiryTs = contract ? Date.parse(`${contract.expiry}T00:00:00Z`) : Number.NaN;
|
|
|
|
const atmProximity =
|
|
contract && underlyingMid > 0 ? Math.abs(contract.strike - underlyingMid) / underlyingMid : null;
|
|
|
|
return {
|
|
contract_count: Math.max(1, structureLegs || 1),
|
|
print_count: printCount,
|
|
total_size: numberFeature(packet, "total_size"),
|
|
total_premium: numberFeature(packet, "total_premium"),
|
|
total_notional: numberFeature(packet, "total_notional"),
|
|
start_ts: numberFeature(packet, "start_ts") || packet.source_ts,
|
|
end_ts: numberFeature(packet, "end_ts") || packet.source_ts,
|
|
window_ms: Math.max(0, Math.round(numberFeature(packet, "window_ms"))),
|
|
...(contractId ? { option_contract_id: contractId } : {}),
|
|
...(contract?.right === "C" || contract?.right === "P" ? { option_type: contract.right } : {}),
|
|
dte_days: getDteDays(packet),
|
|
moneyness: contract && underlyingMid > 0 ? contract.strike / underlyingMid : null,
|
|
atm_proximity: atmProximity,
|
|
aggressor_buy_ratio: clamp(numberFeature(packet, "nbbo_aggressive_buy_ratio")),
|
|
aggressor_sell_ratio: clamp(numberFeature(packet, "nbbo_aggressive_sell_ratio")),
|
|
aggressor_ratio: clamp(numberFeature(packet, "nbbo_aggressive_ratio")),
|
|
nbbo_coverage_ratio: clamp(numberFeature(packet, "nbbo_coverage_ratio")),
|
|
nbbo_inside_ratio: clamp(numberFeature(packet, "nbbo_inside_ratio")),
|
|
nbbo_stale_ratio: printCount > 0 ? clamp((staleCount + missingCount) / printCount) : 0,
|
|
quote_age_ms: quoteAge > 0 ? quoteAge : null,
|
|
venue_count: Math.max(1, Math.round(numberFeature(packet, "venue_count") || 1)),
|
|
inter_fill_ms_mean: printCount > 1 ? numberFeature(packet, "window_ms") / Math.max(1, printCount - 1) : null,
|
|
strike_count: strikeCount,
|
|
strike_concentration: strikeCount > 0 ? clamp(1 / strikeCount) : 0,
|
|
...(stringFeature(packet, "structure_type") ? { structure_type: stringFeature(packet, "structure_type") } : {}),
|
|
structure_legs: structureLegs,
|
|
same_size_leg_symmetry: clamp(numberFeature(packet, "same_size_leg_symmetry")),
|
|
net_directional_bias: clamp(
|
|
numberFeature(packet, "nbbo_aggressive_buy_ratio") - numberFeature(packet, "nbbo_aggressive_sell_ratio"),
|
|
-1,
|
|
1
|
|
),
|
|
synthetic_iv_shock: numberFeature(packet, "execution_iv_shock") || null,
|
|
spread_widening: numberFeature(packet, "nbbo_spread_z") || null,
|
|
underlying_move_bps: numberFeature(packet, "underlying_move_bps") || null,
|
|
days_to_event: eventTs > 0 ? (eventTs - referenceTs) / MS_PER_DAY : null,
|
|
expiry_after_event: eventTs > 0 && Number.isFinite(expiryTs) ? expiryTs >= eventTs : null,
|
|
pre_event_concentration: eventTs > 0 && eventTs >= referenceTs ? clamp(1 - (eventTs - referenceTs) / (21 * MS_PER_DAY)) : null,
|
|
special_print_ratio: printCount > 0 ? clamp(specialCount / printCount) : 0
|
|
};
|
|
};
|
|
|
|
const detectSuppression = (packet: FlowPacket, features: SmartMoneyFeatures): string[] => {
|
|
const reasons: string[] = [];
|
|
const conditions = String(packet.features.conditions ?? "")
|
|
.split(",")
|
|
.map((item) => item.trim().toUpperCase())
|
|
.filter(Boolean);
|
|
if (conditions.some((condition) => SPECIAL_CONDITIONS.has(condition)) || features.special_print_ratio >= 0.34) {
|
|
reasons.push("special_print_or_complex_context");
|
|
}
|
|
if (features.nbbo_coverage_ratio < 0.35 || features.nbbo_stale_ratio >= 0.5) {
|
|
reasons.push("stale_or_missing_quote_context");
|
|
}
|
|
if (features.nbbo_inside_ratio >= 0.7 && features.aggressor_ratio < 0.35) {
|
|
reasons.push("inside_market_or_cross_like_execution");
|
|
}
|
|
return reasons;
|
|
};
|
|
|
|
const evaluateProfiles = (
|
|
packet: FlowPacket,
|
|
features: SmartMoneyFeatures,
|
|
suppressed: string[]
|
|
): SmartMoneyProfileScore[] => {
|
|
const direction = inferDirection(packet);
|
|
const dte = features.dte_days ?? 999;
|
|
const structure = features.structure_type ?? "";
|
|
const isStructure = features.structure_legs >= 2 || Boolean(structure);
|
|
const buy = features.aggressor_buy_ratio;
|
|
const sell = features.aggressor_sell_ratio;
|
|
const premiumFactor = clamp(features.total_premium / 120_000);
|
|
const sizeFactor = clamp(features.total_size / 1800);
|
|
const burstFactor = clamp(features.print_count / 8);
|
|
const quality = clamp(features.nbbo_coverage_ratio - features.nbbo_stale_ratio);
|
|
const shortDatedOtm =
|
|
dte <= 7 && features.atm_proximity !== null && features.atm_proximity >= 0.05 && features.option_type === "C";
|
|
const nearAtm = features.atm_proximity !== null && features.atm_proximity <= 0.015;
|
|
const preEvent =
|
|
features.days_to_event !== null &&
|
|
features.days_to_event >= 0 &&
|
|
features.days_to_event <= 21 &&
|
|
features.expiry_after_event === true;
|
|
|
|
const scores = [
|
|
score(
|
|
"institutional_directional",
|
|
suppressed.length > 0 || shortDatedOtm
|
|
? 0.18
|
|
: 0.2 + premiumFactor * 0.25 + burstFactor * 0.18 + quality * 0.16 + (buy >= 0.58 || sell >= 0.58 ? 0.12 : 0),
|
|
direction,
|
|
[
|
|
"large_parent_event",
|
|
"directional_aggressor_mix",
|
|
...(shortDatedOtm ? ["retail_frenzy_guard"] : []),
|
|
...suppressed
|
|
]
|
|
),
|
|
score(
|
|
"retail_whale",
|
|
0.12 +
|
|
(shortDatedOtm ? 0.28 : 0) +
|
|
burstFactor * 0.18 +
|
|
clamp(features.synthetic_iv_shock ?? 0, 0, 0.2) +
|
|
(features.total_premium < 100_000 ? 0.1 : 0),
|
|
direction,
|
|
["short_dated_otm_attention_flow", "burst_print_pattern"]
|
|
),
|
|
score(
|
|
"event_driven",
|
|
0.12 + (preEvent ? 0.32 : 0) + premiumFactor * 0.14 + clamp(features.spread_widening ?? 0, 0, 0.16),
|
|
direction === "unknown" ? "neutral" : direction,
|
|
["event_calendar_alignment", "expiry_after_event", "pre_event_concentration"]
|
|
),
|
|
score(
|
|
"vol_seller",
|
|
0.12 + (sell >= 0.58 ? 0.24 : 0) + (structure === "straddle" || structure === "strangle" ? 0.2 : 0) + premiumFactor * 0.14,
|
|
"neutral",
|
|
["sell_side_premium", "short_vol_structure_evidence"]
|
|
),
|
|
score(
|
|
"arbitrage",
|
|
0.08 +
|
|
(isStructure ? 0.18 : 0) +
|
|
(features.same_size_leg_symmetry >= 0.7 ? 0.24 : 0) +
|
|
(Math.abs(features.net_directional_bias) <= 0.15 ? 0.18 : 0),
|
|
"neutral",
|
|
["matched_leg_symmetry", "near_flat_directional_exposure"]
|
|
),
|
|
score(
|
|
"hedge_reactive",
|
|
0.1 +
|
|
(dte <= 2 && nearAtm ? 0.32 : 0) +
|
|
clamp(Math.abs(features.underlying_move_bps ?? 0) / 80, 0, 0.18) +
|
|
sizeFactor * 0.12,
|
|
direction,
|
|
["short_dated_atm_gamma_context", "underlying_move_linkage"]
|
|
)
|
|
];
|
|
|
|
return scores.sort((a, b) => b.probability - a.probability);
|
|
};
|
|
|
|
export const buildSmartMoneyEventFromPacket = (
|
|
packet: FlowPacket,
|
|
options: SmartMoneyParentEventOptions = {}
|
|
): SmartMoneyEvent => {
|
|
const features = buildFeatures(packet, options);
|
|
const suppressed = detectSuppression(packet, features);
|
|
const profileScores = evaluateProfiles(packet, features, suppressed);
|
|
const primary = profileScores[0] ?? null;
|
|
const abstained = !primary || primary.probability < 0.42 || suppressed.includes("stale_or_missing_quote_context");
|
|
const underlying = stringFeature(packet, "underlying_id") || parseContractId(features.option_contract_id ?? "")?.root || "UNKNOWN";
|
|
const eventKind = features.structure_legs >= 2 || stringFeature(packet, "packet_kind") === "structure"
|
|
? "multi_leg_event"
|
|
: "single_leg_event";
|
|
|
|
return SmartMoneyEventSchema.parse({
|
|
source_ts: packet.source_ts,
|
|
ingest_ts: packet.ingest_ts,
|
|
seq: packet.seq,
|
|
trace_id: `smartmoney:${packet.id}`,
|
|
event_id: `smartmoney:${eventKind}:${packet.id}`,
|
|
packet_ids: [packet.id],
|
|
member_print_ids: packet.members,
|
|
underlying_id: underlying,
|
|
event_kind: eventKind,
|
|
event_window_ms: features.window_ms,
|
|
features,
|
|
profile_scores: profileScores,
|
|
primary_profile_id: abstained ? null : primary?.profile_id ?? null,
|
|
primary_direction: abstained ? "unknown" : primary?.direction ?? "unknown",
|
|
abstained,
|
|
suppressed_reasons: suppressed
|
|
});
|
|
};
|
|
|
|
const LEGACY_PROFILE_MAP: Record<SmartMoneyProfileId, string> = {
|
|
institutional_directional: "smart_money_institutional_directional",
|
|
retail_whale: "smart_money_retail_whale",
|
|
event_driven: "smart_money_event_driven",
|
|
vol_seller: "smart_money_vol_seller",
|
|
arbitrage: "smart_money_arbitrage",
|
|
hedge_reactive: "smart_money_hedge_reactive"
|
|
};
|
|
|
|
export const deriveClassifierHitsFromSmartMoneyEvent = (event: SmartMoneyEvent): ClassifierHit[] => {
|
|
if (event.abstained || !event.primary_profile_id) {
|
|
return [];
|
|
}
|
|
|
|
return event.profile_scores
|
|
.filter((entry) => entry.profile_id === event.primary_profile_id || entry.probability >= 0.5)
|
|
.slice(0, 3)
|
|
.map((entry) => ({
|
|
classifier_id: LEGACY_PROFILE_MAP[entry.profile_id],
|
|
confidence: entry.probability,
|
|
direction: entry.direction,
|
|
explanations: [
|
|
`Profile ${entry.profile_id} probability ${(entry.probability * 100).toFixed(0)}%.`,
|
|
...entry.reasons,
|
|
...event.suppressed_reasons.map((reason) => `Suppression guard: ${reason}.`)
|
|
]
|
|
}));
|
|
};
|