294 lines
7.9 KiB
TypeScript
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);
|
|
}
|
|
};
|
|
}
|
|
};
|
|
};
|