Add hosted synthetic control plane
This commit is contained in:
parent
af04875107
commit
8dcbcd2201
21 changed files with 3695 additions and 772 deletions
|
|
@ -1,7 +1,10 @@
|
|||
import {
|
||||
SP500_SYMBOLS,
|
||||
getSyntheticSessionState,
|
||||
getSyntheticUnderlyingState,
|
||||
type EquityPrint,
|
||||
type EquityQuote,
|
||||
type SyntheticControlState,
|
||||
type SyntheticMarketMode
|
||||
} from "@islandflow/types";
|
||||
import type { EquityIngestAdapter, EquityIngestHandlers } from "./types";
|
||||
|
|
@ -9,34 +12,14 @@ import type { EquityIngestAdapter, EquityIngestHandlers } from "./types";
|
|||
type SyntheticEquitiesAdapterConfig = {
|
||||
emitIntervalMs: number;
|
||||
mode: SyntheticMarketMode;
|
||||
getControl: () => SyntheticControlState;
|
||||
};
|
||||
|
||||
const EXCHANGES = ["NYSE", "NASDAQ", "ARCA", "BATS", "IEX", "TEST"];
|
||||
const EXCHANGES = ["NYSE", "NASDAQ", "ARCA", "BATS", "IEX", "MEMX"];
|
||||
const DARK_EXCHANGE = "OTC";
|
||||
|
||||
type PricePlacement = "MID" | "A" | "AA" | "B" | "BB";
|
||||
type DarkScenario = "block" | "buy" | "sell";
|
||||
|
||||
const DARK_SEQUENCE: DarkScenario[] = [
|
||||
"block",
|
||||
"buy",
|
||||
"buy",
|
||||
"buy",
|
||||
"buy",
|
||||
"sell",
|
||||
"sell",
|
||||
"sell",
|
||||
"sell"
|
||||
];
|
||||
const SYNTHETIC_SYMBOLS = ["SPY", ...(SP500_SYMBOLS as readonly string[])];
|
||||
|
||||
const hashSymbol = (value: string): number => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
type PricePlacement = "MID" | "A" | "AA" | "B" | "BB";
|
||||
|
||||
const buildSyntheticPrint = (
|
||||
seq: number,
|
||||
|
|
@ -46,20 +29,18 @@ const buildSyntheticPrint = (
|
|||
size: number,
|
||||
exchange: string,
|
||||
offExchangeFlag: boolean
|
||||
): EquityPrint => {
|
||||
return {
|
||||
source_ts: now,
|
||||
ingest_ts: now,
|
||||
seq,
|
||||
trace_id: `synthetic-equities-${seq}`,
|
||||
ts: now,
|
||||
underlying_id: symbol,
|
||||
price,
|
||||
size,
|
||||
exchange,
|
||||
offExchangeFlag
|
||||
};
|
||||
};
|
||||
): 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,
|
||||
|
|
@ -67,32 +48,18 @@ const buildSyntheticQuote = (
|
|||
symbol: string,
|
||||
bid: number,
|
||||
ask: number
|
||||
): EquityQuote => {
|
||||
return {
|
||||
source_ts: now,
|
||||
ingest_ts: now,
|
||||
seq,
|
||||
trace_id: `synthetic-equity-quote-${seq}`,
|
||||
ts: now,
|
||||
underlying_id: symbol,
|
||||
bid,
|
||||
ask
|
||||
};
|
||||
};
|
||||
): 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 => {
|
||||
return Number(value.toFixed(2));
|
||||
};
|
||||
|
||||
const buildQuoteFromMid = (mid: number) => {
|
||||
const spread = Math.max(0.05, Number((mid * 0.002).toFixed(2)));
|
||||
const half = spread / 2;
|
||||
const bid = formatPrice(Math.max(0.01, mid - half));
|
||||
const ask = formatPrice(Math.max(bid + 0.01, mid + half));
|
||||
const epsilon = Math.max(0.01, spread * 0.05);
|
||||
|
||||
return { bid, ask, spread, epsilon };
|
||||
};
|
||||
const formatPrice = (value: number): number => Number(value.toFixed(2));
|
||||
|
||||
const priceForPlacement = (
|
||||
mid: number,
|
||||
|
|
@ -100,7 +67,6 @@ const priceForPlacement = (
|
|||
placement: PricePlacement
|
||||
): number => {
|
||||
const { bid, ask, epsilon } = quote;
|
||||
|
||||
let price = mid;
|
||||
switch (placement) {
|
||||
case "AA":
|
||||
|
|
@ -120,44 +86,83 @@ const priceForPlacement = (
|
|||
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 profile =
|
||||
const throughput =
|
||||
config.mode === "firehose"
|
||||
? {
|
||||
batchSize: 10,
|
||||
darkEvery: true,
|
||||
offExchangeMod: 2,
|
||||
litSizeBase: 40,
|
||||
litSizeRange: 1400
|
||||
}
|
||||
? { batchSize: 10, litSizeBase: 48, litSizeRange: 1800, darkSizeBase: 2800 }
|
||||
: config.mode === "active"
|
||||
? {
|
||||
batchSize: 5,
|
||||
darkEvery: true,
|
||||
offExchangeMod: 4,
|
||||
litSizeBase: 20,
|
||||
litSizeRange: 900
|
||||
}
|
||||
: {
|
||||
batchSize: 2,
|
||||
darkEvery: false,
|
||||
offExchangeMod: 8,
|
||||
litSizeBase: 10,
|
||||
litSizeRange: 300
|
||||
};
|
||||
? { 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 darkStep = 0;
|
||||
let darkSymbolIndex = 0;
|
||||
let symbolCursor = 0;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let stopped = false;
|
||||
|
||||
|
|
@ -167,84 +172,113 @@ export const createSyntheticEquitiesAdapter = (
|
|||
}
|
||||
|
||||
const now = Date.now();
|
||||
const batchSize = profile.batchSize;
|
||||
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";
|
||||
|
||||
const darkSymbol = SYNTHETIC_SYMBOLS[darkSymbolIndex % SYNTHETIC_SYMBOLS.length];
|
||||
const darkHash = hashSymbol(darkSymbol);
|
||||
const darkBase = 25 + (darkHash % 475);
|
||||
const darkDrift = ((darkStep % 24) - 12) * 0.08;
|
||||
const darkMid = formatPrice(darkBase + darkDrift);
|
||||
const darkQuote = buildQuoteFromMid(darkMid);
|
||||
const scenario = DARK_SEQUENCE[darkStep % DARK_SEQUENCE.length];
|
||||
const darkTs = now;
|
||||
|
||||
if (profile.darkEvery) {
|
||||
if (handlers.onQuote) {
|
||||
quoteSeq += 1;
|
||||
const quoteEvent = buildSyntheticQuote(
|
||||
quoteSeq,
|
||||
darkTs - 2,
|
||||
darkSymbol,
|
||||
darkQuote.bid,
|
||||
darkQuote.ask
|
||||
);
|
||||
void handlers.onQuote(quoteEvent);
|
||||
}
|
||||
|
||||
seq += 1;
|
||||
let darkPlacement: PricePlacement = "MID";
|
||||
let darkSize = config.mode === "firehose" ? 4000 : 2600;
|
||||
if (scenario === "buy") {
|
||||
darkPlacement = darkStep % 2 === 0 ? "A" : "AA";
|
||||
darkSize = config.mode === "firehose" ? 1500 : 800;
|
||||
} else if (scenario === "sell") {
|
||||
darkPlacement = darkStep % 2 === 0 ? "B" : "BB";
|
||||
darkSize = config.mode === "firehose" ? 1500 : 800;
|
||||
}
|
||||
const darkPrice = priceForPlacement(darkMid, darkQuote, darkPlacement);
|
||||
const darkPrint = buildSyntheticPrint(
|
||||
seq,
|
||||
darkTs,
|
||||
darkSymbol,
|
||||
darkPrice,
|
||||
darkSize,
|
||||
DARK_EXCHANGE,
|
||||
true
|
||||
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)
|
||||
)
|
||||
);
|
||||
void handlers.onTrade(darkPrint);
|
||||
|
||||
darkStep += 1;
|
||||
if (darkStep >= DARK_SEQUENCE.length) {
|
||||
darkStep = 0;
|
||||
darkSymbolIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < batchSize; i += 1) {
|
||||
seq += 1;
|
||||
const symbol = SYNTHETIC_SYMBOLS[(seq + i) % SYNTHETIC_SYMBOLS.length];
|
||||
const symbolHash = hashSymbol(symbol);
|
||||
const basePrice = 25 + (symbolHash % 475);
|
||||
const mid = formatPrice(basePrice + ((seq % 40) - 20) * 0.05);
|
||||
const quote = buildQuoteFromMid(mid);
|
||||
const placement: PricePlacement =
|
||||
seq % 11 === 0 ? "A" : seq % 13 === 0 ? "B" : "MID";
|
||||
const price = priceForPlacement(mid, quote, placement);
|
||||
const size = profile.litSizeBase + (seq % profile.litSizeRange);
|
||||
const exchange = EXCHANGES[(seq + symbolHash) % EXCHANGES.length];
|
||||
const offExchangeFlag = (seq + i) % profile.offExchangeMod === 0;
|
||||
const eventTs = now + i * 4;
|
||||
|
||||
if (handlers.onQuote) {
|
||||
quoteSeq += 1;
|
||||
const quoteEventTs = eventTs - 2;
|
||||
const quoteEvent = buildSyntheticQuote(quoteSeq, quoteEventTs, symbol, quote.bid, quote.ask);
|
||||
void handlers.onQuote(quoteEvent);
|
||||
void handlers.onQuote(
|
||||
buildSyntheticQuote(
|
||||
quoteSeq,
|
||||
now - 2,
|
||||
darkSymbol,
|
||||
darkQuote.bid,
|
||||
darkQuote.ask
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const print = buildSyntheticPrint(seq, eventTs, symbol, price, size, exchange, offExchangeFlag);
|
||||
void handlers.onTrade(print);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import {
|
|||
STREAM_EQUITY_PRINTS,
|
||||
STREAM_EQUITY_QUOTES,
|
||||
connectJetStreamWithRetry,
|
||||
ensureSyntheticControlState,
|
||||
ensureKnownStreams,
|
||||
openSyntheticControlKv,
|
||||
watchSyntheticControlState,
|
||||
publishJson
|
||||
} from "@islandflow/bus";
|
||||
import {
|
||||
|
|
@ -19,9 +22,11 @@ import {
|
|||
import {
|
||||
EquityPrintSchema,
|
||||
EquityQuoteSchema,
|
||||
DEFAULT_SYNTHETIC_CONTROL_STATE,
|
||||
resolveSyntheticMarketModes,
|
||||
type EquityPrint,
|
||||
type EquityQuote
|
||||
type EquityQuote,
|
||||
type SyntheticControlState
|
||||
} from "@islandflow/types";
|
||||
import { createAlpacaEquitiesAdapter } from "./adapters/alpaca";
|
||||
import { createSyntheticEquitiesAdapter } from "./adapters/synthetic";
|
||||
|
|
@ -157,11 +162,15 @@ const parseSymbolList = (value: string): string[] => {
|
|||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const selectAdapter = (name: string): EquityIngestAdapter => {
|
||||
const selectAdapter = (
|
||||
name: string,
|
||||
getSyntheticControl: () => SyntheticControlState
|
||||
): EquityIngestAdapter => {
|
||||
if (name === "synthetic") {
|
||||
return createSyntheticEquitiesAdapter({
|
||||
emitIntervalMs: env.EMIT_INTERVAL_MS,
|
||||
mode: syntheticModes.equities
|
||||
mode: syntheticModes.equities,
|
||||
getControl: getSyntheticControl
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -196,6 +205,24 @@ const run = async () => {
|
|||
|
||||
await ensureKnownStreams(jsm, [STREAM_EQUITY_PRINTS, STREAM_EQUITY_QUOTES], { logger });
|
||||
|
||||
let syntheticControl = DEFAULT_SYNTHETIC_CONTROL_STATE;
|
||||
let stopSyntheticControlWatch = async () => {};
|
||||
if (env.EQUITIES_INGEST_ADAPTER === "synthetic") {
|
||||
const syntheticControlKv = await openSyntheticControlKv(js);
|
||||
syntheticControl = await ensureSyntheticControlState(syntheticControlKv);
|
||||
stopSyntheticControlWatch = await watchSyntheticControlState(
|
||||
syntheticControlKv,
|
||||
(nextControl) => {
|
||||
syntheticControl = nextControl;
|
||||
},
|
||||
(error) => {
|
||||
logger.warn("synthetic control watch failed", {
|
||||
error: getErrorMessage(error)
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const clickhouse = createClickHouseClient({
|
||||
url: env.CLICKHOUSE_URL,
|
||||
database: env.CLICKHOUSE_DATABASE
|
||||
|
|
@ -206,7 +233,10 @@ const run = async () => {
|
|||
await ensureEquityQuotesTable(clickhouse);
|
||||
});
|
||||
|
||||
const adapter = selectAdapter(env.EQUITIES_INGEST_ADAPTER);
|
||||
const adapter = selectAdapter(
|
||||
env.EQUITIES_INGEST_ADAPTER,
|
||||
() => syntheticControl
|
||||
);
|
||||
logger.info("ingest adapter selected", { adapter: adapter.name });
|
||||
const allowPublish = buildThrottle(env.TESTING_MODE, env.TESTING_THROTTLE_MS);
|
||||
const allowQuotePublish = buildThrottle(env.TESTING_MODE, env.TESTING_THROTTLE_MS);
|
||||
|
|
@ -274,6 +304,7 @@ const run = async () => {
|
|||
state.shuttingDown = true;
|
||||
state.shutdownPromise = (async () => {
|
||||
logger.info("service stopping", { signal });
|
||||
await stopSyntheticControlWatch();
|
||||
await stopAdapter();
|
||||
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue