Restore continuous live tape history
This commit is contained in:
parent
d81b4c0cfb
commit
034d24f8ac
5 changed files with 483 additions and 117 deletions
|
|
@ -112,7 +112,7 @@ import {
|
|||
} from "@islandflow/types";
|
||||
import { createClient } from "redis";
|
||||
import { z } from "zod";
|
||||
import { LIVE_FEED_LOOKBACK_MS, LiveStateManager, shouldFanoutLiveEvent } from "./live";
|
||||
import { LiveStateManager, shouldFanoutLiveEvent } from "./live";
|
||||
|
||||
const service = "api";
|
||||
const logger = createLogger({ service });
|
||||
|
|
@ -617,14 +617,12 @@ const parseLiveOptionPrintFilters = (url: URL): OptionPrintQueryFilters => {
|
|||
return {
|
||||
...storageFilters,
|
||||
underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids"),
|
||||
optionContractId: url.searchParams.get("option_contract_id") ?? undefined,
|
||||
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
|
||||
optionContractId: url.searchParams.get("option_contract_id") ?? undefined
|
||||
};
|
||||
};
|
||||
|
||||
const parseLiveEquityPrintFilters = (url: URL): EquityPrintQueryFilters => ({
|
||||
underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids"),
|
||||
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
|
||||
underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids")
|
||||
});
|
||||
|
||||
const matchesScopedOptionSubscription = (
|
||||
|
|
|
|||
|
|
@ -408,13 +408,7 @@ export class LiveStateManager {
|
|||
const config = this.generic[channel];
|
||||
if (this.redis?.isOpen) {
|
||||
const payloads = await this.redis.lRange(config.redisKey, 0, config.limit - 1);
|
||||
const cached = normalizeGenericItems(
|
||||
channel,
|
||||
parseJsonList(payloads, config.parse).filter((item) =>
|
||||
isWithinLiveFeedLookback(channel, item)
|
||||
),
|
||||
config
|
||||
);
|
||||
const cached = normalizeGenericItems(channel, parseJsonList(payloads, config.parse), config);
|
||||
if (cached.length > 0) {
|
||||
this.genericItems.set(channel, cached);
|
||||
this.stats.genericHydrateFromRedis += 1;
|
||||
|
|
@ -434,9 +428,7 @@ export class LiveStateManager {
|
|||
|
||||
const fresh = normalizeGenericItems(
|
||||
channel,
|
||||
(await config.fetchRecent(this.clickhouse, config.limit)).filter((item) =>
|
||||
isWithinLiveFeedLookback(channel, item)
|
||||
),
|
||||
await config.fetchRecent(this.clickhouse, config.limit),
|
||||
config
|
||||
);
|
||||
this.stats.genericHydrateFromClickHouse += 1;
|
||||
|
|
@ -467,8 +459,7 @@ export class LiveStateManager {
|
|||
optionTypes: subscription.filters?.optionTypes,
|
||||
minNotional: subscription.filters?.minNotional,
|
||||
underlyingIds: subscription.underlying_ids,
|
||||
optionContractId: subscription.option_contract_id,
|
||||
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
|
||||
optionContractId: subscription.option_contract_id
|
||||
};
|
||||
const items = await fetchRecentOptionPrints(
|
||||
this.clickhouse,
|
||||
|
|
@ -487,7 +478,6 @@ export class LiveStateManager {
|
|||
const config = this.generic.options;
|
||||
const limit = snapshotLimitFor(subscription, config.limit);
|
||||
const items = (this.genericItems.get("options") ?? []).filter((item) =>
|
||||
isWithinLiveFeedLookback("options", item) &&
|
||||
matchesOptionPrintFilters(item, subscription.filters)
|
||||
).slice(0, limit);
|
||||
return {
|
||||
|
|
@ -501,7 +491,6 @@ export class LiveStateManager {
|
|||
const config = this.generic.flow;
|
||||
const limit = snapshotLimitFor(subscription, config.limit);
|
||||
const items = (this.genericItems.get("flow") ?? []).filter((item) =>
|
||||
isWithinLiveFeedLookback("flow", item) &&
|
||||
matchesFlowPacketFilters(item, subscription.filters)
|
||||
).slice(0, limit);
|
||||
return {
|
||||
|
|
@ -516,8 +505,7 @@ export class LiveStateManager {
|
|||
const limit = snapshotLimitFor(subscription, config.limit);
|
||||
if (subscription.underlying_ids?.length) {
|
||||
const filters: EquityPrintQueryFilters = {
|
||||
underlyingIds: subscription.underlying_ids,
|
||||
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
|
||||
underlyingIds: subscription.underlying_ids
|
||||
};
|
||||
const items = await fetchRecentEquityPrints(this.clickhouse, limit, filters);
|
||||
return {
|
||||
|
|
@ -527,9 +515,7 @@ export class LiveStateManager {
|
|||
next_before: nextBeforeForItems(items, config.cursor)
|
||||
};
|
||||
}
|
||||
const items = (this.genericItems.get("equities") ?? []).filter((item) =>
|
||||
isWithinLiveFeedLookback("equities", item)
|
||||
).slice(0, limit);
|
||||
const items = (this.genericItems.get("equities") ?? []).slice(0, limit);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
|
|
@ -568,9 +554,7 @@ export class LiveStateManager {
|
|||
default: {
|
||||
const config = this.generic[subscription.channel];
|
||||
const limit = snapshotLimitFor(subscription, config.limit);
|
||||
const items = (this.genericItems.get(subscription.channel) ?? []).filter((item) =>
|
||||
isWithinLiveFeedLookback(subscription.channel, item)
|
||||
).slice(0, limit);
|
||||
const items = (this.genericItems.get(subscription.channel) ?? []).slice(0, limit);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
|
|
|
|||
|
|
@ -7,15 +7,17 @@ import {
|
|||
shouldFanoutLiveEvent
|
||||
} from "../src/live";
|
||||
|
||||
const makeClickHouse = (): ClickHouseClient =>
|
||||
const makeClickHouse = (
|
||||
queryResolver?: (query: string) => unknown[]
|
||||
): ClickHouseClient =>
|
||||
({
|
||||
exec: async () => {},
|
||||
insert: async () => {},
|
||||
ping: async () => ({ success: true }),
|
||||
close: async () => {},
|
||||
query: async () => ({
|
||||
query: async ({ query }: { query: string }) => ({
|
||||
async json<T>() {
|
||||
return [] as T;
|
||||
return (queryResolver?.(query) ?? []) as T;
|
||||
}
|
||||
})
|
||||
}) as ClickHouseClient;
|
||||
|
|
@ -408,6 +410,160 @@ describe("LiveStateManager", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("seeds scoped option snapshots from clickhouse rows older than 24h", async () => {
|
||||
const now = Date.now();
|
||||
const staleTs = now - 25 * 60 * 60 * 1000;
|
||||
const manager = new LiveStateManager(
|
||||
makeClickHouse((query) =>
|
||||
query.includes("FROM option_prints")
|
||||
? [
|
||||
{
|
||||
source_ts: staleTs,
|
||||
ingest_ts: staleTs + 1,
|
||||
seq: 1,
|
||||
trace_id: "opt-ancient",
|
||||
ts: staleTs,
|
||||
option_contract_id: "AAPL-2025-01-17-200-C",
|
||||
underlying_id: "AAPL",
|
||||
price: 1,
|
||||
size: 10,
|
||||
exchange: "X",
|
||||
signal_pass: true
|
||||
}
|
||||
]
|
||||
: []
|
||||
),
|
||||
null
|
||||
);
|
||||
|
||||
const snapshot = await manager.getSnapshot({
|
||||
channel: "options",
|
||||
underlying_ids: ["AAPL"],
|
||||
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||
});
|
||||
|
||||
expect((snapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([
|
||||
"opt-ancient"
|
||||
]);
|
||||
expect(snapshot.next_before).toEqual({ ts: staleTs, seq: 1 });
|
||||
expect(isLiveItemFresh("options", snapshot.items[0], now)).toBe(false);
|
||||
});
|
||||
|
||||
it("seeds scoped equity snapshots from clickhouse rows older than 24h", async () => {
|
||||
const now = Date.now();
|
||||
const staleTs = now - 25 * 60 * 60 * 1000;
|
||||
const manager = new LiveStateManager(
|
||||
makeClickHouse((query) =>
|
||||
query.includes("FROM equity_prints")
|
||||
? [
|
||||
{
|
||||
source_ts: staleTs,
|
||||
ingest_ts: staleTs + 1,
|
||||
seq: 1,
|
||||
trace_id: "eq-ancient",
|
||||
ts: staleTs,
|
||||
underlying_id: "AAPL",
|
||||
price: 100,
|
||||
size: 10,
|
||||
exchange: "X",
|
||||
offExchangeFlag: false
|
||||
}
|
||||
]
|
||||
: []
|
||||
),
|
||||
null
|
||||
);
|
||||
|
||||
const snapshot = await manager.getSnapshot({
|
||||
channel: "equities",
|
||||
underlying_ids: ["AAPL"]
|
||||
});
|
||||
|
||||
expect((snapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([
|
||||
"eq-ancient"
|
||||
]);
|
||||
expect(snapshot.next_before).toEqual({ ts: staleTs, seq: 1 });
|
||||
expect(isLiveItemFresh("equities", snapshot.items[0], now)).toBe(false);
|
||||
});
|
||||
|
||||
it("hydrates retained rows older than 24h into generic live snapshots and keeps them stale", async () => {
|
||||
const redis = makeRedis();
|
||||
const now = Date.now();
|
||||
const staleTs = now - 25 * 60 * 60 * 1000;
|
||||
|
||||
await redis.lPush(
|
||||
"live:options",
|
||||
JSON.stringify({
|
||||
source_ts: staleTs,
|
||||
ingest_ts: staleTs + 1,
|
||||
seq: 1,
|
||||
trace_id: "opt-retained",
|
||||
ts: staleTs,
|
||||
option_contract_id: "AAPL-2025-01-17-200-C",
|
||||
underlying_id: "AAPL",
|
||||
price: 1,
|
||||
size: 10,
|
||||
exchange: "X",
|
||||
signal_pass: true
|
||||
})
|
||||
);
|
||||
await redis.hSet("live:cursors", "options", JSON.stringify({ ts: staleTs, seq: 1 }));
|
||||
|
||||
await redis.lPush(
|
||||
"live:equities",
|
||||
JSON.stringify({
|
||||
source_ts: staleTs,
|
||||
ingest_ts: staleTs + 1,
|
||||
seq: 2,
|
||||
trace_id: "eq-retained",
|
||||
ts: staleTs,
|
||||
underlying_id: "AAPL",
|
||||
price: 100,
|
||||
size: 10,
|
||||
exchange: "X",
|
||||
offExchangeFlag: false
|
||||
})
|
||||
);
|
||||
await redis.hSet("live:cursors", "equities", JSON.stringify({ ts: staleTs, seq: 2 }));
|
||||
|
||||
await redis.lPush(
|
||||
"live:flow",
|
||||
JSON.stringify({
|
||||
source_ts: staleTs,
|
||||
ingest_ts: staleTs + 1,
|
||||
seq: 3,
|
||||
trace_id: "flow-retained",
|
||||
id: "flow-retained",
|
||||
members: ["opt-retained"],
|
||||
features: {},
|
||||
join_quality: {}
|
||||
})
|
||||
);
|
||||
await redis.hSet("live:cursors", "flow", JSON.stringify({ ts: staleTs, seq: 3 }));
|
||||
|
||||
const manager = new LiveStateManager(makeClickHouse(), redis as never);
|
||||
await manager.hydrate();
|
||||
|
||||
const [optionsSnapshot, equitiesSnapshot, flowSnapshot] = await Promise.all([
|
||||
manager.getSnapshot({ channel: "options" }),
|
||||
manager.getSnapshot({ channel: "equities" }),
|
||||
manager.getSnapshot({ channel: "flow" })
|
||||
]);
|
||||
|
||||
expect((optionsSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([
|
||||
"opt-retained"
|
||||
]);
|
||||
expect((equitiesSnapshot.items as Array<{ trace_id: string }>).map((item) => item.trace_id)).toEqual([
|
||||
"eq-retained"
|
||||
]);
|
||||
expect((flowSnapshot.items as Array<{ id: string }>).map((item) => item.id)).toEqual([
|
||||
"flow-retained"
|
||||
]);
|
||||
expect(isLiveItemFresh("options", optionsSnapshot.items[0], now)).toBe(false);
|
||||
expect(isLiveItemFresh("equities", equitiesSnapshot.items[0], now)).toBe(false);
|
||||
expect(isLiveItemFresh("flow", flowSnapshot.items[0], now)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps only the newest NBBO quote per contract across hydrate and ingest", async () => {
|
||||
const redis = makeRedis();
|
||||
const now = Date.now();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue