Implement scoped live 24h feed visibility
This commit is contained in:
parent
f28c8e641f
commit
48b0d980a6
11 changed files with 547 additions and 49 deletions
|
|
@ -384,6 +384,59 @@ input {
|
|||
color: #ffd89a;
|
||||
}
|
||||
|
||||
.instrument-focus-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
max-width: min(360px, 32vw);
|
||||
padding: 6px 8px 6px 10px;
|
||||
border: 1px solid rgba(255, 216, 154, 0.34);
|
||||
border-radius: 8px;
|
||||
background: rgba(245, 166, 35, 0.08);
|
||||
color: #ffe2aa;
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.instrument-focus-chip span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.instrument-focus-chip button,
|
||||
.instrument-cell-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.instrument-focus-chip button {
|
||||
padding: 4px 6px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.instrument-cell-button {
|
||||
padding: 0;
|
||||
text-align: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(255, 216, 154, 0.36);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.instrument-cell-button:hover,
|
||||
.instrument-cell-button:focus-visible {
|
||||
color: #ffd89a;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pause-button {
|
||||
padding: 7px 10px;
|
||||
font-size: 0.66rem;
|
||||
|
|
|
|||
|
|
@ -73,6 +73,32 @@ describe("live manifest", () => {
|
|||
|
||||
expect(optionsSubscription?.filters).toBe(filters);
|
||||
});
|
||||
|
||||
it("includes scoped option and equity subscriptions", () => {
|
||||
const manifest = getLiveManifest(
|
||||
"/tape",
|
||||
"AAPL",
|
||||
60000,
|
||||
buildDefaultFlowFilters(),
|
||||
{
|
||||
underlying_ids: ["AAPL"],
|
||||
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||
},
|
||||
{ underlying_ids: ["AAPL"] }
|
||||
);
|
||||
const optionsSubscription = manifest.find(
|
||||
(subscription): subscription is Extract<(typeof manifest)[number], { channel: "options" }> =>
|
||||
subscription.channel === "options"
|
||||
);
|
||||
const equitiesSubscription = manifest.find(
|
||||
(subscription): subscription is Extract<(typeof manifest)[number], { channel: "equities" }> =>
|
||||
subscription.channel === "equities"
|
||||
);
|
||||
|
||||
expect(optionsSubscription?.underlying_ids).toEqual(["AAPL"]);
|
||||
expect(optionsSubscription?.option_contract_id).toBe("AAPL-2025-01-17-200-C");
|
||||
expect(equitiesSubscription?.underlying_ids).toEqual(["AAPL"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live tape pausable helpers", () => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
useState,
|
||||
type CSSProperties,
|
||||
type Dispatch,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
type SetStateAction
|
||||
} from "react";
|
||||
|
|
@ -124,6 +125,11 @@ type ChartCandle = {
|
|||
close: number;
|
||||
};
|
||||
|
||||
type SelectedInstrument =
|
||||
| null
|
||||
| { kind: "equity"; underlyingId: string }
|
||||
| { kind: "option-contract"; contractId: string; underlyingId: string };
|
||||
|
||||
const formatIntervalLabel = (intervalMs: number): string => {
|
||||
const match = CANDLE_INTERVALS.find((interval) => interval.ms === intervalMs);
|
||||
if (match) {
|
||||
|
|
@ -2247,6 +2253,15 @@ const appendOptionFlowFilters = (params: URLSearchParams, filters: OptionFlowFil
|
|||
}
|
||||
};
|
||||
|
||||
const appendLiveScopeParams = (params: URLSearchParams, subscription: LiveSubscription): void => {
|
||||
if ((subscription.channel === "options" || subscription.channel === "equities") && subscription.underlying_ids?.length) {
|
||||
params.set("underlying_ids", subscription.underlying_ids.join(","));
|
||||
}
|
||||
if (subscription.channel === "options" && subscription.option_contract_id) {
|
||||
params.set("option_contract_id", subscription.option_contract_id);
|
||||
}
|
||||
};
|
||||
|
||||
const dedupeLiveSubscriptions = (subscriptions: LiveSubscription[]): LiveSubscription[] => {
|
||||
const seen = new Set<string>();
|
||||
return subscriptions.filter((subscription) => {
|
||||
|
|
@ -2263,9 +2278,11 @@ export const getLiveManifest = (
|
|||
pathname: string,
|
||||
chartTicker: string,
|
||||
chartIntervalMs: number,
|
||||
flowFilters: OptionFlowFilters
|
||||
flowFilters: OptionFlowFilters,
|
||||
optionScope?: Pick<Extract<LiveSubscription, { channel: "options" }>, "underlying_ids" | "option_contract_id">,
|
||||
equityScope?: Pick<Extract<LiveSubscription, { channel: "equities" }>, "underlying_ids">
|
||||
): LiveSubscription[] => {
|
||||
const baselineSubs: LiveSubscription[] = [{ channel: "options", filters: flowFilters }];
|
||||
const baselineSubs: LiveSubscription[] = [{ channel: "options", filters: flowFilters, ...optionScope }];
|
||||
const chartSubs: LiveSubscription[] = [
|
||||
{ channel: "equity-candles", underlying_id: chartTicker, interval_ms: chartIntervalMs },
|
||||
{ channel: "equity-overlay", underlying_id: chartTicker }
|
||||
|
|
@ -2274,9 +2291,9 @@ export const getLiveManifest = (
|
|||
if (pathname === "/tape") {
|
||||
return dedupeLiveSubscriptions([
|
||||
...baselineSubs,
|
||||
{ channel: "options", filters: flowFilters },
|
||||
{ channel: "options", filters: flowFilters, ...optionScope },
|
||||
{ channel: "nbbo" },
|
||||
{ channel: "equities" },
|
||||
{ channel: "equities", ...equityScope },
|
||||
{ channel: "flow", filters: flowFilters },
|
||||
{ channel: "classifier-hits" }
|
||||
]);
|
||||
|
|
@ -2306,7 +2323,7 @@ export const getLiveManifest = (
|
|||
|
||||
return dedupeLiveSubscriptions([
|
||||
...baselineSubs,
|
||||
{ channel: "equities" },
|
||||
{ channel: "equities", ...equityScope },
|
||||
{ channel: "flow" },
|
||||
{ channel: "alerts" },
|
||||
{ channel: "classifier-hits" },
|
||||
|
|
@ -2320,7 +2337,9 @@ const useLiveSession = (
|
|||
pathname: string,
|
||||
chartTicker: string,
|
||||
chartIntervalMs: number,
|
||||
flowFilters: OptionFlowFilters
|
||||
flowFilters: OptionFlowFilters,
|
||||
optionScope?: Pick<Extract<LiveSubscription, { channel: "options" }>, "underlying_ids" | "option_contract_id">,
|
||||
equityScope?: Pick<Extract<LiveSubscription, { channel: "equities" }>, "underlying_ids">
|
||||
): LiveSessionState => {
|
||||
const [status, setStatus] = useState<WsStatus>(enabled ? "connecting" : "disconnected");
|
||||
const [connectedAt, setConnectedAt] = useState<number | null>(null);
|
||||
|
|
@ -2350,8 +2369,8 @@ const useLiveSession = (
|
|||
const subscribedKeysRef = useRef<Set<string>>(new Set());
|
||||
const subscribedMapRef = useRef<Map<string, LiveSubscription>>(new Map());
|
||||
const manifest = useMemo(
|
||||
() => getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters),
|
||||
[pathname, chartTicker, chartIntervalMs, flowFilters]
|
||||
() => getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters, optionScope, equityScope),
|
||||
[pathname, chartTicker, chartIntervalMs, flowFilters, optionScope, equityScope]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -2616,6 +2635,42 @@ const useLiveSession = (
|
|||
const currentKeys = subscribedKeysRef.current;
|
||||
const toSubscribe = manifest.filter((sub) => !currentKeys.has(getLiveSubscriptionKey(sub)));
|
||||
const removedKeys = Array.from(currentKeys).filter((key) => !nextKeys.has(key));
|
||||
const resetScopedChannels = new Set(
|
||||
[...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]
|
||||
.map((key) => subscribedMapRef.current.get(key) ?? nextMap.get(key) ?? null)
|
||||
.filter((sub): sub is LiveSubscription => sub !== null)
|
||||
.map((sub) => sub.channel)
|
||||
.filter((channel) => channel === "options" || channel === "equities")
|
||||
);
|
||||
if (resetScopedChannels.has("options")) {
|
||||
setOptions([]);
|
||||
}
|
||||
if (resetScopedChannels.has("equities")) {
|
||||
setEquities([]);
|
||||
}
|
||||
if (resetScopedChannels.size > 0) {
|
||||
setHistoryCursors((current) => {
|
||||
const next = { ...current };
|
||||
for (const key of [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]) {
|
||||
delete next[key];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setHistoryLoading((current) => {
|
||||
const next = { ...current };
|
||||
for (const key of [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]) {
|
||||
delete next[key];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setHistoryErrors((current) => {
|
||||
const next = { ...current };
|
||||
for (const key of [...removedKeys, ...toSubscribe.map(getLiveSubscriptionKey)]) {
|
||||
delete next[key];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (removedKeys.length > 0) {
|
||||
const removedSubs = removedKeys
|
||||
|
|
@ -2660,6 +2715,7 @@ const useLiveSession = (
|
|||
if (subscription.channel === "options" || subscription.channel === "flow") {
|
||||
appendOptionFlowFilters(params, subscription.filters);
|
||||
}
|
||||
appendLiveScopeParams(params, subscription);
|
||||
const response = await fetch(buildApiUrl(`${endpoint}?${params.toString()}`));
|
||||
if (!response.ok) {
|
||||
const detail = await readErrorDetail(response);
|
||||
|
|
@ -3981,6 +4037,7 @@ const useTerminalState = () => {
|
|||
const [selectedAlert, setSelectedAlert] = useState<AlertEvent | null>(null);
|
||||
const [selectedDarkEvent, setSelectedDarkEvent] = useState<InferredDarkEvent | null>(null);
|
||||
const [selectedClassifierHit, setSelectedClassifierHit] = useState<ClassifierHitEvent | null>(null);
|
||||
const [selectedInstrument, setSelectedInstrument] = useState<SelectedInstrument>(null);
|
||||
const [filterInput, setFilterInput] = useState<string>("");
|
||||
const [flowFilters, setFlowFilters] = useState<OptionFlowFilters>(() => buildDefaultFlowFilters());
|
||||
const [chartIntervalMs, setChartIntervalMs] = useState<number>(CANDLE_INTERVALS[0].ms);
|
||||
|
|
@ -3992,20 +4049,52 @@ const useTerminalState = () => {
|
|||
return Array.from(new Set(parts));
|
||||
}, [filterInput]);
|
||||
const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]);
|
||||
const chartTicker = useMemo(() => activeTickers[0] ?? "SPY", [activeTickers]);
|
||||
const instrumentUnderlying = selectedInstrument?.underlyingId.toUpperCase() ?? null;
|
||||
const optionScope = useMemo(
|
||||
() => ({
|
||||
underlying_ids: activeTickers.length > 0 ? activeTickers : instrumentUnderlying ? [instrumentUnderlying] : undefined,
|
||||
option_contract_id:
|
||||
selectedInstrument?.kind === "option-contract" ? selectedInstrument.contractId : undefined
|
||||
}),
|
||||
[activeTickers, instrumentUnderlying, selectedInstrument]
|
||||
);
|
||||
const equityScope = useMemo(
|
||||
() => ({
|
||||
underlying_ids: activeTickers.length > 0 ? activeTickers : instrumentUnderlying ? [instrumentUnderlying] : undefined
|
||||
}),
|
||||
[activeTickers, instrumentUnderlying]
|
||||
);
|
||||
const chartTicker = useMemo(
|
||||
() => instrumentUnderlying ?? activeTickers[0] ?? "SPY",
|
||||
[activeTickers, instrumentUnderlying]
|
||||
);
|
||||
const selectedInstrumentLabel = useMemo(() => {
|
||||
if (!selectedInstrument) {
|
||||
return null;
|
||||
}
|
||||
if (selectedInstrument.kind === "equity") {
|
||||
return `Equity: ${selectedInstrument.underlyingId}`;
|
||||
}
|
||||
const display = formatOptionContractLabel(selectedInstrument.contractId);
|
||||
return display
|
||||
? `Contract: ${display.ticker} ${display.expiration} ${display.strike}`
|
||||
: `Contract: ${selectedInstrument.contractId}`;
|
||||
}, [selectedInstrument]);
|
||||
const liveSession = useLiveSession(
|
||||
mode === "live",
|
||||
pathname,
|
||||
chartTicker,
|
||||
chartIntervalMs,
|
||||
flowFilters
|
||||
flowFilters,
|
||||
optionScope,
|
||||
equityScope
|
||||
);
|
||||
const equitiesLiveSubscriptionActive = useMemo(
|
||||
() =>
|
||||
getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters).some(
|
||||
getLiveManifest(pathname, chartTicker.toUpperCase(), chartIntervalMs, flowFilters, optionScope, equityScope).some(
|
||||
(sub) => sub.channel === "equities"
|
||||
),
|
||||
[pathname, chartTicker, chartIntervalMs, flowFilters]
|
||||
[pathname, chartTicker, chartIntervalMs, flowFilters, optionScope, equityScope]
|
||||
);
|
||||
|
||||
const handleReplaySource = useCallback((value: string | null) => {
|
||||
|
|
@ -4665,19 +4754,31 @@ const useTerminalState = () => {
|
|||
if (!matchesOptionPrintFilters(print, flowFilters)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
selectedInstrument?.kind === "option-contract" &&
|
||||
normalizeContractId(print.option_contract_id) !== selectedInstrument.contractId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (tickerSet.size === 0) {
|
||||
return true;
|
||||
return (
|
||||
!instrumentUnderlying ||
|
||||
extractUnderlying(normalizeContractId(print.option_contract_id)) === instrumentUnderlying
|
||||
);
|
||||
}
|
||||
return matchesTicker(extractUnderlying(normalizeContractId(print.option_contract_id)));
|
||||
});
|
||||
}, [flowFilters, optionsFeed.items, matchesTicker, tickerSet]);
|
||||
}, [flowFilters, optionsFeed.items, matchesTicker, tickerSet, selectedInstrument, instrumentUnderlying]);
|
||||
|
||||
const filteredEquities = useMemo(() => {
|
||||
if (tickerSet.size === 0) {
|
||||
if (instrumentUnderlying) {
|
||||
return equitiesFeed.items.filter((print) => print.underlying_id.toUpperCase() === instrumentUnderlying);
|
||||
}
|
||||
return equitiesFeed.items;
|
||||
}
|
||||
return equitiesFeed.items.filter((print) => matchesTicker(print.underlying_id));
|
||||
}, [equitiesFeed.items, matchesTicker, tickerSet]);
|
||||
}, [equitiesFeed.items, matchesTicker, tickerSet, instrumentUnderlying]);
|
||||
|
||||
const equitiesSilentWarning = shouldShowEquitiesSilentFeedWarning({
|
||||
wsStatus: liveSession.status,
|
||||
|
|
@ -5000,6 +5101,9 @@ const useTerminalState = () => {
|
|||
setSelectedDarkEvent,
|
||||
selectedClassifierHit,
|
||||
setSelectedClassifierHit,
|
||||
selectedInstrument,
|
||||
setSelectedInstrument,
|
||||
selectedInstrumentLabel,
|
||||
filterInput,
|
||||
setFilterInput,
|
||||
flowFilters,
|
||||
|
|
@ -5473,6 +5577,15 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => {
|
|||
const spot = print.execution_underlying_spot;
|
||||
const iv = print.execution_iv;
|
||||
const decor = state.classifierDecorByOptionTraceId.get(print.trace_id);
|
||||
const underlyingId = (print.underlying_id ?? parsed?.root ?? extractUnderlying(contractId)).toUpperCase();
|
||||
const focusContract = (event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
state.setSelectedInstrument({
|
||||
kind: "option-contract",
|
||||
contractId,
|
||||
underlyingId
|
||||
});
|
||||
};
|
||||
const commonProps = {
|
||||
className: `data-table-row data-table-row-button data-table-row-classified data-table-row-options${decor ? ` is-classified classifier-${decor.tone}` : ""}`,
|
||||
style: decor ? ({ "--classifier-intensity": decor.intensity } as CSSProperties) : undefined
|
||||
|
|
@ -5480,10 +5593,26 @@ const OptionsPane = ({ limit }: OptionsPaneProps) => {
|
|||
const cells = (
|
||||
<>
|
||||
<span className="data-table-cell data-table-cell-number">{formatTime(print.ts)}</span>
|
||||
<span className="data-table-cell">{contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)}</span>
|
||||
<span className="data-table-cell">{contractDisplay?.expiration ?? parsed?.expiry ?? "--"}</span>
|
||||
<span className="data-table-cell data-table-cell-number">{contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"}</span>
|
||||
<span className="data-table-cell">{parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"}</span>
|
||||
<span className="data-table-cell">
|
||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||
{contractDisplay?.ticker ?? parsed?.root ?? formatContractLabel(contractId)}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell">
|
||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||
{contractDisplay?.expiration ?? parsed?.expiry ?? "--"}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell data-table-cell-number">
|
||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||
{contractDisplay?.strike.replace(/[CP]$/, "") ?? "--"}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell">
|
||||
<button className="instrument-cell-button" type="button" onClick={focusContract}>
|
||||
{parsed?.right ?? contractDisplay?.strike.slice(-1) ?? "--"}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell data-table-cell-number">{typeof spot === "number" ? formatPrice(spot) : "--"}</span>
|
||||
<span className="data-table-cell data-table-cell-number">
|
||||
{formatSize(print.size)}@{formatPrice(print.price)}_{nbboSide ?? "--"}
|
||||
|
|
@ -5598,7 +5727,20 @@ const EquitiesPane = ({ limit }: EquitiesPaneProps) => {
|
|||
{virtual.visibleItems.map((print) => (
|
||||
<div className="data-table-row data-table-row-equities" key={`${print.trace_id}-${print.seq}`}>
|
||||
<span className="data-table-cell data-table-cell-number">{formatTime(print.ts)}</span>
|
||||
<span className="data-table-cell">{print.underlying_id}</span>
|
||||
<span className="data-table-cell">
|
||||
<button
|
||||
className="instrument-cell-button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
state.setSelectedInstrument({
|
||||
kind: "equity",
|
||||
underlyingId: print.underlying_id.toUpperCase()
|
||||
})
|
||||
}
|
||||
>
|
||||
{print.underlying_id}
|
||||
</button>
|
||||
</span>
|
||||
<span className="data-table-cell data-table-cell-number">${formatPrice(print.price)}</span>
|
||||
<span className="data-table-cell data-table-cell-number">{formatSize(print.size)}x</span>
|
||||
<span className="data-table-cell">{print.exchange}</span>
|
||||
|
|
@ -6237,6 +6379,14 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
|||
>
|
||||
Clear
|
||||
</button>
|
||||
{state.selectedInstrumentLabel ? (
|
||||
<span className="instrument-focus-chip">
|
||||
<span>{state.selectedInstrumentLabel}</span>
|
||||
<button type="button" onClick={() => state.setSelectedInstrument(null)}>
|
||||
Clear
|
||||
</button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="terminal-topbar-mode">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -552,6 +552,14 @@ export type OptionPrintQueryFilters = {
|
|||
security?: "stock" | "etf" | "all";
|
||||
optionTypes?: string[];
|
||||
nbboSides?: string[];
|
||||
underlyingIds?: string[];
|
||||
optionContractId?: string;
|
||||
sinceTs?: number;
|
||||
};
|
||||
|
||||
export type EquityPrintQueryFilters = {
|
||||
underlyingIds?: string[];
|
||||
sinceTs?: number;
|
||||
};
|
||||
|
||||
const buildOptionPrintFilterConditions = (
|
||||
|
|
@ -590,6 +598,32 @@ const buildOptionPrintFilterConditions = (
|
|||
conditions.push(`nbbo_side IN (${buildStringList(filters.nbboSides)})`);
|
||||
}
|
||||
|
||||
if (filters.underlyingIds && filters.underlyingIds.length > 0) {
|
||||
conditions.push(`underlying_id IN (${buildStringList(filters.underlyingIds)})`);
|
||||
}
|
||||
|
||||
if (filters.optionContractId) {
|
||||
conditions.push(`option_contract_id = ${quoteString(filters.optionContractId)}`);
|
||||
}
|
||||
|
||||
if (typeof filters.sinceTs === "number" && Number.isFinite(filters.sinceTs)) {
|
||||
conditions.push(`ts >= ${clampCursor(filters.sinceTs)}`);
|
||||
}
|
||||
|
||||
return conditions;
|
||||
};
|
||||
|
||||
const buildEquityPrintFilterConditions = (filters?: EquityPrintQueryFilters): string[] => {
|
||||
const conditions: string[] = [];
|
||||
if (!filters) {
|
||||
return conditions;
|
||||
}
|
||||
if (filters.underlyingIds && filters.underlyingIds.length > 0) {
|
||||
conditions.push(`underlying_id IN (${buildStringList(filters.underlyingIds)})`);
|
||||
}
|
||||
if (typeof filters.sinceTs === "number" && Number.isFinite(filters.sinceTs)) {
|
||||
conditions.push(`ts >= ${clampCursor(filters.sinceTs)}`);
|
||||
}
|
||||
return conditions;
|
||||
};
|
||||
|
||||
|
|
@ -798,11 +832,14 @@ export const fetchRecentOptionNBBO = async (
|
|||
|
||||
export const fetchRecentEquityPrints = async (
|
||||
client: ClickHouseClient,
|
||||
limit: number
|
||||
limit: number,
|
||||
filters?: EquityPrintQueryFilters
|
||||
): Promise<EquityPrint[]> => {
|
||||
const safeLimit = clampLimit(limit);
|
||||
const conditions = buildEquityPrintFilterConditions(filters);
|
||||
const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await client.query({
|
||||
query: `SELECT * FROM ${EQUITY_PRINTS_TABLE} ORDER BY ts DESC, seq DESC LIMIT ${safeLimit}`,
|
||||
query: `SELECT * FROM ${EQUITY_PRINTS_TABLE}${whereClause} ORDER BY ts DESC, seq DESC LIMIT ${safeLimit}`,
|
||||
format: "JSONEachRow"
|
||||
});
|
||||
|
||||
|
|
@ -983,14 +1020,20 @@ export const fetchEquityPrintsAfter = async (
|
|||
client: ClickHouseClient,
|
||||
afterTs: number,
|
||||
afterSeq: number,
|
||||
limit: number
|
||||
limit: number,
|
||||
filters?: EquityPrintQueryFilters
|
||||
): Promise<EquityPrint[]> => {
|
||||
const safeLimit = clampLimit(limit);
|
||||
const safeAfterTs = clampCursor(afterTs);
|
||||
const safeAfterSeq = clampCursor(afterSeq);
|
||||
|
||||
const conditions = [
|
||||
`((ts, seq) > (${safeAfterTs}, ${safeAfterSeq}))`,
|
||||
...buildEquityPrintFilterConditions(filters)
|
||||
];
|
||||
|
||||
const result = await client.query({
|
||||
query: `SELECT * FROM ${EQUITY_PRINTS_TABLE} WHERE (ts, seq) > (${safeAfterTs}, ${safeAfterSeq}) ORDER BY ts ASC, seq ASC LIMIT ${safeLimit}`,
|
||||
query: `SELECT * FROM ${EQUITY_PRINTS_TABLE} WHERE ${conditions.join(" AND ")} ORDER BY ts ASC, seq ASC LIMIT ${safeLimit}`,
|
||||
format: "JSONEachRow"
|
||||
});
|
||||
|
||||
|
|
@ -1252,11 +1295,16 @@ export const fetchEquityPrintsBefore = async (
|
|||
client: ClickHouseClient,
|
||||
beforeTs: number,
|
||||
beforeSeq: number,
|
||||
limit: number
|
||||
limit: number,
|
||||
filters?: EquityPrintQueryFilters
|
||||
): Promise<EquityPrint[]> => {
|
||||
const safeLimit = clampLimit(limit);
|
||||
const conditions = [
|
||||
buildBeforeTupleCondition("ts", "seq", beforeTs, beforeSeq),
|
||||
...buildEquityPrintFilterConditions(filters)
|
||||
];
|
||||
const result = await client.query({
|
||||
query: `SELECT * FROM ${EQUITY_PRINTS_TABLE} WHERE ${buildBeforeTupleCondition("ts", "seq", beforeTs, beforeSeq)} ORDER BY ts DESC, seq DESC LIMIT ${safeLimit}`,
|
||||
query: `SELECT * FROM ${EQUITY_PRINTS_TABLE} WHERE ${conditions.join(" AND ")} ORDER BY ts DESC, seq DESC LIMIT ${safeLimit}`,
|
||||
format: "JSONEachRow"
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
createClickHouseClient,
|
||||
fetchEquityPrintsAfter,
|
||||
fetchEquityPrintsBefore,
|
||||
fetchRecentEquityPrints
|
||||
} from "../src/clickhouse";
|
||||
import { equityPrintsTableDDL, EQUITY_PRINTS_TABLE } from "../src/equity-prints";
|
||||
|
||||
const basePrint = {
|
||||
|
|
@ -24,4 +30,39 @@ describe("equity-prints storage helpers", () => {
|
|||
expect(ddl).toContain(EQUITY_PRINTS_TABLE);
|
||||
expect(ddl).toContain("CREATE TABLE IF NOT EXISTS");
|
||||
});
|
||||
|
||||
it("builds scoped recent, before, and after queries", async () => {
|
||||
const queries: string[] = [];
|
||||
const client = createClickHouseClient({ url: "http://127.0.0.1:8123" });
|
||||
client.query = async ({ query }) => {
|
||||
queries.push(query);
|
||||
return {
|
||||
async json<T>() {
|
||||
return [] as T;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
await fetchRecentEquityPrints(client, 25, {
|
||||
underlyingIds: ["AAPL", "NVDA"],
|
||||
sinceTs: 123
|
||||
});
|
||||
await fetchEquityPrintsBefore(client, 100, 5, 20, {
|
||||
underlyingIds: ["AAPL"],
|
||||
sinceTs: 50
|
||||
});
|
||||
await fetchEquityPrintsAfter(client, 100, 5, 20, {
|
||||
underlyingIds: ["NVDA"],
|
||||
sinceTs: 50
|
||||
});
|
||||
|
||||
expect(queries[0]).toContain("underlying_id IN ('AAPL', 'NVDA')");
|
||||
expect(queries[0]).toContain("ts >= 123");
|
||||
expect(queries[1]).toContain("(ts, seq) < (100, 5)");
|
||||
expect(queries[1]).toContain("underlying_id IN ('AAPL')");
|
||||
expect(queries[1]).toContain("ts >= 50");
|
||||
expect(queries[2]).toContain("((ts, seq) > (100, 5))");
|
||||
expect(queries[2]).toContain("underlying_id IN ('NVDA')");
|
||||
expect(queries[2]).toContain("ts >= 50");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -58,7 +58,10 @@ describe("option-prints storage helpers", () => {
|
|||
security: "stock",
|
||||
nbboSides: ["AA", "A"],
|
||||
optionTypes: ["call"],
|
||||
minNotional: 25_000
|
||||
minNotional: 25_000,
|
||||
underlyingIds: ["AAPL", "NVDA"],
|
||||
optionContractId: "AAPL-2025-01-17-200-C",
|
||||
sinceTs: 123
|
||||
});
|
||||
await fetchOptionPrintsBefore(client, 100, 5, 20, "alpaca");
|
||||
await fetchOptionPrintsByTraceIds(client, ["trace-1", "trace-2"]);
|
||||
|
|
@ -68,6 +71,9 @@ describe("option-prints storage helpers", () => {
|
|||
expect(queries[0]).toContain("nbbo_side IN ('AA', 'A')");
|
||||
expect(queries[0]).toContain("option_type IN ('call')");
|
||||
expect(queries[0]).toContain("notional >= 25000");
|
||||
expect(queries[0]).toContain("underlying_id IN ('AAPL', 'NVDA')");
|
||||
expect(queries[0]).toContain("option_contract_id = 'AAPL-2025-01-17-200-C'");
|
||||
expect(queries[0]).toContain("ts >= 123");
|
||||
expect(queries[1]).toContain("(ts, seq) < (100, 5)");
|
||||
expect(queries[1]).toContain("startsWith(trace_id, 'alpaca')");
|
||||
expect(queries[1]).toContain("ORDER BY ts DESC, seq DESC LIMIT 20");
|
||||
|
|
|
|||
|
|
@ -55,14 +55,20 @@ export type LiveGenericChannel = z.infer<typeof LiveGenericChannelSchema>;
|
|||
export const LiveSubscriptionSchema = z.discriminatedUnion("channel", [
|
||||
z.object({
|
||||
channel: z.literal("options"),
|
||||
filters: OptionFlowFiltersSchema.optional()
|
||||
filters: OptionFlowFiltersSchema.optional(),
|
||||
underlying_ids: z.array(z.string().min(1)).optional(),
|
||||
option_contract_id: z.string().min(1).optional()
|
||||
}),
|
||||
z.object({
|
||||
channel: z.literal("flow"),
|
||||
filters: OptionFlowFiltersSchema.optional()
|
||||
}),
|
||||
z.object({
|
||||
channel: z.enum(["nbbo", "equities", "equity-quotes", "equity-joins", "classifier-hits", "alerts", "inferred-dark"])
|
||||
channel: z.enum(["nbbo", "equity-quotes", "equity-joins", "classifier-hits", "alerts", "inferred-dark"])
|
||||
}),
|
||||
z.object({
|
||||
channel: z.literal("equities"),
|
||||
underlying_ids: z.array(z.string().min(1)).optional()
|
||||
}),
|
||||
z.object({
|
||||
channel: z.literal("equity-candles"),
|
||||
|
|
@ -181,9 +187,23 @@ export type LiveServerMessage = z.infer<typeof LiveServerMessageSchema>;
|
|||
|
||||
export const getSubscriptionKey = (subscription: LiveSubscription): string => {
|
||||
switch (subscription.channel) {
|
||||
case "options":
|
||||
case "options": {
|
||||
const underlyings = subscription.underlying_ids?.length
|
||||
? `|underlyings:${[...subscription.underlying_ids].sort().join(",")}`
|
||||
: "";
|
||||
const contract = subscription.option_contract_id
|
||||
? `|contract:${subscription.option_contract_id}`
|
||||
: "";
|
||||
return `${subscription.channel}|${optionFlowFilterKey(subscription.filters)}${underlyings}${contract}`;
|
||||
}
|
||||
case "flow":
|
||||
return `${subscription.channel}|${optionFlowFilterKey(subscription.filters)}`;
|
||||
case "equities": {
|
||||
const underlyings = subscription.underlying_ids?.length
|
||||
? `|underlyings:${[...subscription.underlying_ids].sort().join(",")}`
|
||||
: "";
|
||||
return `${subscription.channel}${underlyings}`;
|
||||
}
|
||||
case "equity-candles":
|
||||
return `${subscription.channel}|${subscription.underlying_id}|${subscription.interval_ms}`;
|
||||
case "equity-overlay":
|
||||
|
|
|
|||
|
|
@ -23,6 +23,19 @@ describe("live protocol types", () => {
|
|||
).toBe(
|
||||
'options|{"view":"signal","securityTypes":["stock"],"nbboSides":["A","AA"],"optionTypes":["call","put"],"minNotional":25000}'
|
||||
);
|
||||
expect(
|
||||
getSubscriptionKey({
|
||||
channel: "options",
|
||||
filters: { view: "signal" },
|
||||
underlying_ids: ["NVDA", "AAPL"],
|
||||
option_contract_id: "AAPL-2025-01-17-200-C"
|
||||
})
|
||||
).toBe(
|
||||
'options|{"view":"signal"}|underlyings:AAPL,NVDA|contract:AAPL-2025-01-17-200-C'
|
||||
);
|
||||
expect(getSubscriptionKey({ channel: "equities", underlying_ids: ["NVDA", "AAPL"] })).toBe(
|
||||
"equities|underlyings:AAPL,NVDA"
|
||||
);
|
||||
expect(
|
||||
getSubscriptionKey({
|
||||
channel: "equity-candles",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import {
|
|||
fetchOptionPrintsByTraceIds,
|
||||
fetchRecentOptionPrints
|
||||
} from "@islandflow/storage";
|
||||
import type { EquityPrintQueryFilters, OptionPrintQueryFilters } from "@islandflow/storage";
|
||||
import {
|
||||
AlertEventSchema,
|
||||
ClassifierHitEventSchema,
|
||||
|
|
@ -100,7 +101,7 @@ import {
|
|||
} from "@islandflow/types";
|
||||
import { createClient } from "redis";
|
||||
import { z } from "zod";
|
||||
import { LiveStateManager, shouldFanoutLiveEvent } from "./live";
|
||||
import { LIVE_FEED_LOOKBACK_MS, LiveStateManager, shouldFanoutLiveEvent } from "./live";
|
||||
|
||||
const service = "api";
|
||||
const logger = createLogger({ service });
|
||||
|
|
@ -558,6 +559,62 @@ const buildHistoryResponse = <T extends { seq: number }>(
|
|||
};
|
||||
};
|
||||
|
||||
const parseScopeList = (url: URL, ...keys: string[]): string[] | undefined => {
|
||||
const values = keys
|
||||
.flatMap((key) => url.searchParams.getAll(key))
|
||||
.flatMap((value) => value.split(","))
|
||||
.map((value) => value.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
const unique = Array.from(new Set(values));
|
||||
return unique.length > 0 ? unique : undefined;
|
||||
};
|
||||
|
||||
const parseLiveOptionPrintFilters = (url: URL): OptionPrintQueryFilters => {
|
||||
const { storageFilters } = parseOptionPrintFilters(url);
|
||||
return {
|
||||
...storageFilters,
|
||||
underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids"),
|
||||
optionContractId: url.searchParams.get("option_contract_id") ?? undefined,
|
||||
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
|
||||
};
|
||||
};
|
||||
|
||||
const parseLiveEquityPrintFilters = (url: URL): EquityPrintQueryFilters => ({
|
||||
underlyingIds: parseScopeList(url, "underlying_id", "underlying_ids"),
|
||||
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
|
||||
});
|
||||
|
||||
const matchesScopedOptionSubscription = (
|
||||
print: { underlying_id?: string; option_contract_id: string },
|
||||
subscription: LiveSubscription
|
||||
): boolean => {
|
||||
if (subscription.channel !== "options") {
|
||||
return false;
|
||||
}
|
||||
if (subscription.option_contract_id && subscription.option_contract_id !== print.option_contract_id) {
|
||||
return false;
|
||||
}
|
||||
if (subscription.underlying_ids?.length) {
|
||||
const underlying = (print.underlying_id ?? "").toUpperCase();
|
||||
return subscription.underlying_ids.map((value) => value.toUpperCase()).includes(underlying);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const matchesScopedEquitySubscription = (
|
||||
print: { underlying_id: string },
|
||||
subscription: LiveSubscription
|
||||
): boolean => {
|
||||
if (subscription.channel !== "equities") {
|
||||
return false;
|
||||
}
|
||||
if (!subscription.underlying_ids?.length) {
|
||||
return true;
|
||||
}
|
||||
const underlying = print.underlying_id.toUpperCase();
|
||||
return subscription.underlying_ids.map((value) => value.toUpperCase()).includes(underlying);
|
||||
};
|
||||
|
||||
const buildCandleCacheKey = (underlyingId: string, intervalMs: number): string => {
|
||||
return `candles:equity:${intervalMs}:${underlyingId}`;
|
||||
};
|
||||
|
|
@ -987,7 +1044,7 @@ const run = async () => {
|
|||
}
|
||||
|
||||
const matchingSubscriptions =
|
||||
subscription.channel === "options" || subscription.channel === "flow"
|
||||
subscription.channel === "options" || subscription.channel === "flow" || subscription.channel === "equities"
|
||||
? [...subscriptionDefinitions.entries()].filter(([, candidate]) => candidate.channel === subscription.channel)
|
||||
: [[getSubscriptionKey(subscription), subscription] as const];
|
||||
|
||||
|
|
@ -1003,7 +1060,15 @@ const run = async () => {
|
|||
|
||||
if (
|
||||
candidate.channel === "options" &&
|
||||
!matchesOptionPrintFilters(OptionPrintSchema.parse(item), candidate.filters)
|
||||
(!matchesOptionPrintFilters(OptionPrintSchema.parse(item), candidate.filters) ||
|
||||
!matchesScopedOptionSubscription(OptionPrintSchema.parse(item), candidate))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
candidate.channel === "equities" &&
|
||||
!matchesScopedEquitySubscription(EquityPrintSchema.parse(item), candidate)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1343,7 +1408,7 @@ const run = async () => {
|
|||
try {
|
||||
const { beforeTs, beforeSeq, limit } = parseBeforeParams(url);
|
||||
const source = parseReplaySource(url) ?? undefined;
|
||||
const { storageFilters } = parseOptionPrintFilters(url);
|
||||
const storageFilters = parseLiveOptionPrintFilters(url);
|
||||
const data = await fetchOptionPrintsBefore(
|
||||
clickhouse,
|
||||
beforeTs,
|
||||
|
|
@ -1373,7 +1438,13 @@ const run = async () => {
|
|||
|
||||
if (req.method === "GET" && url.pathname === "/history/equities") {
|
||||
const { beforeTs, beforeSeq, limit } = parseBeforeParams(url);
|
||||
const data = await fetchEquityPrintsBefore(clickhouse, beforeTs, beforeSeq, limit);
|
||||
const data = await fetchEquityPrintsBefore(
|
||||
clickhouse,
|
||||
beforeTs,
|
||||
beforeSeq,
|
||||
limit,
|
||||
parseLiveEquityPrintFilters(url)
|
||||
);
|
||||
return jsonResponse(buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq })));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
type ClickHouseClient
|
||||
} from "@islandflow/storage";
|
||||
import type { OptionPrintQueryFilters } from "@islandflow/storage";
|
||||
import type { EquityPrintQueryFilters } from "@islandflow/storage";
|
||||
import {
|
||||
AlertEventSchema,
|
||||
ClassifierHitEventSchema,
|
||||
|
|
@ -38,6 +39,7 @@ import {
|
|||
import type { RedisClientType } from "redis";
|
||||
|
||||
const CURSOR_HASH_KEY = "live:cursors";
|
||||
export const LIVE_FEED_LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const DEFAULT_GENERIC_LIMIT = 10000;
|
||||
const MAX_GENERIC_LIMIT = 100000;
|
||||
|
|
@ -267,12 +269,24 @@ const extractFreshnessTs = (channel: LiveGenericChannel, item: any): number | nu
|
|||
case "equity-quotes":
|
||||
return typeof item.ts === "number" ? item.ts : null;
|
||||
case "flow":
|
||||
case "classifier-hits":
|
||||
case "alerts":
|
||||
case "inferred-dark":
|
||||
return typeof item.source_ts === "number" ? item.source_ts : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isWithinLiveFeedLookback = (
|
||||
channel: LiveGenericChannel,
|
||||
item: unknown,
|
||||
now = Date.now()
|
||||
): boolean => {
|
||||
const ts = extractFreshnessTs(channel, item);
|
||||
return ts !== null && now - ts <= LIVE_FEED_LOOKBACK_MS;
|
||||
};
|
||||
|
||||
export const isLiveItemFresh = (
|
||||
channel: LiveGenericChannel,
|
||||
item: unknown,
|
||||
|
|
@ -289,7 +303,12 @@ export const isLiveItemFresh = (
|
|||
return now - ts <= thresholdMs;
|
||||
};
|
||||
|
||||
export const shouldFanoutLiveEvent = (_channel: LiveChannel, _item: unknown): boolean => true;
|
||||
export const shouldFanoutLiveEvent = (channel: LiveChannel, item: unknown): boolean => {
|
||||
if (channel === "equity-candles" || channel === "equity-overlay") {
|
||||
return true;
|
||||
}
|
||||
return isWithinLiveFeedLookback(channel, item);
|
||||
};
|
||||
|
||||
const nextBeforeForItems = <T>(items: T[], cursorOf: (item: T) => Cursor): Cursor | null => {
|
||||
const last = items.at(-1);
|
||||
|
|
@ -353,7 +372,13 @@ 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), config);
|
||||
const cached = normalizeGenericItems(
|
||||
channel,
|
||||
parseJsonList(payloads, config.parse).filter((item) =>
|
||||
isWithinLiveFeedLookback(channel, item)
|
||||
),
|
||||
config
|
||||
);
|
||||
if (cached.length > 0) {
|
||||
this.genericItems.set(channel, cached);
|
||||
this.stats.genericHydrateFromRedis += 1;
|
||||
|
|
@ -370,7 +395,13 @@ export class LiveStateManager {
|
|||
}
|
||||
}
|
||||
|
||||
const fresh = normalizeGenericItems(channel, await config.fetchRecent(this.clickhouse, config.limit), config);
|
||||
const fresh = normalizeGenericItems(
|
||||
channel,
|
||||
(await config.fetchRecent(this.clickhouse, config.limit)).filter((item) =>
|
||||
isWithinLiveFeedLookback(channel, item)
|
||||
),
|
||||
config
|
||||
);
|
||||
this.stats.genericHydrateFromClickHouse += 1;
|
||||
this.stats.cacheDepthByKey.set(config.redisKey, fresh.length);
|
||||
this.genericItems.set(channel, fresh);
|
||||
|
|
@ -382,16 +413,21 @@ export class LiveStateManager {
|
|||
async getSnapshot(subscription: LiveSubscription): Promise<FeedSnapshot<unknown>> {
|
||||
switch (subscription.channel) {
|
||||
case "options": {
|
||||
if (subscription.filters?.view === "raw") {
|
||||
const scoped =
|
||||
Boolean(subscription.underlying_ids?.length) || Boolean(subscription.option_contract_id);
|
||||
if (subscription.filters?.view === "raw" || scoped) {
|
||||
const storageFilters: OptionPrintQueryFilters = {
|
||||
view: "raw",
|
||||
view: subscription.filters?.view ?? "signal",
|
||||
security:
|
||||
subscription.filters.securityTypes?.length === 1
|
||||
subscription.filters?.securityTypes?.length === 1
|
||||
? subscription.filters.securityTypes[0]
|
||||
: "all",
|
||||
nbboSides: subscription.filters.nbboSides,
|
||||
optionTypes: subscription.filters.optionTypes,
|
||||
minNotional: subscription.filters.minNotional
|
||||
nbboSides: subscription.filters?.nbboSides,
|
||||
optionTypes: subscription.filters?.optionTypes,
|
||||
minNotional: subscription.filters?.minNotional,
|
||||
underlyingIds: subscription.underlying_ids,
|
||||
optionContractId: subscription.option_contract_id,
|
||||
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
|
||||
};
|
||||
const items = await fetchRecentOptionPrints(
|
||||
this.clickhouse,
|
||||
|
|
@ -409,6 +445,7 @@ export class LiveStateManager {
|
|||
|
||||
const config = this.generic.options;
|
||||
const items = (this.genericItems.get("options") ?? []).filter((item) =>
|
||||
isWithinLiveFeedLookback("options", item) &&
|
||||
matchesOptionPrintFilters(item, subscription.filters)
|
||||
);
|
||||
return {
|
||||
|
|
@ -421,6 +458,7 @@ export class LiveStateManager {
|
|||
case "flow": {
|
||||
const config = this.generic.flow;
|
||||
const items = (this.genericItems.get("flow") ?? []).filter((item) =>
|
||||
isWithinLiveFeedLookback("flow", item) &&
|
||||
matchesFlowPacketFilters(item, subscription.filters)
|
||||
);
|
||||
return {
|
||||
|
|
@ -430,6 +468,31 @@ export class LiveStateManager {
|
|||
next_before: nextBeforeForItems(items, config.cursor)
|
||||
};
|
||||
}
|
||||
case "equities": {
|
||||
const config = this.generic.equities;
|
||||
if (subscription.underlying_ids?.length) {
|
||||
const filters: EquityPrintQueryFilters = {
|
||||
underlyingIds: subscription.underlying_ids,
|
||||
sinceTs: Date.now() - LIVE_FEED_LOOKBACK_MS
|
||||
};
|
||||
const items = await fetchRecentEquityPrints(this.clickhouse, config.limit, filters);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
watermark: items[0] ? { ts: items[0].ts, seq: items[0].seq } : null,
|
||||
next_before: nextBeforeForItems(items, config.cursor)
|
||||
};
|
||||
}
|
||||
const items = (this.genericItems.get("equities") ?? []).filter((item) =>
|
||||
isWithinLiveFeedLookback("equities", item)
|
||||
);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
watermark: this.genericCursors.get(config.cursorField) ?? null,
|
||||
next_before: nextBeforeForItems(items, config.cursor)
|
||||
};
|
||||
}
|
||||
case "equity-candles": {
|
||||
const key = candleRedisKey(subscription.underlying_id, subscription.interval_ms);
|
||||
const cursorField = candleCursorField(subscription.underlying_id, subscription.interval_ms);
|
||||
|
|
@ -460,7 +523,9 @@ export class LiveStateManager {
|
|||
}
|
||||
default: {
|
||||
const config = this.generic[subscription.channel];
|
||||
const items = this.genericItems.get(subscription.channel) ?? [];
|
||||
const items = (this.genericItems.get(subscription.channel) ?? []).filter((item) =>
|
||||
isWithinLiveFeedLookback(subscription.channel, item)
|
||||
);
|
||||
return {
|
||||
subscription,
|
||||
items,
|
||||
|
|
@ -506,6 +571,9 @@ export class LiveStateManager {
|
|||
default: {
|
||||
const config = this.generic[channel];
|
||||
const parsed = config.parse(item);
|
||||
if (!isWithinLiveFeedLookback(channel, parsed)) {
|
||||
return null;
|
||||
}
|
||||
const items = this.genericItems.get(channel) ?? [];
|
||||
const next = normalizeGenericItems(channel, [parsed, ...items], config);
|
||||
this.genericItems.set(channel, next);
|
||||
|
|
|
|||
|
|
@ -578,9 +578,11 @@ describe("LiveStateManager", () => {
|
|||
expect(isLiveItemFresh("equity-joins", { source_ts: 1 }, 1_000_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("fans out stale live events so delayed data remains visible without refresh", () => {
|
||||
expect(shouldFanoutLiveEvent("options", { ts: 1000 })).toBe(true);
|
||||
expect(shouldFanoutLiveEvent("equities", { ts: 1000 })).toBe(true);
|
||||
expect(shouldFanoutLiveEvent("flow", { source_ts: 1000 })).toBe(true);
|
||||
it("gates live feed fanout to the rolling visibility window", () => {
|
||||
const now = Date.now();
|
||||
expect(shouldFanoutLiveEvent("options", { ts: now })).toBe(true);
|
||||
expect(shouldFanoutLiveEvent("equities", { ts: now - 25 * 60 * 60 * 1000 })).toBe(false);
|
||||
expect(shouldFanoutLiveEvent("flow", { source_ts: now - 25 * 60 * 60 * 1000 })).toBe(false);
|
||||
expect(shouldFanoutLiveEvent("equity-candles", { ts: 1000 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue