islandflow/services/ingest-equities/src/adapters/synthetic.ts

294 lines
7.9 KiB
TypeScript

import {
SP500_SYMBOLS,
getSyntheticSessionState,
getSyntheticUnderlyingState,
type EquityPrint,
type EquityQuote,
type SyntheticControlState,
type SyntheticMarketMode
} from "@islandflow/types";
import type { EquityIngestAdapter, EquityIngestHandlers } from "./types";
type SyntheticEquitiesAdapterConfig = {
emitIntervalMs: number;
mode: SyntheticMarketMode;
getControl: () => SyntheticControlState;
};
const EXCHANGES = ["NYSE", "NASDAQ", "ARCA", "BATS", "IEX", "MEMX"];
const DARK_EXCHANGE = "OTC";
const SYNTHETIC_SYMBOLS = ["SPY", ...(SP500_SYMBOLS as readonly string[])];
type PricePlacement = "MID" | "A" | "AA" | "B" | "BB";
const buildSyntheticPrint = (
seq: number,
now: number,
symbol: string,
price: number,
size: number,
exchange: string,
offExchangeFlag: boolean
): EquityPrint => ({
source_ts: now,
ingest_ts: now,
seq,
trace_id: `synthetic-equities-${seq}`,
ts: now,
underlying_id: symbol,
price,
size,
exchange,
offExchangeFlag
});
const buildSyntheticQuote = (
seq: number,
now: number,
symbol: string,
bid: number,
ask: number
): EquityQuote => ({
source_ts: now,
ingest_ts: now,
seq,
trace_id: `synthetic-equity-quote-${seq}`,
ts: now,
underlying_id: symbol,
bid,
ask
});
const formatPrice = (value: number): number => Number(value.toFixed(2));
const priceForPlacement = (
mid: number,
quote: { bid: number; ask: number; epsilon: number },
placement: PricePlacement
): number => {
const { bid, ask, epsilon } = quote;
let price = mid;
switch (placement) {
case "AA":
price = ask + epsilon * 1.5;
break;
case "A":
price = ask;
break;
case "BB":
price = bid - epsilon * 1.5;
break;
case "B":
price = bid;
break;
case "MID":
default:
price = mid;
break;
}
return formatPrice(Math.max(0.01, price));
};
const buildQuoteContext = (
symbol: string,
now: number,
control: SyntheticControlState
) => {
const session = getSyntheticSessionState(now, control);
const state = getSyntheticUnderlyingState(symbol, now, control, session);
return {
session,
state,
mid: state.mid,
bid: formatPrice(state.bid),
ask: formatPrice(state.ask),
spread: state.spread,
epsilon: Math.max(0.01, state.spread * 0.08)
};
};
const pickPrimaryPlacement = (
driftBps: number,
regime: ReturnType<typeof getSyntheticSessionState>["regime"],
seq: number
): PricePlacement => {
if (regime === "dealer_gamma") {
return seq % 4 === 0 ? "A" : seq % 3 === 0 ? "B" : "MID";
}
if (regime === "arb_calm" || regime === "mean_revert") {
return seq % 11 === 0 ? "A" : seq % 13 === 0 ? "B" : "MID";
}
if (regime === "event_ramp" || regime === "retail_chase") {
if (driftBps >= 0) {
return seq % 3 === 0 ? "AA" : "A";
}
return seq % 3 === 0 ? "BB" : "B";
}
if (driftBps >= 0) {
return seq % 5 === 0 ? "A" : "MID";
}
return seq % 5 === 0 ? "B" : "MID";
};
const pickDarkPlacement = (
driftBps: number,
regime: ReturnType<typeof getSyntheticSessionState>["regime"],
seq: number
): PricePlacement => {
if (regime === "dealer_gamma") {
return seq % 2 === 0 ? "A" : "B";
}
if (regime === "arb_calm" || regime === "mean_revert") {
return "MID";
}
if (regime === "event_ramp" || regime === "retail_chase") {
return driftBps >= 0 ? (seq % 2 === 0 ? "A" : "AA") : seq % 2 === 0 ? "B" : "BB";
}
return driftBps >= 0 ? "A" : "B";
};
export const createSyntheticEquitiesAdapter = (
config: SyntheticEquitiesAdapterConfig
): EquityIngestAdapter => {
const throughput =
config.mode === "firehose"
? { batchSize: 10, litSizeBase: 48, litSizeRange: 1800, darkSizeBase: 2800 }
: config.mode === "active"
? { batchSize: 5, litSizeBase: 22, litSizeRange: 980, darkSizeBase: 1800 }
: { batchSize: 2, litSizeBase: 12, litSizeRange: 340, darkSizeBase: 900 };
return {
name: "synthetic",
start: (handlers: EquityIngestHandlers) => {
let seq = 0;
let quoteSeq = 0;
let symbolCursor = 0;
let timer: ReturnType<typeof setInterval> | null = null;
let stopped = false;
const emit = () => {
if (stopped) {
return;
}
const now = Date.now();
const control = config.getControl();
const session = getSyntheticSessionState(now, control);
const focusSymbols =
session.focus_symbols.length > 0 ? session.focus_symbols : SYNTHETIC_SYMBOLS.slice(0, 3);
const focusSet = new Set(focusSymbols);
const allowDark =
config.mode !== "realistic" ||
session.regime === "event_ramp" ||
session.regime === "dealer_gamma" ||
session.regime === "retail_chase";
if (allowDark) {
const darkSymbol = focusSymbols[seq % focusSymbols.length] ?? SYNTHETIC_SYMBOLS[symbolCursor % SYNTHETIC_SYMBOLS.length]!;
const darkQuote = buildQuoteContext(darkSymbol, now, control);
const darkPlacement = pickDarkPlacement(
darkQuote.state.driftBps,
session.regime,
seq + 1
);
const darkBias = darkQuote.state.offExchangeBias;
const darkSize = Math.max(
250,
Math.round(
throughput.darkSizeBase *
(0.65 + darkBias * 0.9 + darkQuote.state.sessionVolatility * 0.2)
)
);
if (handlers.onQuote) {
quoteSeq += 1;
void handlers.onQuote(
buildSyntheticQuote(
quoteSeq,
now - 2,
darkSymbol,
darkQuote.bid,
darkQuote.ask
)
);
}
seq += 1;
void handlers.onTrade(
buildSyntheticPrint(
seq,
now,
darkSymbol,
priceForPlacement(darkQuote.mid, darkQuote, darkPlacement),
darkSize,
DARK_EXCHANGE,
true
)
);
}
for (let i = 0; i < throughput.batchSize; i += 1) {
seq += 1;
const symbol =
i < focusSymbols.length
? focusSymbols[i]!
: SYNTHETIC_SYMBOLS[(symbolCursor + i) % SYNTHETIC_SYMBOLS.length]!;
const eventTs = now + i * 4;
const quote = buildQuoteContext(symbol, eventTs, control);
const clustered = focusSet.has(symbol);
const placement = pickPrimaryPlacement(
quote.state.driftBps,
session.regime,
seq + i
);
const exchange = EXCHANGES[(seq + symbol.charCodeAt(0) + i) % EXCHANGES.length]!;
const baseSize =
throughput.litSizeBase +
((seq + i) % throughput.litSizeRange) +
Math.round(quote.state.sessionVolatility * 140);
const size = clustered
? Math.round(baseSize * (1 + quote.state.clusteringScore * 0.35))
: baseSize;
const offExchangeFlag =
((seq + i * 3) % 10) / 10 < quote.state.offExchangeBias * (clustered ? 1.12 : 0.86);
if (handlers.onQuote) {
quoteSeq += 1;
void handlers.onQuote(
buildSyntheticQuote(
quoteSeq,
eventTs - 2,
symbol,
quote.bid,
quote.ask
)
);
}
void handlers.onTrade(
buildSyntheticPrint(
seq,
eventTs,
symbol,
priceForPlacement(quote.mid, quote, placement),
size,
exchange,
offExchangeFlag
)
);
}
symbolCursor = (symbolCursor + throughput.batchSize) % SYNTHETIC_SYMBOLS.length;
};
timer = setInterval(emit, config.emitIntervalMs);
return () => {
stopped = true;
if (timer) {
clearInterval(timer);
}
};
}
};
};