diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index e557011..41b3c6d 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -373,6 +373,7 @@ h1 { scrollbar-gutter: stable; } + .row { display: flex; justify-content: space-between; @@ -793,31 +794,30 @@ h1 { .card-flow, .card-alerts, .card-classifiers { - display: grid; - grid-template-columns: minmax(0, 1fr); - height: 760px; + display: flex; + flex-direction: column; + height: 960px; + overflow: hidden; } -.card-flow, -.card-classifiers { - grid-template-rows: auto auto minmax(0, 1fr); -} - -.card-alerts { - grid-template-rows: auto auto auto minmax(0, 1fr); -} - -.card-flow .list, -.card-alerts .list, -.card-classifiers .list { - height: 100%; +.card-body { + display: flex; + flex-direction: column; + flex: 1 1 0; min-height: 0; + gap: 16px; +} + +.card-body .list { + flex: 1 1 0; + min-height: 0; + height: auto; } @media (max-width: 720px) { .card-flow, .card-alerts, .card-classifiers { - height: 600px; + height: 780px; } } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 21448d9..50be6a8 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -239,6 +239,22 @@ const useListScroll = (): ListScrollState => { isAtTopRef.current = isAtTop; }, [isAtTop]); + const updateScrollState = useCallback(() => { + const el = listRef.current; + if (!el) { + return; + } + + const atTop = el.scrollTop <= 2; + + isAtTopRef.current = atTop; + setIsAtTop(atTop); + + if (atTop) { + setMissed(0); + } + }, [isAtTopRef]); + useEffect(() => { const el = listRef.current; if (!el) { @@ -246,21 +262,16 @@ const useListScroll = (): ListScrollState => { } const onScroll = () => { - const atTop = el.scrollTop <= 2; - isAtTopRef.current = atTop; - setIsAtTop(atTop); - if (atTop) { - setMissed(0); - } + updateScrollState(); }; - onScroll(); + updateScrollState(); el.addEventListener("scroll", onScroll); return () => { el.removeEventListener("scroll", onScroll); }; - }, []); + }, [updateScrollState]); const onNewItems = useCallback((count: number) => { if (count <= 0) { @@ -281,9 +292,10 @@ const useListScroll = (): ListScrollState => { return; } - el.scrollTo({ top: 0, behavior: "smooth" }); - setMissed(0); - }, []); + isAtTopRef.current = true; + el.scrollTop = 0; + updateScrollState(); + }, [isAtTopRef, listRef, updateScrollState]); return { listRef, @@ -299,10 +311,11 @@ const useScrollAnchor = ( listRef: React.RefObject, isAtTopRef: React.MutableRefObject ) => { - const pendingRef = useRef<{ top: number; height: number } | null>(null); + const pendingRef = useRef<{ height: number } | null>(null); const capture = useCallback(() => { if (isAtTopRef.current) { + pendingRef.current = null; return; } @@ -312,7 +325,6 @@ const useScrollAnchor = ( } pendingRef.current = { - top: el.scrollTop, height: el.scrollHeight }; }, [isAtTopRef, listRef]); @@ -328,10 +340,17 @@ const useScrollAnchor = ( return; } + if (isAtTopRef.current) { + pendingRef.current = null; + return; + } + const delta = el.scrollHeight - pending.height; - el.scrollTop = pending.top + delta; + if (delta !== 0) { + el.scrollTop = Math.max(0, el.scrollTop + delta); + } pendingRef.current = null; - }, [listRef]); + }, [isAtTopRef, listRef]); return { capture, apply }; }; @@ -1624,48 +1643,50 @@ export default function HomePage() { /> -
- {mode !== "live" ? ( -
Flow packets are live-only in this build.
- ) : filteredFlow.length === 0 ? ( -
- {tickerSet.size > 0 - ? "No flow packets match the current filter." - : "No flow packets yet. Start compute."} -
- ) : ( - filteredFlow.map((packet) => { - const features = packet.features ?? {}; - const contract = String(features.option_contract_id ?? packet.id ?? "unknown"); - const count = parseNumber(features.count, packet.members.length); - const totalSize = parseNumber(features.total_size, 0); - const totalPremium = parseNumber(features.total_premium, 0); - const notional = totalPremium * 100; - const startTs = parseNumber(features.start_ts, packet.source_ts); - const endTs = parseNumber(features.end_ts, startTs); - const windowMs = parseNumber(features.window_ms, 0); +
+
+ {mode !== "live" ? ( +
Flow packets are live-only in this build.
+ ) : filteredFlow.length === 0 ? ( +
+ {tickerSet.size > 0 + ? "No flow packets match the current filter." + : "No flow packets yet. Start compute."} +
+ ) : ( + filteredFlow.map((packet) => { + const features = packet.features ?? {}; + const contract = String(features.option_contract_id ?? packet.id ?? "unknown"); + const count = parseNumber(features.count, packet.members.length); + const totalSize = parseNumber(features.total_size, 0); + const totalPremium = parseNumber(features.total_premium, 0); + const notional = totalPremium * 100; + const startTs = parseNumber(features.start_ts, packet.source_ts); + const endTs = parseNumber(features.end_ts, startTs); + const windowMs = parseNumber(features.window_ms, 0); - return ( -
-
-
{contract}
-
- {formatFlowMetric(count)} prints - {formatFlowMetric(totalSize)} size - Premium ${formatPrice(totalPremium)} - Notional ${formatUsd(notional)} - {windowMs > 0 ? ( - {formatFlowMetric(windowMs, "ms")} - ) : null} + return ( +
+
+
{contract}
+
+ {formatFlowMetric(count)} prints + {formatFlowMetric(totalSize)} size + Premium ${formatPrice(totalPremium)} + Notional ${formatUsd(notional)} + {windowMs > 0 ? ( + {formatFlowMetric(windowMs, "ms")} + ) : null} +
+
+
+ {formatTime(startTs)} → {formatTime(endTs)}
-
- {formatTime(startTs)} → {formatTime(endTs)} -
-
- ); - }) - )} + ); + }) + )} +
@@ -1694,50 +1715,51 @@ export default function HomePage() { />
- +
+ +
+ {mode !== "live" ? ( +
Alerts are live-only in this build.
+ ) : filteredAlerts.length === 0 ? ( +
+ {tickerSet.size > 0 + ? "No alerts match the current filter." + : "No alerts yet. Start compute."} +
+ ) : ( + filteredAlerts.map((alert) => { + const primary = alert.hits[0]; + const direction = primary ? normalizeDirection(primary.direction) : "neutral"; -
- {mode !== "live" ? ( -
Alerts are live-only in this build.
- ) : filteredAlerts.length === 0 ? ( -
- {tickerSet.size > 0 - ? "No alerts match the current filter." - : "No alerts yet. Start compute."} -
- ) : ( - filteredAlerts.map((alert) => { - const primary = alert.hits[0]; - const direction = primary ? normalizeDirection(primary.direction) : "neutral"; - - return ( -
-
{formatTime(alert.source_ts)}
- - ); - }) - )} +
{formatTime(alert.source_ts)}
+ + ); + }) + )} +
@@ -1766,35 +1788,37 @@ export default function HomePage() { />
-
- {mode !== "live" ? ( -
Classifier hits are live-only in this build.
- ) : filteredClassifierHits.length === 0 ? ( -
- {tickerSet.size > 0 - ? "No classifier hits match the current filter." - : "No classifier hits yet. Start compute."} -
- ) : ( - filteredClassifierHits.map((hit) => { - const direction = normalizeDirection(hit.direction); - return ( -
-
-
{humanizeClassifierId(hit.classifier_id)}
-
- {direction} - Confidence {formatConfidence(hit.confidence)} +
+
+ {mode !== "live" ? ( +
Classifier hits are live-only in this build.
+ ) : filteredClassifierHits.length === 0 ? ( +
+ {tickerSet.size > 0 + ? "No classifier hits match the current filter." + : "No classifier hits yet. Start compute."} +
+ ) : ( + filteredClassifierHits.map((hit) => { + const direction = normalizeDirection(hit.direction); + return ( +
+
+
{humanizeClassifierId(hit.classifier_id)}
+
+ {direction} + Confidence {formatConfidence(hit.confidence)} +
+ {hit.explanations?.[0] ? ( +
{hit.explanations[0]}
+ ) : null}
- {hit.explanations?.[0] ? ( -
{hit.explanations[0]}
- ) : null} +
{formatTime(hit.source_ts)}
-
{formatTime(hit.source_ts)}
-
- ); - }) - )} + ); + }) + )} +
diff --git a/services/compute/src/index.ts b/services/compute/src/index.ts index ab5e4f5..571a25d 100644 --- a/services/compute/src/index.ts +++ b/services/compute/src/index.ts @@ -60,10 +60,10 @@ const envSchema = z.object({ return value; }, z.boolean()) .default(false), - CLASSIFIER_SWEEP_MIN_PREMIUM: z.coerce.number().positive().default(50_000), + CLASSIFIER_SWEEP_MIN_PREMIUM: z.coerce.number().positive().default(40_000), CLASSIFIER_SWEEP_MIN_COUNT: z.coerce.number().int().positive().default(3), - CLASSIFIER_SPIKE_MIN_PREMIUM: z.coerce.number().positive().default(25_000), - CLASSIFIER_SPIKE_MIN_SIZE: z.coerce.number().int().positive().default(500) + CLASSIFIER_SPIKE_MIN_PREMIUM: z.coerce.number().positive().default(20_000), + CLASSIFIER_SPIKE_MIN_SIZE: z.coerce.number().int().positive().default(400) }); const env = readEnv(envSchema); diff --git a/services/ingest-options/src/adapters/synthetic.ts b/services/ingest-options/src/adapters/synthetic.ts index 2b26294..d0e51bc 100644 --- a/services/ingest-options/src/adapters/synthetic.ts +++ b/services/ingest-options/src/adapters/synthetic.ts @@ -11,18 +11,102 @@ type Burst = { baseSize: number; exchange: string; conditions?: string[]; - burstSize: number; + printCount: number; + priceStep: number; }; const MS_PER_DAY = 24 * 60 * 60 * 1000; -const EXPIRY_OFFSETS = [7, 14, 28, 45, 60, 90]; +const EXPIRY_OFFSETS = [0, 1, 7, 14, 28, 45, 60, 90]; const EXCHANGES = ["CBOE", "PHLX", "ISE", "ARCA", "BOX", "MIAX"]; const CONDITIONS = ["SWEEP", "ISO", "FILL", "TEST"]; +const BURST_RUN_RANGE: [number, number] = [2, 4]; + +type Scenario = { + id: string; + weight: number; + right: "C" | "P" | "either"; + countRange: [number, number]; + sizeRange: [number, number]; + premiumRange: [number, number]; + priceTrend: "up" | "down" | "flat"; + conditions?: string[]; +}; + +const SCENARIOS: Scenario[] = [ + { + id: "bullish_sweep", + weight: 35, + right: "C", + countRange: [7, 10], + sizeRange: [600, 1800], + premiumRange: [120_000, 240_000], + priceTrend: "up", + conditions: ["SWEEP"] + }, + { + id: "bearish_sweep", + weight: 35, + right: "P", + countRange: [7, 10], + sizeRange: [600, 1800], + premiumRange: [120_000, 240_000], + priceTrend: "up", + conditions: ["SWEEP"] + }, + { + id: "contract_spike", + weight: 20, + right: "either", + countRange: [5, 8], + sizeRange: [1200, 3200], + premiumRange: [60_000, 140_000], + priceTrend: "flat", + conditions: ["ISO"] + }, + { + id: "noise", + weight: 10, + right: "either", + countRange: [2, 4], + sizeRange: [10, 200], + premiumRange: [500, 5000], + priceTrend: "flat", + conditions: ["FILL"] + } +]; const pick = (items: T[], seed: number): T => { return items[Math.abs(seed) % items.length]; }; +const pickInt = (min: number, max: number, seed: number): number => { + if (max <= min) { + return min; + } + const span = max - min + 1; + return min + (Math.abs(seed) % span); +}; + +const pickFloat = (min: number, max: number, seed: number): number => { + if (max <= min) { + return min; + } + const offset = (Math.abs(seed) % 1000) / 1000; + return min + (max - min) * offset; +}; + +const pickWeighted = (items: T[], seed: number): T => { + const totalWeight = items.reduce((sum, item) => sum + item.weight, 0); + let target = Math.abs(seed) % totalWeight; + for (const item of items) { + if (target < item.weight) { + return item; + } + target -= item.weight; + } + return items[0]; +}; + const hashSymbol = (value: string): number => { let hash = 0; for (let i = 0; i < value.length; i += 1) { @@ -44,21 +128,36 @@ const formatExpiry = (now: number, offsetDays: number): string => { const buildBurst = (burstIndex: number, now: number): Burst => { const symbol = SP500_SYMBOLS[burstIndex % SP500_SYMBOLS.length]; const symbolHash = hashSymbol(symbol); - const basePrice = 30 + (symbolHash % 470); + const scenario = pickWeighted(SCENARIOS, symbolHash + burstIndex * 7); + const baseUnderlying = 30 + (symbolHash % 470); const expiryOffset = pick(EXPIRY_OFFSETS, symbolHash + burstIndex); const expiry = formatExpiry(now, expiryOffset); - const strikeStep = basePrice >= 200 ? 10 : 5; - const strikeOffset = ((burstIndex % 7) - 3) * strikeStep; - const strike = Math.max(1, Math.round(basePrice / strikeStep) * strikeStep + strikeOffset); - const right = burstIndex % 2 === 0 ? "C" : "P"; + const strikeStep = baseUnderlying >= 200 ? 10 : baseUnderlying >= 100 ? 5 : 2.5; + const moneynessSteps = scenario.id === "noise" ? 5 : 2; + const strikeOffset = pickInt(-moneynessSteps, moneynessSteps, symbolHash + burstIndex * 11); + const strike = Math.max( + 1, + Math.round(baseUnderlying / strikeStep) * strikeStep + strikeOffset * strikeStep + ); + const right = + scenario.right === "either" + ? (symbolHash + burstIndex) % 2 === 0 + ? "C" + : "P" + : scenario.right; const contractId = `${symbol}-${expiry}-${formatStrike(strike)}-${right}`; const exchange = pick(EXCHANGES, burstIndex + symbolHash); - const isBlock = burstIndex % 4 === 0; - const burstSize = isBlock ? 4 : burstIndex % 3 === 0 ? 2 : 1; - const baseSize = isBlock ? 1200 + (symbolHash % 1800) : 5 + (symbolHash % 180); - const distance = Math.abs(strike - basePrice); - const basePricePer = isBlock ? 12 + distance / strikeStep : 0.5 + distance / 30; - const conditions = isBlock ? [pick(CONDITIONS, burstIndex)] : undefined; + const printCount = pickInt(scenario.countRange[0], scenario.countRange[1], symbolHash + burstIndex * 13); + const baseSize = pickInt(scenario.sizeRange[0], scenario.sizeRange[1], symbolHash + burstIndex * 17); + const premiumTarget = pickFloat( + scenario.premiumRange[0], + scenario.premiumRange[1], + symbolHash + burstIndex * 19 + ); + const basePricePer = Math.max(0.05, Number((premiumTarget / (baseSize * printCount)).toFixed(2))); + const conditions = scenario.conditions?.length ? scenario.conditions : [pick(CONDITIONS, burstIndex)]; + const priceStep = + scenario.priceTrend === "up" ? 0.01 : scenario.priceTrend === "down" ? -0.01 : 0; return { contractId, @@ -66,7 +165,8 @@ const buildBurst = (burstIndex: number, now: number): Burst => { baseSize, exchange, conditions, - burstSize + printCount, + priceStep }; }; @@ -79,7 +179,7 @@ export const createSyntheticOptionsAdapter = ( let seq = 0; let burstIndex = 0; let currentBurst: Burst | null = null; - let remaining = 0; + let remainingRuns = 0; let timer: ReturnType | null = null; let stopped = false; @@ -89,19 +189,20 @@ export const createSyntheticOptionsAdapter = ( } const now = Date.now(); - if (!currentBurst || remaining <= 0) { + if (!currentBurst || remainingRuns <= 0) { burstIndex += 1; currentBurst = buildBurst(burstIndex, now); - remaining = currentBurst.burstSize; + remainingRuns = pickInt(BURST_RUN_RANGE[0], BURST_RUN_RANGE[1], burstIndex * 23); } const burst = currentBurst; - const printsToEmit = remaining; + const printsToEmit = burst.printCount; for (let i = 0; i < printsToEmit; i += 1) { seq += 1; - const priceJitter = (i % 3) - 1; - const sizeJitter = (i % 4) - 1; + const priceJitter = ((i % 3) - 1) * 0.004; + const sizeJitter = ((i % 3) - 1) * 0.08; + const priceMultiplier = 1 + burst.priceStep * i + priceJitter; const print: OptionPrint = { source_ts: now + i * 5, ingest_ts: now + i * 5, @@ -109,8 +210,8 @@ export const createSyntheticOptionsAdapter = ( trace_id: `synthetic-options-${seq}`, ts: now + i * 5, option_contract_id: burst.contractId, - price: Math.max(0.05, Number((burst.basePrice * (1 + priceJitter * 0.02)).toFixed(2))), - size: Math.max(1, Math.round(burst.baseSize * (1 + sizeJitter * 0.05))), + price: Math.max(0.05, Number((burst.basePrice * priceMultiplier).toFixed(2))), + size: Math.max(1, Math.round(burst.baseSize * (1 + sizeJitter))), exchange: burst.exchange, conditions: burst.conditions }; @@ -118,7 +219,7 @@ export const createSyntheticOptionsAdapter = ( void handlers.onTrade(print); } - remaining = 0; + remainingRuns -= 1; }; timer = setInterval(emit, config.emitIntervalMs);