Add additional flow classifiers
This commit is contained in:
parent
1583a50412
commit
f96f5699ef
2 changed files with 627 additions and 34 deletions
|
|
@ -14,6 +14,8 @@ export type ClassifierConfig = {
|
|||
minAggressorRatio: number;
|
||||
};
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
const clamp = (value: number, min = 0, max = 1): number => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return min;
|
||||
|
|
@ -33,8 +35,29 @@ const getNumberFeature = (packet: FlowPacket, key: string): number => {
|
|||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
};
|
||||
|
||||
const getStringFeature = (packet: FlowPacket, key: string): string => {
|
||||
const value = packet.features[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
};
|
||||
|
||||
const formatPct = (value: number): string => `${Math.round(value * 100)}%`;
|
||||
|
||||
const getAggressorContext = (
|
||||
packet: FlowPacket
|
||||
): {
|
||||
coverage: number;
|
||||
aggressiveBuyRatio: number;
|
||||
aggressiveSellRatio: number;
|
||||
aggressiveRatio: number;
|
||||
} => {
|
||||
return {
|
||||
coverage: getNumberFeature(packet, "nbbo_coverage_ratio"),
|
||||
aggressiveBuyRatio: getNumberFeature(packet, "nbbo_aggressive_buy_ratio"),
|
||||
aggressiveSellRatio: getNumberFeature(packet, "nbbo_aggressive_sell_ratio"),
|
||||
aggressiveRatio: getNumberFeature(packet, "nbbo_aggressive_ratio")
|
||||
};
|
||||
};
|
||||
|
||||
const applyAggressorAdjustment = (
|
||||
confidence: number,
|
||||
coverage: number,
|
||||
|
|
@ -61,6 +84,124 @@ const applyAggressorAdjustment = (
|
|||
return { confidence: adjusted, note };
|
||||
};
|
||||
|
||||
type LargeActivity = {
|
||||
count: number;
|
||||
totalPremium: number;
|
||||
totalSize: number;
|
||||
windowMs: number;
|
||||
premiumZ: number;
|
||||
sizeZ: number;
|
||||
premiumBaselineReady: boolean;
|
||||
sizeBaselineReady: boolean;
|
||||
passesAbsolute: boolean;
|
||||
passesZ: boolean;
|
||||
baselineNote: string;
|
||||
};
|
||||
|
||||
const getLargeActivity = (packet: FlowPacket, config: ClassifierConfig): LargeActivity => {
|
||||
const count = getNumberFeature(packet, "count");
|
||||
const totalPremium = getNumberFeature(packet, "total_premium");
|
||||
const totalSize = getNumberFeature(packet, "total_size");
|
||||
const windowMs = getNumberFeature(packet, "window_ms");
|
||||
const premiumZ = getNumberFeature(packet, "total_premium_z");
|
||||
const sizeZ = getNumberFeature(packet, "total_size_z");
|
||||
const premiumBaseline = getNumberFeature(packet, "total_premium_baseline_n");
|
||||
const sizeBaseline = getNumberFeature(packet, "total_size_baseline_n");
|
||||
|
||||
const premiumBaselineReady = premiumBaseline >= config.zMinSamples;
|
||||
const sizeBaselineReady = sizeBaseline >= config.zMinSamples;
|
||||
const passesAbsolute = totalSize >= config.spikeMinSize && totalPremium >= config.spikeMinPremium;
|
||||
const passesZ =
|
||||
(premiumBaselineReady && premiumZ >= config.spikeMinPremiumZ) ||
|
||||
(sizeBaselineReady && sizeZ >= config.spikeMinSizeZ);
|
||||
|
||||
const baselineNote =
|
||||
premiumBaselineReady || sizeBaselineReady
|
||||
? `Baseline z-scores: premium ${premiumZ.toFixed(2)}, size ${sizeZ.toFixed(2)}.`
|
||||
: "Baseline z-scores unavailable.";
|
||||
|
||||
return {
|
||||
count,
|
||||
totalPremium,
|
||||
totalSize,
|
||||
windowMs,
|
||||
premiumZ,
|
||||
sizeZ,
|
||||
premiumBaselineReady,
|
||||
sizeBaselineReady,
|
||||
passesAbsolute,
|
||||
passesZ,
|
||||
baselineNote
|
||||
};
|
||||
};
|
||||
|
||||
const applySideAggressorAdjustment = (
|
||||
confidence: number,
|
||||
coverage: number,
|
||||
ratio: number,
|
||||
config: ClassifierConfig,
|
||||
label: string
|
||||
): { confidence: number; note: string } => {
|
||||
const normalizedCoverage = clamp(coverage, 0, 1);
|
||||
const normalizedRatio = clamp(ratio, 0, 1);
|
||||
let adjusted = confidence;
|
||||
|
||||
if (normalizedCoverage <= 0) {
|
||||
return {
|
||||
confidence: adjusted - 0.15,
|
||||
note: "Aggressor mix unavailable (no NBBO coverage)."
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedCoverage < config.minNbboCoverage) {
|
||||
adjusted -= 0.1;
|
||||
}
|
||||
|
||||
if (normalizedRatio >= config.minAggressorRatio) {
|
||||
adjusted += 0.05;
|
||||
} else {
|
||||
adjusted -= 0.1;
|
||||
}
|
||||
|
||||
const note = `Aggressor mix ${formatPct(normalizedRatio)} ${label}, NBBO coverage ${formatPct(
|
||||
normalizedCoverage
|
||||
)}.`;
|
||||
|
||||
return { confidence: adjusted, note };
|
||||
};
|
||||
|
||||
const getReferenceTs = (packet: FlowPacket): number | null => {
|
||||
const endTs = getNumberFeature(packet, "end_ts");
|
||||
if (endTs > 0) {
|
||||
return endTs;
|
||||
}
|
||||
|
||||
if (Number.isFinite(packet.source_ts) && packet.source_ts > 0) {
|
||||
return packet.source_ts;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getDteDays = (packet: FlowPacket, contract: ParsedContract): number | null => {
|
||||
const expiryTs = Date.parse(`${contract.expiry}T00:00:00Z`);
|
||||
if (!Number.isFinite(expiryTs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const referenceTs = getReferenceTs(packet);
|
||||
if (!referenceTs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const diffMs = expiryTs - referenceTs;
|
||||
if (diffMs < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.ceil(diffMs / MS_PER_DAY);
|
||||
};
|
||||
|
||||
const buildSweepHit = (
|
||||
packet: FlowPacket,
|
||||
contract: ParsedContract,
|
||||
|
|
@ -130,43 +271,30 @@ const buildSweepHit = (
|
|||
};
|
||||
|
||||
const buildSpikeHit = (packet: FlowPacket, config: ClassifierConfig): ClassifierHit | null => {
|
||||
const count = getNumberFeature(packet, "count");
|
||||
const totalPremium = getNumberFeature(packet, "total_premium");
|
||||
const totalSize = getNumberFeature(packet, "total_size");
|
||||
const windowMs = getNumberFeature(packet, "window_ms");
|
||||
const premiumZ = getNumberFeature(packet, "total_premium_z");
|
||||
const sizeZ = getNumberFeature(packet, "total_size_z");
|
||||
const premiumBaseline = getNumberFeature(packet, "total_premium_baseline_n");
|
||||
const sizeBaseline = getNumberFeature(packet, "total_size_baseline_n");
|
||||
const coverage = getNumberFeature(packet, "nbbo_coverage_ratio");
|
||||
const aggressiveBuyRatio = getNumberFeature(packet, "nbbo_aggressive_buy_ratio");
|
||||
const aggressiveSellRatio = getNumberFeature(packet, "nbbo_aggressive_sell_ratio");
|
||||
const activity = getLargeActivity(packet, config);
|
||||
const { coverage, aggressiveBuyRatio, aggressiveSellRatio } = getAggressorContext(packet);
|
||||
const aggressiveRatio = Math.max(aggressiveBuyRatio, aggressiveSellRatio);
|
||||
|
||||
const premiumBaselineReady = premiumBaseline >= config.zMinSamples;
|
||||
const sizeBaselineReady = sizeBaseline >= config.zMinSamples;
|
||||
const passesAbsolute = totalSize >= config.spikeMinSize && totalPremium >= config.spikeMinPremium;
|
||||
const passesZ =
|
||||
(premiumBaselineReady && premiumZ >= config.spikeMinPremiumZ) ||
|
||||
(sizeBaselineReady && sizeZ >= config.spikeMinSizeZ);
|
||||
|
||||
if (!passesAbsolute && !passesZ) {
|
||||
if (!activity.passesAbsolute && !activity.passesZ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let confidence = 0.5;
|
||||
if (totalSize >= config.spikeMinSize * 2) {
|
||||
if (activity.totalSize >= config.spikeMinSize * 2) {
|
||||
confidence += 0.15;
|
||||
}
|
||||
if (totalPremium >= config.spikeMinPremium * 2) {
|
||||
if (activity.totalPremium >= config.spikeMinPremium * 2) {
|
||||
confidence += 0.15;
|
||||
}
|
||||
if (count >= 3) {
|
||||
if (activity.count >= 3) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
if (passesZ) {
|
||||
if (activity.passesZ) {
|
||||
confidence += 0.1;
|
||||
if (premiumZ >= config.spikeMinPremiumZ + 1 || sizeZ >= config.spikeMinSizeZ + 1) {
|
||||
if (
|
||||
activity.premiumZ >= config.spikeMinPremiumZ + 1 ||
|
||||
activity.sizeZ >= config.spikeMinSizeZ + 1
|
||||
) {
|
||||
confidence += 0.05;
|
||||
}
|
||||
}
|
||||
|
|
@ -174,20 +302,365 @@ const buildSpikeHit = (packet: FlowPacket, config: ClassifierConfig): Classifier
|
|||
const aggressor = applyAggressorAdjustment(confidence, coverage, aggressiveRatio, config);
|
||||
confidence = clamp(aggressor.confidence, 0, 0.9);
|
||||
|
||||
const baselineNote =
|
||||
premiumBaselineReady || sizeBaselineReady
|
||||
? `Baseline z-scores: premium ${premiumZ.toFixed(2)}, size ${sizeZ.toFixed(2)}.`
|
||||
: "Baseline z-scores unavailable.";
|
||||
|
||||
return {
|
||||
classifier_id: "unusual_contract_spike",
|
||||
confidence,
|
||||
direction: "neutral",
|
||||
explanations: [
|
||||
`Unusual contract spike: ${count} prints in ${Math.round(windowMs)}ms for ${packet.features.option_contract_id ?? packet.id}.`,
|
||||
`Premium ${formatUsd(totalPremium)} across ${Math.round(totalSize)} contracts.`,
|
||||
`Unusual contract spike: ${activity.count} prints in ${Math.round(activity.windowMs)}ms for ${packet.features.option_contract_id ?? packet.id}.`,
|
||||
`Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`,
|
||||
`Thresholds: >=${config.spikeMinSize} contracts and >=${formatUsd(config.spikeMinPremium)} premium or z>=${config.spikeMinPremiumZ.toFixed(1)}.`,
|
||||
baselineNote,
|
||||
activity.baselineNote,
|
||||
aggressor.note
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const buildOverwriteHit = (
|
||||
packet: FlowPacket,
|
||||
contract: ParsedContract,
|
||||
config: ClassifierConfig
|
||||
): ClassifierHit | null => {
|
||||
if (contract.right !== "C") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activity = getLargeActivity(packet, config);
|
||||
if (!activity.passesAbsolute && !activity.passesZ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { coverage, aggressiveSellRatio } = getAggressorContext(packet);
|
||||
let confidence = 0.45;
|
||||
if (activity.totalPremium >= config.spikeMinPremium * 2) {
|
||||
confidence += 0.15;
|
||||
}
|
||||
if (activity.totalSize >= config.spikeMinSize * 2) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
if (activity.count >= 3) {
|
||||
confidence += 0.05;
|
||||
}
|
||||
if (activity.passesZ) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
|
||||
const aggressor = applySideAggressorAdjustment(
|
||||
confidence,
|
||||
coverage,
|
||||
aggressiveSellRatio,
|
||||
config,
|
||||
"sell-side"
|
||||
);
|
||||
confidence = clamp(aggressor.confidence, 0, 0.9);
|
||||
|
||||
return {
|
||||
classifier_id: "large_call_sell_overwrite",
|
||||
confidence,
|
||||
direction: "bearish",
|
||||
explanations: [
|
||||
`Likely call overwrite: ${activity.count} prints in ${Math.round(activity.windowMs)}ms for ${packet.features.option_contract_id ?? packet.id}.`,
|
||||
`Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`,
|
||||
`Thresholds: >=${config.spikeMinSize} contracts and >=${formatUsd(config.spikeMinPremium)} premium or z>=${config.spikeMinPremiumZ.toFixed(1)}.`,
|
||||
"Direction inferred from sell-side aggressor mix.",
|
||||
activity.baselineNote,
|
||||
aggressor.note
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const buildPutWriteHit = (
|
||||
packet: FlowPacket,
|
||||
contract: ParsedContract,
|
||||
config: ClassifierConfig
|
||||
): ClassifierHit | null => {
|
||||
if (contract.right !== "P") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activity = getLargeActivity(packet, config);
|
||||
if (!activity.passesAbsolute && !activity.passesZ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { coverage, aggressiveSellRatio } = getAggressorContext(packet);
|
||||
let confidence = 0.45;
|
||||
if (activity.totalPremium >= config.spikeMinPremium * 2) {
|
||||
confidence += 0.15;
|
||||
}
|
||||
if (activity.totalSize >= config.spikeMinSize * 2) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
if (activity.count >= 3) {
|
||||
confidence += 0.05;
|
||||
}
|
||||
if (activity.passesZ) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
|
||||
const aggressor = applySideAggressorAdjustment(
|
||||
confidence,
|
||||
coverage,
|
||||
aggressiveSellRatio,
|
||||
config,
|
||||
"sell-side"
|
||||
);
|
||||
confidence = clamp(aggressor.confidence, 0, 0.9);
|
||||
|
||||
return {
|
||||
classifier_id: "large_put_sell_write",
|
||||
confidence,
|
||||
direction: "bullish",
|
||||
explanations: [
|
||||
`Likely put write: ${activity.count} prints in ${Math.round(activity.windowMs)}ms for ${packet.features.option_contract_id ?? packet.id}.`,
|
||||
`Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`,
|
||||
`Thresholds: >=${config.spikeMinSize} contracts and >=${formatUsd(config.spikeMinPremium)} premium or z>=${config.spikeMinPremiumZ.toFixed(1)}.`,
|
||||
"Direction inferred from sell-side aggressor mix.",
|
||||
activity.baselineNote,
|
||||
aggressor.note
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const buildStraddleStrangleHit = (
|
||||
packet: FlowPacket,
|
||||
config: ClassifierConfig
|
||||
): ClassifierHit | null => {
|
||||
const structureType = getStringFeature(packet, "structure_type");
|
||||
if (structureType !== "straddle" && structureType !== "strangle") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activity = getLargeActivity(packet, config);
|
||||
const { coverage, aggressiveBuyRatio, aggressiveSellRatio, aggressiveRatio } =
|
||||
getAggressorContext(packet);
|
||||
const structureLegs = getNumberFeature(packet, "structure_legs");
|
||||
const structureStrikes = getNumberFeature(packet, "structure_strikes");
|
||||
const strikeSpan = getNumberFeature(packet, "structure_strike_span");
|
||||
|
||||
let confidence = 0.45;
|
||||
if (activity.totalPremium >= config.spikeMinPremium) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
if (activity.totalSize >= config.spikeMinSize) {
|
||||
confidence += 0.05;
|
||||
}
|
||||
if (structureLegs >= 4) {
|
||||
confidence += 0.05;
|
||||
}
|
||||
|
||||
const aggressor = applyAggressorAdjustment(confidence, coverage, aggressiveRatio, config);
|
||||
confidence = clamp(aggressor.confidence, 0, 0.85);
|
||||
|
||||
let volBias = "mixed aggressor skew";
|
||||
if (aggressiveBuyRatio >= aggressiveSellRatio + 0.1) {
|
||||
volBias = "buy-side skew suggests long volatility";
|
||||
} else if (aggressiveSellRatio >= aggressiveBuyRatio + 0.1) {
|
||||
volBias = "sell-side skew suggests short volatility";
|
||||
}
|
||||
|
||||
const skewNote = `Aggressor skew: buy ${formatPct(aggressiveBuyRatio)}, sell ${formatPct(
|
||||
aggressiveSellRatio
|
||||
)}; ${volBias}.`;
|
||||
|
||||
return {
|
||||
classifier_id: structureType === "straddle" ? "straddle" : "strangle",
|
||||
confidence,
|
||||
direction: "neutral",
|
||||
explanations: [
|
||||
`Likely ${structureType}: ${structureLegs} legs across ${structureStrikes} strikes (span ${strikeSpan}).`,
|
||||
`Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`,
|
||||
skewNote,
|
||||
aggressor.note
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const buildVerticalSpreadHit = (
|
||||
packet: FlowPacket,
|
||||
config: ClassifierConfig
|
||||
): ClassifierHit | null => {
|
||||
const structureType = getStringFeature(packet, "structure_type");
|
||||
if (structureType !== "vertical") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const structureRights = getStringFeature(packet, "structure_rights");
|
||||
if (structureRights !== "C" && structureRights !== "P") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activity = getLargeActivity(packet, config);
|
||||
const { coverage, aggressiveBuyRatio, aggressiveSellRatio } = getAggressorContext(packet);
|
||||
const structureLegs = getNumberFeature(packet, "structure_legs");
|
||||
const structureStrikes = getNumberFeature(packet, "structure_strikes");
|
||||
const strikeSpan = getNumberFeature(packet, "structure_strike_span");
|
||||
|
||||
let confidence = 0.5;
|
||||
if (activity.totalPremium >= config.spikeMinPremium) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
if (activity.totalSize >= config.spikeMinSize) {
|
||||
confidence += 0.05;
|
||||
}
|
||||
if (structureLegs >= 3) {
|
||||
confidence += 0.05;
|
||||
}
|
||||
|
||||
let direction: "bullish" | "bearish" | "neutral" = "neutral";
|
||||
let biasNote = "Debit/credit bias unclear (insufficient aggressor data).";
|
||||
let aggressorNote = "Aggressor mix unavailable (no NBBO coverage).";
|
||||
const hasAggressor = coverage > 0 && aggressiveBuyRatio + aggressiveSellRatio > 0;
|
||||
if (hasAggressor) {
|
||||
const buyDominant = aggressiveBuyRatio >= aggressiveSellRatio;
|
||||
const dominantRatio = buyDominant ? aggressiveBuyRatio : aggressiveSellRatio;
|
||||
const label = buyDominant ? "buy-side" : "sell-side";
|
||||
const aggressor = applySideAggressorAdjustment(
|
||||
confidence,
|
||||
coverage,
|
||||
dominantRatio,
|
||||
config,
|
||||
label
|
||||
);
|
||||
confidence = aggressor.confidence;
|
||||
aggressorNote = aggressor.note;
|
||||
|
||||
const spreadBias = buyDominant ? "debit" : "credit";
|
||||
biasNote = `Aggressor skew: buy ${formatPct(aggressiveBuyRatio)}, sell ${formatPct(
|
||||
aggressiveSellRatio
|
||||
)}; suggests ${spreadBias} ${structureRights === "C" ? "call" : "put"} vertical.`;
|
||||
|
||||
if (structureRights === "C") {
|
||||
direction = buyDominant ? "bullish" : "bearish";
|
||||
} else {
|
||||
direction = buyDominant ? "bearish" : "bullish";
|
||||
}
|
||||
} else {
|
||||
confidence -= 0.1;
|
||||
}
|
||||
|
||||
confidence = clamp(confidence, 0, 0.85);
|
||||
|
||||
return {
|
||||
classifier_id: "vertical_spread",
|
||||
confidence,
|
||||
direction,
|
||||
explanations: [
|
||||
`Likely vertical spread: ${structureLegs} legs across ${structureStrikes} strikes (span ${strikeSpan}).`,
|
||||
`Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`,
|
||||
biasNote,
|
||||
aggressorNote,
|
||||
"Direction inferred from debit/credit bias."
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const buildLadderHit = (
|
||||
packet: FlowPacket,
|
||||
config: ClassifierConfig
|
||||
): ClassifierHit | null => {
|
||||
const structureType = getStringFeature(packet, "structure_type");
|
||||
if (structureType !== "ladder") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activity = getLargeActivity(packet, config);
|
||||
const { coverage, aggressiveRatio } = getAggressorContext(packet);
|
||||
const structureRights = getStringFeature(packet, "structure_rights");
|
||||
const structureLegs = getNumberFeature(packet, "structure_legs");
|
||||
const structureStrikes = getNumberFeature(packet, "structure_strikes");
|
||||
const strikeSpan = getNumberFeature(packet, "structure_strike_span");
|
||||
|
||||
const qualifies =
|
||||
activity.totalPremium >= config.spikeMinPremium ||
|
||||
activity.totalSize >= config.spikeMinSize ||
|
||||
activity.passesZ;
|
||||
if (!qualifies) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let confidence = 0.45;
|
||||
if (activity.totalPremium >= config.spikeMinPremium * 2) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
if (activity.totalSize >= config.spikeMinSize * 2) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
if (structureStrikes >= 4) {
|
||||
confidence += 0.05;
|
||||
}
|
||||
if (activity.passesZ) {
|
||||
confidence += 0.05;
|
||||
}
|
||||
|
||||
const aggressor = applyAggressorAdjustment(confidence, coverage, aggressiveRatio, config);
|
||||
confidence = clamp(aggressor.confidence, 0, 0.85);
|
||||
|
||||
let direction: "bullish" | "bearish" | "neutral" = "neutral";
|
||||
if (structureRights === "C") {
|
||||
direction = "bullish";
|
||||
} else if (structureRights === "P") {
|
||||
direction = "bearish";
|
||||
}
|
||||
|
||||
return {
|
||||
classifier_id: "ladder_accumulation",
|
||||
confidence,
|
||||
direction,
|
||||
explanations: [
|
||||
`Likely multi-strike ladder accumulation: ${structureLegs} legs across ${structureStrikes} strikes (span ${strikeSpan}).`,
|
||||
`Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`,
|
||||
`Thresholds: ladder structure plus >=${config.spikeMinSize} contracts or >=${formatUsd(config.spikeMinPremium)} premium.`,
|
||||
"Direction inferred from call/put ladder.",
|
||||
activity.baselineNote,
|
||||
aggressor.note
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const buildFarDatedHit = (
|
||||
packet: FlowPacket,
|
||||
contract: ParsedContract,
|
||||
config: ClassifierConfig
|
||||
): ClassifierHit | null => {
|
||||
const dteDays = getDteDays(packet, contract);
|
||||
if (!dteDays || dteDays < 60) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activity = getLargeActivity(packet, config);
|
||||
if (!activity.passesAbsolute && !activity.passesZ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { coverage, aggressiveRatio } = getAggressorContext(packet);
|
||||
let confidence = 0.5;
|
||||
if (dteDays >= 90) {
|
||||
confidence += 0.05;
|
||||
}
|
||||
if (activity.totalPremium >= config.spikeMinPremium * 2) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
if (activity.totalSize >= config.spikeMinSize * 2) {
|
||||
confidence += 0.05;
|
||||
}
|
||||
if (activity.passesZ) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
|
||||
const aggressor = applyAggressorAdjustment(confidence, coverage, aggressiveRatio, config);
|
||||
confidence = clamp(aggressor.confidence, 0, 0.85);
|
||||
|
||||
return {
|
||||
classifier_id: "far_dated_conviction",
|
||||
confidence,
|
||||
direction: contract.right === "C" ? "bullish" : "bearish",
|
||||
explanations: [
|
||||
`Likely far-dated ${contract.right === "C" ? "call" : "put"} positioning: ${dteDays} DTE for ${packet.features.option_contract_id ?? packet.id}.`,
|
||||
`Premium ${formatUsd(activity.totalPremium)} across ${Math.round(activity.totalSize)} contracts.`,
|
||||
`Thresholds: DTE >=60 and >=${config.spikeMinSize} contracts or >=${formatUsd(config.spikeMinPremium)} premium (or z-scores).`,
|
||||
"Direction inferred from call/put right.",
|
||||
activity.baselineNote,
|
||||
aggressor.note
|
||||
]
|
||||
};
|
||||
|
|
@ -222,5 +695,37 @@ export const evaluateClassifiers = (
|
|||
hits.push(spikeHit);
|
||||
}
|
||||
|
||||
if (contract) {
|
||||
const overwriteHit = buildOverwriteHit(packet, contract, config);
|
||||
if (overwriteHit) {
|
||||
hits.push(overwriteHit);
|
||||
}
|
||||
|
||||
const putWriteHit = buildPutWriteHit(packet, contract, config);
|
||||
if (putWriteHit) {
|
||||
hits.push(putWriteHit);
|
||||
}
|
||||
|
||||
const farDatedHit = buildFarDatedHit(packet, contract, config);
|
||||
if (farDatedHit) {
|
||||
hits.push(farDatedHit);
|
||||
}
|
||||
}
|
||||
|
||||
const structureHit = buildStraddleStrangleHit(packet, config);
|
||||
if (structureHit) {
|
||||
hits.push(structureHit);
|
||||
}
|
||||
|
||||
const verticalHit = buildVerticalSpreadHit(packet, config);
|
||||
if (verticalHit) {
|
||||
hits.push(verticalHit);
|
||||
}
|
||||
|
||||
const ladderHit = buildLadderHit(packet, config);
|
||||
if (ladderHit) {
|
||||
hits.push(ladderHit);
|
||||
}
|
||||
|
||||
return hits;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ const baseConfig: ClassifierConfig = {
|
|||
minAggressorRatio: 0.55
|
||||
};
|
||||
|
||||
const DEFAULT_TS = Date.UTC(2024, 0, 2);
|
||||
|
||||
const buildPacket = (
|
||||
overrides: Record<string, string | number | boolean>
|
||||
): FlowPacket => {
|
||||
return {
|
||||
source_ts: 1,
|
||||
ingest_ts: 1,
|
||||
source_ts: DEFAULT_TS,
|
||||
ingest_ts: DEFAULT_TS,
|
||||
seq: 1,
|
||||
trace_id: "trace",
|
||||
id: "packet",
|
||||
|
|
@ -32,6 +34,8 @@ const buildPacket = (
|
|||
total_size: 20,
|
||||
first_price: 1,
|
||||
last_price: 1.01,
|
||||
start_ts: DEFAULT_TS - 500,
|
||||
end_ts: DEFAULT_TS,
|
||||
window_ms: 500,
|
||||
...overrides
|
||||
},
|
||||
|
|
@ -100,3 +104,87 @@ describe("classifier z-score behavior", () => {
|
|||
expect((highHit?.confidence ?? 0)).toBeGreaterThan(lowHit?.confidence ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
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("vertical spread infers direction from aggressor skew", () => {
|
||||
const packet = buildPacket({
|
||||
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({
|
||||
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("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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue