271 lines
8.5 KiB
TypeScript
271 lines
8.5 KiB
TypeScript
import type {
|
|
FlowPacket,
|
|
SmartMoneyDirection,
|
|
SmartMoneyEvent,
|
|
SmartMoneyProfileId
|
|
} from "@islandflow/types";
|
|
import { buildSmartMoneyEventFromPacket, type SmartMoneyParentEventOptions } from "./parent-events";
|
|
|
|
export type SmartMoneyLabel = {
|
|
event_id: string;
|
|
profile_id: SmartMoneyProfileId | null;
|
|
direction?: Exclude<SmartMoneyDirection, "unknown">;
|
|
realized_return_bps?: number;
|
|
};
|
|
|
|
export type ReplayConsistencyMismatch = {
|
|
event_id: string;
|
|
field: "missing_live" | "missing_batch" | "signature";
|
|
live?: SmartMoneyEventSignature;
|
|
batch?: SmartMoneyEventSignature;
|
|
};
|
|
|
|
export type ReplayConsistencyReport = {
|
|
live_count: number;
|
|
batch_count: number;
|
|
matched_count: number;
|
|
mismatches: ReplayConsistencyMismatch[];
|
|
consistent: boolean;
|
|
};
|
|
|
|
export type SmartMoneyEventSignature = {
|
|
event_id: string;
|
|
primary_profile_id: SmartMoneyProfileId | null;
|
|
primary_direction: SmartMoneyDirection;
|
|
abstained: boolean;
|
|
suppressed_reasons: string[];
|
|
profile_scores: Array<{
|
|
profile_id: SmartMoneyProfileId;
|
|
probability: number;
|
|
confidence_band: SmartMoneyEvent["profile_scores"][number]["confidence_band"];
|
|
direction: SmartMoneyDirection;
|
|
}>;
|
|
};
|
|
|
|
export type CalibrationBucket = {
|
|
min_probability: number;
|
|
max_probability: number;
|
|
count: number;
|
|
average_probability: number;
|
|
accuracy: number | null;
|
|
};
|
|
|
|
export type SmartMoneyEvaluationReport = {
|
|
sample_count: number;
|
|
labeled_count: number;
|
|
emitted_count: number;
|
|
abstained_count: number;
|
|
abstention_rate: number;
|
|
profile_precision: Partial<Record<SmartMoneyProfileId, number | null>>;
|
|
profile_recall: Partial<Record<SmartMoneyProfileId, number | null>>;
|
|
calibration: CalibrationBucket[];
|
|
economic_sanity: {
|
|
directional_count: number;
|
|
direction_hit_rate: number | null;
|
|
average_signed_return_bps: number | null;
|
|
};
|
|
};
|
|
|
|
const PROFILES: SmartMoneyProfileId[] = [
|
|
"institutional_directional",
|
|
"retail_whale",
|
|
"event_driven",
|
|
"vol_seller",
|
|
"arbitrage",
|
|
"hedge_reactive"
|
|
];
|
|
|
|
const directionalSign = (direction: SmartMoneyDirection): number => {
|
|
if (direction === "bullish") {
|
|
return 1;
|
|
}
|
|
if (direction === "bearish") {
|
|
return -1;
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const round = (value: number, digits = 4): number => {
|
|
if (!Number.isFinite(value)) {
|
|
return 0;
|
|
}
|
|
return Number(value.toFixed(digits));
|
|
};
|
|
|
|
export const smartMoneyEventSignature = (event: SmartMoneyEvent): SmartMoneyEventSignature => ({
|
|
event_id: event.event_id,
|
|
primary_profile_id: event.primary_profile_id,
|
|
primary_direction: event.primary_direction,
|
|
abstained: event.abstained,
|
|
suppressed_reasons: [...event.suppressed_reasons].sort(),
|
|
profile_scores: event.profile_scores.map((entry) => ({
|
|
profile_id: entry.profile_id,
|
|
probability: round(entry.probability, 6),
|
|
confidence_band: entry.confidence_band,
|
|
direction: entry.direction
|
|
}))
|
|
});
|
|
|
|
export const buildSmartMoneyEventsForReplay = (
|
|
packets: FlowPacket[],
|
|
optionsByPacketId: Record<string, SmartMoneyParentEventOptions | undefined> = {}
|
|
): SmartMoneyEvent[] => {
|
|
return packets
|
|
.slice()
|
|
.sort((a, b) => a.source_ts - b.source_ts || a.seq - b.seq || a.id.localeCompare(b.id))
|
|
.map((packet) => buildSmartMoneyEventFromPacket(packet, optionsByPacketId[packet.id]));
|
|
};
|
|
|
|
export const compareSmartMoneyReplayOutputs = (
|
|
liveEvents: SmartMoneyEvent[],
|
|
batchEvents: SmartMoneyEvent[]
|
|
): ReplayConsistencyReport => {
|
|
const liveById = new Map(
|
|
liveEvents.map((event) => [event.event_id, smartMoneyEventSignature(event)])
|
|
);
|
|
const batchById = new Map(
|
|
batchEvents.map((event) => [event.event_id, smartMoneyEventSignature(event)])
|
|
);
|
|
const ids = [...new Set([...liveById.keys(), ...batchById.keys()])].sort();
|
|
const mismatches: ReplayConsistencyMismatch[] = [];
|
|
|
|
for (const id of ids) {
|
|
const live = liveById.get(id);
|
|
const batch = batchById.get(id);
|
|
if (!live) {
|
|
mismatches.push({ event_id: id, field: "missing_live", batch });
|
|
continue;
|
|
}
|
|
if (!batch) {
|
|
mismatches.push({ event_id: id, field: "missing_batch", live });
|
|
continue;
|
|
}
|
|
if (JSON.stringify(live) !== JSON.stringify(batch)) {
|
|
mismatches.push({ event_id: id, field: "signature", live, batch });
|
|
}
|
|
}
|
|
|
|
return {
|
|
live_count: liveEvents.length,
|
|
batch_count: batchEvents.length,
|
|
matched_count: ids.length - mismatches.length,
|
|
mismatches,
|
|
consistent: mismatches.length === 0
|
|
};
|
|
};
|
|
|
|
export const evaluateSmartMoneyEvents = (
|
|
events: SmartMoneyEvent[],
|
|
labels: SmartMoneyLabel[],
|
|
bucketCount = 5
|
|
): SmartMoneyEvaluationReport => {
|
|
const labelsById = new Map(labels.map((label) => [label.event_id, label]));
|
|
const labeledEvents = events
|
|
.map((event) => ({ event, label: labelsById.get(event.event_id) }))
|
|
.filter((entry): entry is { event: SmartMoneyEvent; label: SmartMoneyLabel } =>
|
|
Boolean(entry.label)
|
|
);
|
|
|
|
const emitted = events.filter((event) => !event.abstained && event.primary_profile_id);
|
|
const profilePrecision: SmartMoneyEvaluationReport["profile_precision"] = {};
|
|
const profileRecall: SmartMoneyEvaluationReport["profile_recall"] = {};
|
|
|
|
for (const profile of PROFILES) {
|
|
const predicted = labeledEvents.filter((entry) => entry.event.primary_profile_id === profile);
|
|
const actual = labeledEvents.filter((entry) => entry.label.profile_id === profile);
|
|
const truePositive = predicted.filter((entry) => entry.label.profile_id === profile).length;
|
|
profilePrecision[profile] =
|
|
predicted.length > 0 ? round(truePositive / predicted.length) : null;
|
|
profileRecall[profile] = actual.length > 0 ? round(truePositive / actual.length) : null;
|
|
}
|
|
|
|
const calibration = buildCalibration(labeledEvents, Math.max(1, Math.floor(bucketCount)));
|
|
const economic = buildEconomicSanity(labeledEvents);
|
|
|
|
return {
|
|
sample_count: events.length,
|
|
labeled_count: labeledEvents.length,
|
|
emitted_count: emitted.length,
|
|
abstained_count: events.filter((event) => event.abstained).length,
|
|
abstention_rate:
|
|
events.length > 0
|
|
? round(events.filter((event) => event.abstained).length / events.length)
|
|
: 0,
|
|
profile_precision: profilePrecision,
|
|
profile_recall: profileRecall,
|
|
calibration,
|
|
economic_sanity: economic
|
|
};
|
|
};
|
|
|
|
const buildCalibration = (
|
|
entries: Array<{ event: SmartMoneyEvent; label: SmartMoneyLabel }>,
|
|
bucketCount: number
|
|
): CalibrationBucket[] => {
|
|
const buckets = Array.from({ length: bucketCount }, (_, index) => ({
|
|
min_probability: round(index / bucketCount),
|
|
max_probability: round((index + 1) / bucketCount),
|
|
probabilities: [] as number[],
|
|
correct: 0
|
|
}));
|
|
|
|
for (const { event, label } of entries) {
|
|
const probability =
|
|
event.profile_scores.find((entry) => entry.profile_id === event.primary_profile_id)
|
|
?.probability ?? 0;
|
|
const index = Math.min(bucketCount - 1, Math.floor(probability * bucketCount));
|
|
buckets[index].probabilities.push(probability);
|
|
if (!event.abstained && event.primary_profile_id === label.profile_id) {
|
|
buckets[index].correct += 1;
|
|
}
|
|
}
|
|
|
|
return buckets.map((bucket) => ({
|
|
min_probability: bucket.min_probability,
|
|
max_probability: bucket.max_probability,
|
|
count: bucket.probabilities.length,
|
|
average_probability:
|
|
bucket.probabilities.length > 0
|
|
? round(
|
|
bucket.probabilities.reduce((sum, value) => sum + value, 0) /
|
|
bucket.probabilities.length
|
|
)
|
|
: 0,
|
|
accuracy:
|
|
bucket.probabilities.length > 0 ? round(bucket.correct / bucket.probabilities.length) : null
|
|
}));
|
|
};
|
|
|
|
const buildEconomicSanity = (
|
|
entries: Array<{ event: SmartMoneyEvent; label: SmartMoneyLabel }>
|
|
): SmartMoneyEvaluationReport["economic_sanity"] => {
|
|
const directional = entries
|
|
.map(({ event, label }) => ({
|
|
sign: directionalSign(event.primary_direction),
|
|
realized: label.realized_return_bps
|
|
}))
|
|
.filter(
|
|
(entry): entry is { sign: number; realized: number } =>
|
|
entry.sign !== 0 && Number.isFinite(entry.realized)
|
|
);
|
|
|
|
if (directional.length === 0) {
|
|
return {
|
|
directional_count: 0,
|
|
direction_hit_rate: null,
|
|
average_signed_return_bps: null
|
|
};
|
|
}
|
|
|
|
const signedReturns = directional.map((entry) => entry.sign * entry.realized);
|
|
return {
|
|
directional_count: directional.length,
|
|
direction_hit_rate: round(
|
|
signedReturns.filter((value) => value > 0).length / directional.length
|
|
),
|
|
average_signed_return_bps: round(
|
|
signedReturns.reduce((sum, value) => sum + value, 0) / signedReturns.length,
|
|
2
|
|
)
|
|
};
|
|
};
|