feat: dealer flow, mirror portfolio (M21), options convexity, FINRA short interest, alert producers, vendor gate
CI / Test & Type-Check (push) Canceled after 0s
CI / Test & Type-Check (push) Canceled after 0s
Snapshot of in-progress module work across multiple slices: - Dealer Flow: dealerExposureEngine, dealerMapService, dealerMapExplain, dealerMapIntegrity, dealerMapReplay, dealerStudyEngine, hanStyleLevels - Mirror Portfolio (M21): fundRepository, captureIngest, mirrorAlertProducers, fund holdings strip, live book, position capture ingest - Options: BSM, NormalizedOptionSurface types, OptionsChainRouter, ConvexityGate, option legs panel - Alert producers: vixLevel, rotation, thesis, unlock, portfolioRisk, mirror (fund_capture, fund_13f, mirror_diff) - FINRA short interest adapter + queue integration - SEC company tickers adapter + ingest (symbol search index seed) - Vendor gate (rate-limit-first data plane, ADR-0009) - CUSIP registry, reverse 13F refresh, stock float service - LRU cache, portfolio backtest engine - Frontend: dealer-flow, funds, journal, lab, monitor, plan, portfolio, reports, screener, strategies, theses, guided-start, exits, more pages - Volume profile, workspace profile, visibility-aware poll - ADRs 0010 (mirror math not advice), 0011 (symbol search index) - VENDOR_INTEGRATIONS.md, END_USER_TEST.md - .gitignore: exclude DBs, .DS_Store, local config, agent scratch
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
// Shared symbol autocomplete (M22 Symbol Search Index).
|
||||
//
|
||||
// Debounced local-first search against the symbol index (ADR-0009 — no live vendor
|
||||
// call on the request path). Renders a dropdown of matches; Enter picks the top
|
||||
// (or sole) result. Emits:
|
||||
// onPick(symbol) — a known-index symbol was chosen
|
||||
// onSubmitRaw(raw) — the user submitted text that did NOT match any known
|
||||
// symbol (caller owns the hybrid soft-block flow)
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api, type SymbolSearchResult } from "@/lib/trpc";
|
||||
|
||||
export interface SymbolAutocompleteProps {
|
||||
placeholder?: string;
|
||||
/** Called with an exact index symbol match (already validated against the index). */
|
||||
onPick: (symbol: string) => void;
|
||||
/** Called when the user submits text that has no exact index match. */
|
||||
onSubmitRaw: (raw: string) => void;
|
||||
/** Called when a search query is in flight / completes (for busy indicators). */
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
/** Externally sync the current query value (for parent filtering). */
|
||||
onChange?: (query: string) => void;
|
||||
/** Controlled value — when provided, the input is externally managed. */
|
||||
value?: string;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
id?: string;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SymbolAutocomplete({
|
||||
placeholder = "Add symbol…",
|
||||
onPick,
|
||||
onSubmitRaw,
|
||||
onBusyChange,
|
||||
onChange: externalOnChange,
|
||||
value: controlledValue,
|
||||
autoFocus,
|
||||
className = "",
|
||||
inputClassName = "",
|
||||
id,
|
||||
ariaLabel = "Add symbol",
|
||||
disabled,
|
||||
}: SymbolAutocompleteProps) {
|
||||
const [internalQuery, setInternalQuery] = useState("");
|
||||
const query = controlledValue ?? internalQuery;
|
||||
const [results, setResults] = useState<SymbolSearchResult[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastQueryRef = useRef("");
|
||||
|
||||
const runSearch = useCallback((q: string) => {
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
setBusy(false);
|
||||
onBusyChange?.(false);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
onBusyChange?.(true);
|
||||
api.symbols
|
||||
.search({ q, limit: 8 })
|
||||
.then((r) => {
|
||||
setResults(r.results);
|
||||
setActive(0);
|
||||
setOpen(r.results.length > 0);
|
||||
})
|
||||
.catch(() => {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
})
|
||||
.finally(() => {
|
||||
setBusy(false);
|
||||
onBusyChange?.(false);
|
||||
});
|
||||
}, [onBusyChange]);
|
||||
|
||||
const onChange = useCallback((v: string) => {
|
||||
setInternalQuery(v);
|
||||
externalOnChange?.(v);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => runSearch(v), 150);
|
||||
}, [runSearch, externalOnChange]);
|
||||
|
||||
// Click-outside closes the dropdown.
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
|
||||
|
||||
const submitCurrent = useCallback(() => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
lastQueryRef.current = q;
|
||||
// Prefer the exact symbol match; else the top result; else raw submit.
|
||||
const exact = results.find((r) => r.symbol === q.toUpperCase());
|
||||
if (exact) {
|
||||
setOpen(false);
|
||||
setInternalQuery("");
|
||||
setResults([]);
|
||||
onPick(exact.symbol);
|
||||
return;
|
||||
}
|
||||
if (results.length === 1) {
|
||||
setOpen(false);
|
||||
setInternalQuery("");
|
||||
setResults([]);
|
||||
onPick(results[0].symbol);
|
||||
return;
|
||||
}
|
||||
if (results.length > 1 && active >= 0 && active < results.length) {
|
||||
setOpen(false);
|
||||
setInternalQuery("");
|
||||
setResults([]);
|
||||
onPick(results[active].symbol);
|
||||
return;
|
||||
}
|
||||
// No known match — hand the raw text to the caller (hybrid soft-block).
|
||||
setOpen(false);
|
||||
setInternalQuery("");
|
||||
setResults([]);
|
||||
onSubmitRaw(q);
|
||||
}, [query, results, active, onPick, onSubmitRaw]);
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className={`relative ${className}`}>
|
||||
<input
|
||||
id={id}
|
||||
value={query}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={() => { if (results.length) setOpen(true); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, results.length - 1)); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
|
||||
else if (e.key === "Enter") { e.preventDefault(); submitCurrent(); }
|
||||
else if (e.key === "Escape") { setOpen(false); }
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
className={inputClassName || "w-full bg-surface border border-line rounded-md px-3 py-2.5 text-sm text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"}
|
||||
/>
|
||||
{busy && (
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-fg-muted animate-pulse">…</span>
|
||||
)}
|
||||
{open && results.length > 0 && (
|
||||
<ul className="absolute left-0 right-0 z-30 mt-1 max-h-64 overflow-y-auto rounded-md border border-line bg-surface-raised shadow-lg">
|
||||
{results.map((r, i) => (
|
||||
<li key={r.symbol}>
|
||||
<button
|
||||
type="button"
|
||||
data-idx={i}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => { setInternalQuery(r.symbol); setOpen(false); onPick(r.symbol); }}
|
||||
className={`w-full text-left px-3 py-2 flex items-baseline gap-2 ${i === active ? "bg-surface-sunken" : ""}`}
|
||||
>
|
||||
<span className="text-sm font-semibold tnum text-fg">{r.symbol}</span>
|
||||
<span className="flex-1 truncate text-xs text-fg-muted">{r.name ?? ""}</span>
|
||||
{r.ticker_kind && (
|
||||
<span className="text-[10px] uppercase tracking-wide text-fg-faint border border-line rounded px-1">{r.ticker_kind}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user