add alpaca news wire across ingest api and web
This commit is contained in:
parent
62aae70878
commit
906fe411c9
31 changed files with 1407 additions and 50 deletions
|
|
@ -708,7 +708,12 @@ h3 {
|
|||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.page-grid-news {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.page-grid-home > :nth-child(3),
|
||||
.page-grid-home > :nth-child(4),
|
||||
.page-grid-tape > :nth-child(1),
|
||||
.page-grid-replay > :nth-child(1) {
|
||||
grid-column: 1 / -1;
|
||||
|
|
@ -933,6 +938,7 @@ h3 {
|
|||
}
|
||||
|
||||
.page-grid-home > :nth-child(3),
|
||||
.page-grid-home > :nth-child(4),
|
||||
.page-grid-replay > :not(:first-child) {
|
||||
height: clamp(430px, 58vh, 760px);
|
||||
}
|
||||
|
|
@ -1747,6 +1753,72 @@ h3 {
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.terminal-link-button {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.news-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.news-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: oklch(0.18 0.012 250 / 0.6);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
transition: border-color 150ms ease, background 150ms ease;
|
||||
}
|
||||
|
||||
.news-row:hover {
|
||||
border-color: var(--accent-soft);
|
||||
background: oklch(0.2 0.015 250 / 0.75);
|
||||
}
|
||||
|
||||
.news-row-head,
|
||||
.news-row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.news-row h3 {
|
||||
margin: 0;
|
||||
font-size: 0.96rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.news-row-time {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono), monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.news-row-meta {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.news-drawer-body a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.news-drawer-body p,
|
||||
.news-drawer-body ul,
|
||||
.news-drawer-body ol,
|
||||
.news-drawer-body blockquote {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.synthetic-status-grid strong,
|
||||
.synthetic-hit-row strong {
|
||||
font-family: var(--font-mono), monospace;
|
||||
|
|
@ -1964,6 +2036,7 @@ h3 {
|
|||
}
|
||||
|
||||
.page-grid-home > :nth-child(3),
|
||||
.page-grid-home > :nth-child(4),
|
||||
.page-grid-tape > :nth-child(1),
|
||||
.page-grid-replay > :nth-child(1) {
|
||||
grid-column: auto;
|
||||
|
|
@ -1973,6 +2046,7 @@ h3 {
|
|||
.page-grid-home > :nth-child(1),
|
||||
.page-grid-home > :nth-child(2),
|
||||
.page-grid-home > :nth-child(3),
|
||||
.page-grid-home > :nth-child(4),
|
||||
.page-grid-signals > .terminal-pane,
|
||||
.page-grid-replay > :not(:first-child),
|
||||
.page-grid-tape > :first-child,
|
||||
|
|
|
|||
7
apps/web/app/news/page.tsx
Normal file
7
apps/web/app/news/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NewsRoute } from "../terminal";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function Page() {
|
||||
return <NewsRoute />;
|
||||
}
|
||||
|
|
@ -247,6 +247,15 @@ describe("live manifest", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("includes news subscriptions on home and /news", () => {
|
||||
expect(getLiveManifest("/", "SPY", 60000, buildDefaultFlowFilters()).map((subscription) => subscription.channel)).toContain(
|
||||
"news"
|
||||
);
|
||||
expect(getLiveManifest("/news", "SPY", 60000, buildDefaultFlowFilters()).map((subscription) => subscription.channel)).toEqual([
|
||||
"news"
|
||||
]);
|
||||
});
|
||||
|
||||
it("scopes /charts subscriptions to chart channels only", () => {
|
||||
const channels = getLiveManifest("/charts", "SPY", 60000, buildDefaultFlowFilters()).map(
|
||||
(subscription) => subscription.channel
|
||||
|
|
@ -431,6 +440,13 @@ describe("route feature map", () => {
|
|||
expect(features.equityOverlay).toBe(true);
|
||||
expect(features.alerts).toBe(false);
|
||||
});
|
||||
|
||||
it("maps /news to the dedicated news pane", () => {
|
||||
const features = getRouteFeatures("/news");
|
||||
expect(features.news).toBe(true);
|
||||
expect(features.showNewsPane).toBe(true);
|
||||
expect(features.showAlertsPane).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fixed tape virtualization config", () => {
|
||||
|
|
@ -461,10 +477,11 @@ describe("dark underlying route dependency helper", () => {
|
|||
});
|
||||
|
||||
describe("terminal navigation", () => {
|
||||
it("exposes only Home and Tape as top-level destinations", () => {
|
||||
it("exposes Home, Tape, and News as top-level destinations", () => {
|
||||
expect(NAV_ITEMS).toEqual([
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/tape", label: "Tape" }
|
||||
{ href: "/tape", label: "Tape" },
|
||||
{ href: "/news", label: "News" }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import type {
|
|||
LiveServerMessage,
|
||||
LiveHotChannelHealthMap,
|
||||
LiveSubscription,
|
||||
NewsStory,
|
||||
OptionFlowFilters,
|
||||
OptionFlowView,
|
||||
OptionNbboSide,
|
||||
|
|
@ -158,6 +159,7 @@ type RouteFeatures = {
|
|||
nbbo: boolean;
|
||||
equities: boolean;
|
||||
flow: boolean;
|
||||
news: boolean;
|
||||
alerts: boolean;
|
||||
smartMoney: boolean;
|
||||
classifierHits: boolean;
|
||||
|
|
@ -168,6 +170,7 @@ type RouteFeatures = {
|
|||
showOptionsPane: boolean;
|
||||
showEquitiesPane: boolean;
|
||||
showFlowPane: boolean;
|
||||
showNewsPane: boolean;
|
||||
showAlertsPane: boolean;
|
||||
showClassifierPane: boolean;
|
||||
showDarkPane: boolean;
|
||||
|
|
@ -187,6 +190,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
const includeEquitiesFallback = shouldIncludeEquitiesForDarkUnderlyingFallback();
|
||||
const normalizedPath =
|
||||
pathname === "/tape" ||
|
||||
pathname === "/news" ||
|
||||
pathname === "/signals" ||
|
||||
pathname === "/charts" ||
|
||||
pathname === "/replay"
|
||||
|
|
@ -200,6 +204,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
nbbo: true,
|
||||
equities: true,
|
||||
flow: true,
|
||||
news: false,
|
||||
alerts: false,
|
||||
smartMoney: false,
|
||||
classifierHits: false,
|
||||
|
|
@ -210,6 +215,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
showOptionsPane: true,
|
||||
showEquitiesPane: true,
|
||||
showFlowPane: true,
|
||||
showNewsPane: false,
|
||||
showAlertsPane: false,
|
||||
showClassifierPane: false,
|
||||
showDarkPane: false,
|
||||
|
|
@ -220,12 +226,41 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
needsAlertEvidencePrefetch: false,
|
||||
needsDarkUnderlying: false
|
||||
};
|
||||
case "/news":
|
||||
return {
|
||||
options: false,
|
||||
nbbo: false,
|
||||
equities: false,
|
||||
flow: false,
|
||||
news: true,
|
||||
alerts: false,
|
||||
smartMoney: false,
|
||||
classifierHits: false,
|
||||
inferredDark: false,
|
||||
equityJoins: false,
|
||||
equityCandles: false,
|
||||
equityOverlay: false,
|
||||
showOptionsPane: false,
|
||||
showEquitiesPane: false,
|
||||
showFlowPane: false,
|
||||
showNewsPane: true,
|
||||
showAlertsPane: false,
|
||||
showClassifierPane: false,
|
||||
showDarkPane: false,
|
||||
showChartPane: false,
|
||||
showFocusPane: false,
|
||||
showReplayConsole: false,
|
||||
needsClassifierDecor: false,
|
||||
needsAlertEvidencePrefetch: false,
|
||||
needsDarkUnderlying: false
|
||||
};
|
||||
case "/signals":
|
||||
return {
|
||||
options: false,
|
||||
nbbo: false,
|
||||
equities: includeEquitiesFallback,
|
||||
flow: false,
|
||||
news: false,
|
||||
alerts: true,
|
||||
smartMoney: true,
|
||||
classifierHits: true,
|
||||
|
|
@ -236,6 +271,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
showOptionsPane: false,
|
||||
showEquitiesPane: false,
|
||||
showFlowPane: false,
|
||||
showNewsPane: false,
|
||||
showAlertsPane: true,
|
||||
showClassifierPane: true,
|
||||
showDarkPane: true,
|
||||
|
|
@ -252,6 +288,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
nbbo: false,
|
||||
equities: includeEquitiesFallback,
|
||||
flow: false,
|
||||
news: false,
|
||||
alerts: false,
|
||||
smartMoney: true,
|
||||
classifierHits: false,
|
||||
|
|
@ -262,6 +299,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
showOptionsPane: false,
|
||||
showEquitiesPane: false,
|
||||
showFlowPane: false,
|
||||
showNewsPane: false,
|
||||
showAlertsPane: false,
|
||||
showClassifierPane: false,
|
||||
showDarkPane: false,
|
||||
|
|
@ -278,6 +316,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
nbbo: false,
|
||||
equities: false,
|
||||
flow: false,
|
||||
news: false,
|
||||
alerts: false,
|
||||
smartMoney: false,
|
||||
classifierHits: false,
|
||||
|
|
@ -288,6 +327,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
showOptionsPane: true,
|
||||
showEquitiesPane: false,
|
||||
showFlowPane: true,
|
||||
showNewsPane: false,
|
||||
showAlertsPane: true,
|
||||
showClassifierPane: false,
|
||||
showDarkPane: false,
|
||||
|
|
@ -305,6 +345,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
nbbo: false,
|
||||
equities: true,
|
||||
flow: false,
|
||||
news: true,
|
||||
alerts: true,
|
||||
smartMoney: true,
|
||||
classifierHits: false,
|
||||
|
|
@ -315,6 +356,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
|
|||
showOptionsPane: false,
|
||||
showEquitiesPane: true,
|
||||
showFlowPane: false,
|
||||
showNewsPane: true,
|
||||
showAlertsPane: true,
|
||||
showClassifierPane: false,
|
||||
showDarkPane: false,
|
||||
|
|
@ -332,6 +374,7 @@ const EMPTY_ALERT_EVENTS: AlertEvent[] = [];
|
|||
const EMPTY_CLASSIFIER_HIT_EVENTS: ClassifierHitEvent[] = [];
|
||||
const EMPTY_SMART_MONEY_EVENTS: SmartMoneyEvent[] = [];
|
||||
const EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = [];
|
||||
const EMPTY_NEWS_STORIES: NewsStory[] = [];
|
||||
|
||||
type CandlestickSeries = ReturnType<IChartApi["addCandlestickSeries"]>;
|
||||
|
||||
|
|
@ -1194,6 +1237,44 @@ const formatDateTime = (ts: number): string => {
|
|||
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
|
||||
};
|
||||
|
||||
const isSameLocalDay = (left: number, right: number): boolean => {
|
||||
const a = new Date(left);
|
||||
const b = new Date(right);
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
};
|
||||
|
||||
export const formatNewsTimestamp = (ts: number, now = Date.now()): string => {
|
||||
const date = new Date(ts);
|
||||
return isSameLocalDay(ts, now)
|
||||
? date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||
: date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
};
|
||||
|
||||
const sanitizeNewsHtml = (value: string): { html: string; fallbackText: string; sanitized: boolean } => {
|
||||
const fallbackText = value
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
try {
|
||||
const sanitized = value
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/\son\w+=(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
||||
.replace(/\shref=(["'])javascript:[\s\S]*?\1/gi, ' href="#"')
|
||||
.replace(/<(?!\/?(p|div|section|article|span|strong|em|b|i|ul|ol|li|br|a|h1|h2|h3|h4|blockquote)\b)[^>]*>/gi, "");
|
||||
return { html: sanitized, fallbackText, sanitized: true };
|
||||
} catch {
|
||||
return { html: "", fallbackText, sanitized: false };
|
||||
}
|
||||
};
|
||||
|
||||
const humanizeClassifierId = (value: string): string => {
|
||||
if (!value) {
|
||||
return "Classifier";
|
||||
|
|
@ -2870,6 +2951,7 @@ type LiveSessionState = {
|
|||
smartMoneyHistory: SmartMoneyEvent[];
|
||||
classifierHitsHistory: ClassifierHitEvent[];
|
||||
alertsHistory: AlertEvent[];
|
||||
newsHistory: NewsStory[];
|
||||
inferredDarkHistory: InferredDarkEvent[];
|
||||
options: OptionPrint[];
|
||||
nbbo: OptionNBBO[];
|
||||
|
|
@ -2880,6 +2962,7 @@ type LiveSessionState = {
|
|||
smartMoney: SmartMoneyEvent[];
|
||||
classifierHits: ClassifierHitEvent[];
|
||||
alerts: AlertEvent[];
|
||||
news: NewsStory[];
|
||||
inferredDark: InferredDarkEvent[];
|
||||
chartCandles: EquityCandle[];
|
||||
chartOverlay: EquityPrint[];
|
||||
|
|
@ -2900,6 +2983,7 @@ const LIVE_HISTORY_ENDPOINTS: Partial<Record<LiveSubscription["channel"], string
|
|||
"smart-money": "/history/smart-money",
|
||||
"classifier-hits": "/history/classifier-hits",
|
||||
alerts: "/history/alerts",
|
||||
news: "/history/news",
|
||||
"inferred-dark": "/history/inferred-dark"
|
||||
};
|
||||
|
||||
|
|
@ -3072,6 +3156,9 @@ export const getLiveManifest = (
|
|||
if (features.flow) {
|
||||
subscriptions.push({ channel: "flow", filters: flowFilters, snapshot_limit: LIVE_HOT_WINDOW });
|
||||
}
|
||||
if (features.news) {
|
||||
subscriptions.push({ channel: "news", snapshot_limit: LIVE_OPTIONS_HEAD_LIMIT });
|
||||
}
|
||||
if (features.alerts) {
|
||||
subscriptions.push({ channel: "alerts", snapshot_limit: LIVE_HOT_WINDOW });
|
||||
}
|
||||
|
|
@ -3133,6 +3220,7 @@ const useLiveSession = (
|
|||
const [smartMoney, setSmartMoney] = useState<SmartMoneyEvent[]>([]);
|
||||
const [classifierHits, setClassifierHits] = useState<ClassifierHitEvent[]>([]);
|
||||
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
||||
const [news, setNews] = useState<NewsStory[]>([]);
|
||||
const [inferredDark, setInferredDark] = useState<InferredDarkEvent[]>([]);
|
||||
const [optionsHistory, setOptionsHistory] = useState<OptionPrint[]>([]);
|
||||
const [nbboHistory, setNbboHistory] = useState<OptionNBBO[]>([]);
|
||||
|
|
@ -3142,6 +3230,7 @@ const useLiveSession = (
|
|||
const [smartMoneyHistory, setSmartMoneyHistory] = useState<SmartMoneyEvent[]>([]);
|
||||
const [classifierHitsHistory, setClassifierHitsHistory] = useState<ClassifierHitEvent[]>([]);
|
||||
const [alertsHistory, setAlertsHistory] = useState<AlertEvent[]>([]);
|
||||
const [newsHistory, setNewsHistory] = useState<NewsStory[]>([]);
|
||||
const [inferredDarkHistory, setInferredDarkHistory] = useState<InferredDarkEvent[]>([]);
|
||||
const [chartCandles, setChartCandles] = useState<EquityCandle[]>([]);
|
||||
const [chartOverlay, setChartOverlay] = useState<EquityPrint[]>([]);
|
||||
|
|
@ -3154,6 +3243,7 @@ const useLiveSession = (
|
|||
const smartMoneyRef = useRef<SmartMoneyEvent[]>([]);
|
||||
const classifierHitsRef = useRef<ClassifierHitEvent[]>([]);
|
||||
const alertsRef = useRef<AlertEvent[]>([]);
|
||||
const newsRef = useRef<NewsStory[]>([]);
|
||||
const inferredDarkRef = useRef<InferredDarkEvent[]>([]);
|
||||
const chartCandlesRef = useRef<EquityCandle[]>([]);
|
||||
const chartOverlayRef = useRef<EquityPrint[]>([]);
|
||||
|
|
@ -3165,6 +3255,7 @@ const useLiveSession = (
|
|||
const smartMoneyHistoryRef = useRef<SmartMoneyEvent[]>([]);
|
||||
const classifierHitsHistoryRef = useRef<ClassifierHitEvent[]>([]);
|
||||
const alertsHistoryRef = useRef<AlertEvent[]>([]);
|
||||
const newsHistoryRef = useRef<NewsStory[]>([]);
|
||||
const inferredDarkHistoryRef = useRef<InferredDarkEvent[]>([]);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const reconnectRef = useRef<number | null>(null);
|
||||
|
|
@ -3218,6 +3309,7 @@ const useLiveSession = (
|
|||
setSmartMoney([]);
|
||||
setClassifierHits([]);
|
||||
setAlerts([]);
|
||||
setNews([]);
|
||||
setInferredDark([]);
|
||||
setOptionsHistory([]);
|
||||
setNbboHistory([]);
|
||||
|
|
@ -3227,6 +3319,7 @@ const useLiveSession = (
|
|||
setSmartMoneyHistory([]);
|
||||
setClassifierHitsHistory([]);
|
||||
setAlertsHistory([]);
|
||||
setNewsHistory([]);
|
||||
setInferredDarkHistory([]);
|
||||
setChartCandles([]);
|
||||
setChartOverlay([]);
|
||||
|
|
@ -3239,6 +3332,7 @@ const useLiveSession = (
|
|||
smartMoneyRef.current = [];
|
||||
classifierHitsRef.current = [];
|
||||
alertsRef.current = [];
|
||||
newsRef.current = [];
|
||||
inferredDarkRef.current = [];
|
||||
chartCandlesRef.current = [];
|
||||
chartOverlayRef.current = [];
|
||||
|
|
@ -3250,6 +3344,7 @@ const useLiveSession = (
|
|||
smartMoneyHistoryRef.current = [];
|
||||
classifierHitsHistoryRef.current = [];
|
||||
alertsHistoryRef.current = [];
|
||||
newsHistoryRef.current = [];
|
||||
inferredDarkHistoryRef.current = [];
|
||||
subscribedKeysRef.current = new Set();
|
||||
subscribedMapRef.current = new Map();
|
||||
|
|
@ -3403,6 +3498,12 @@ const useLiveSession = (
|
|||
ref: alertsHistoryRef
|
||||
});
|
||||
break;
|
||||
case "news":
|
||||
mergeItems(setNews, newsRef, items as NewsStory[], LIVE_OPTIONS_HEAD_LIMIT, {
|
||||
setter: setNewsHistory,
|
||||
ref: newsHistoryRef
|
||||
});
|
||||
break;
|
||||
case "inferred-dark":
|
||||
mergeItems(setInferredDark, inferredDarkRef, items as InferredDarkEvent[], LIVE_HOT_WINDOW, {
|
||||
setter: setInferredDarkHistory,
|
||||
|
|
@ -3694,6 +3795,9 @@ const useLiveSession = (
|
|||
case "alerts":
|
||||
mergeOlder(setAlertsHistory, alertsHistoryRef, alertsRef.current);
|
||||
break;
|
||||
case "news":
|
||||
mergeOlder(setNewsHistory, newsHistoryRef, newsRef.current);
|
||||
break;
|
||||
case "inferred-dark":
|
||||
mergeOlder(setInferredDarkHistory, inferredDarkHistoryRef, inferredDarkRef.current);
|
||||
break;
|
||||
|
|
@ -3735,6 +3839,7 @@ const useLiveSession = (
|
|||
smartMoneyHistory,
|
||||
classifierHitsHistory,
|
||||
alertsHistory,
|
||||
newsHistory,
|
||||
inferredDarkHistory,
|
||||
options,
|
||||
nbbo,
|
||||
|
|
@ -3745,6 +3850,7 @@ const useLiveSession = (
|
|||
smartMoney,
|
||||
classifierHits,
|
||||
alerts,
|
||||
news,
|
||||
inferredDark,
|
||||
chartCandles,
|
||||
chartOverlay
|
||||
|
|
@ -4822,6 +4928,69 @@ const AlertDrawer = ({ alert, flowPacket, evidence, contextStatus, onClose }: Al
|
|||
);
|
||||
};
|
||||
|
||||
type NewsDrawerProps = {
|
||||
story: NewsStory;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const NewsDrawer = ({ story, onClose }: NewsDrawerProps) => {
|
||||
const body = sanitizeNewsHtml(story.content_html);
|
||||
|
||||
return (
|
||||
<aside className="drawer">
|
||||
<div className="drawer-header">
|
||||
<div>
|
||||
<p className="drawer-eyebrow">News wire</p>
|
||||
<h3>{story.headline}</h3>
|
||||
<p className="drawer-subtitle">
|
||||
{story.source} · Published {formatDateTime(story.published_ts)}
|
||||
{story.updated_ts !== story.published_ts ? ` · Updated ${formatDateTime(story.updated_ts)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<button className="drawer-close" type="button" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer-meta">
|
||||
{story.resolved_symbols.map((symbol) => (
|
||||
<span className="drawer-chip" key={`${story.trace_id}-${symbol}`}>
|
||||
{symbol}
|
||||
</span>
|
||||
))}
|
||||
<span className="drawer-chip">{story.symbol_resolution}</span>
|
||||
</div>
|
||||
|
||||
{story.summary ? (
|
||||
<div className="drawer-section">
|
||||
<h4>Summary</h4>
|
||||
<p className="drawer-note">{story.summary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="drawer-section">
|
||||
<h4>Story</h4>
|
||||
{body.sanitized && body.html ? (
|
||||
<div className="drawer-note news-drawer-body" dangerouslySetInnerHTML={{ __html: body.html }} />
|
||||
) : body.fallbackText ? (
|
||||
<p className="drawer-note">{body.fallbackText}</p>
|
||||
) : (
|
||||
<p className="drawer-empty">Story body unavailable.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{story.url ? (
|
||||
<div className="drawer-section">
|
||||
<h4>Source link</h4>
|
||||
<a className="terminal-button terminal-link-button" href={story.url} rel="noreferrer" target="_blank">
|
||||
Open original article
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
type ClassifierHitDrawerProps = {
|
||||
hit: ClassifierHitEvent;
|
||||
flowPacket: FlowPacket | null;
|
||||
|
|
@ -5178,6 +5347,7 @@ const useTerminalState = () => {
|
|||
const [mode, setMode] = useState<TapeMode>("live");
|
||||
const [replaySource, setReplaySource] = useState<string | null>(null);
|
||||
const [selectedAlert, setSelectedAlert] = useState<AlertEvent | null>(null);
|
||||
const [selectedNewsStory, setSelectedNewsStory] = useState<NewsStory | null>(null);
|
||||
const [selectedDarkEvent, setSelectedDarkEvent] = useState<InferredDarkEvent | null>(null);
|
||||
const [selectedClassifierHit, setSelectedClassifierHit] = useState<ClassifierHitEvent | null>(null);
|
||||
const [selectedSmartMoneyEvent, setSelectedSmartMoneyEvent] = useState<SmartMoneyEvent | null>(null);
|
||||
|
|
@ -5274,12 +5444,13 @@ const useTerminalState = () => {
|
|||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAlert && !selectedClassifierHit && !selectedDarkEvent && !selectedSmartMoneyEvent) {
|
||||
if (!selectedAlert && !selectedNewsStory && !selectedClassifierHit && !selectedDarkEvent && !selectedSmartMoneyEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dismissDrawers = () => {
|
||||
setSelectedAlert(null);
|
||||
setSelectedNewsStory(null);
|
||||
setSelectedClassifierHit(null);
|
||||
setSelectedSmartMoneyEvent(null);
|
||||
setSelectedDarkEvent(null);
|
||||
|
|
@ -5305,7 +5476,7 @@ const useTerminalState = () => {
|
|||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [selectedAlert, selectedClassifierHit, selectedDarkEvent, selectedSmartMoneyEvent]);
|
||||
}, [selectedAlert, selectedNewsStory, selectedClassifierHit, selectedDarkEvent, selectedSmartMoneyEvent]);
|
||||
|
||||
const optionsScroll = useListScroll();
|
||||
const equitiesScroll = useListScroll();
|
||||
|
|
@ -5540,6 +5711,14 @@ const useTerminalState = () => {
|
|||
)
|
||||
: equityJoins;
|
||||
const flowFeed = mode === "live" ? liveFlow : flow;
|
||||
const newsFeed =
|
||||
mode === "live"
|
||||
? toStaticTapeState(
|
||||
liveSession.status,
|
||||
composeTapeItems([], liveSession.news, liveSession.newsHistory),
|
||||
liveSession.lastUpdate
|
||||
)
|
||||
: toStaticTapeState("disconnected", [], null);
|
||||
const alertsFeed =
|
||||
mode === "live"
|
||||
? toStaticTapeState(
|
||||
|
|
@ -6490,6 +6669,16 @@ const useTerminalState = () => {
|
|||
routeFeatures.needsAlertEvidencePrefetch
|
||||
]);
|
||||
|
||||
const filteredNews = useMemo(() => {
|
||||
if (!routeFeatures.news && !routeFeatures.showNewsPane) {
|
||||
return EMPTY_NEWS_STORIES;
|
||||
}
|
||||
if (tickerSet.size === 0) {
|
||||
return newsFeed.items;
|
||||
}
|
||||
return newsFeed.items.filter((story) => story.resolved_symbols.some((symbol) => matchesTicker(symbol)));
|
||||
}, [matchesTicker, newsFeed.items, routeFeatures.news, routeFeatures.showNewsPane, tickerSet]);
|
||||
|
||||
const visibleAlerts = useMemo(() => {
|
||||
if (routeFeatures.needsAlertEvidencePrefetch) {
|
||||
return filteredAlerts.slice(0, 12);
|
||||
|
|
@ -6767,6 +6956,7 @@ const useTerminalState = () => {
|
|||
(hit: ClassifierHitEvent) => {
|
||||
const alert = findAlertForClassifierHit(hit);
|
||||
if (alert) {
|
||||
setSelectedNewsStory(null);
|
||||
setSelectedClassifierHit(null);
|
||||
setSelectedDarkEvent(null);
|
||||
setSelectedSmartMoneyEvent(null);
|
||||
|
|
@ -6774,6 +6964,7 @@ const useTerminalState = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
setSelectedNewsStory(null);
|
||||
setSelectedAlert(null);
|
||||
setSelectedDarkEvent(null);
|
||||
setSelectedSmartMoneyEvent(null);
|
||||
|
|
@ -6783,6 +6974,7 @@ const useTerminalState = () => {
|
|||
);
|
||||
|
||||
const openFromSmartMoneyEvent = useCallback((event: SmartMoneyEvent) => {
|
||||
setSelectedNewsStory(null);
|
||||
setSelectedAlert(null);
|
||||
setSelectedClassifierHit(null);
|
||||
setSelectedDarkEvent(null);
|
||||
|
|
@ -6797,6 +6989,7 @@ const useTerminalState = () => {
|
|||
);
|
||||
|
||||
const handleDarkMarkerClick = useCallback((event: InferredDarkEvent) => {
|
||||
setSelectedNewsStory(null);
|
||||
setSelectedAlert(null);
|
||||
setSelectedClassifierHit(null);
|
||||
setSelectedSmartMoneyEvent(null);
|
||||
|
|
@ -6817,6 +7010,9 @@ const useTerminalState = () => {
|
|||
if (routeFeatures.flow || routeFeatures.showFlowPane) {
|
||||
updates.push(flowFeed.lastUpdate);
|
||||
}
|
||||
if (routeFeatures.news || routeFeatures.showNewsPane) {
|
||||
updates.push(newsFeed.lastUpdate);
|
||||
}
|
||||
if (routeFeatures.alerts || routeFeatures.showAlertsPane) {
|
||||
updates.push(alertsFeed.lastUpdate);
|
||||
}
|
||||
|
|
@ -6839,6 +7035,8 @@ const useTerminalState = () => {
|
|||
routeFeatures.showFocusPane,
|
||||
routeFeatures.flow,
|
||||
routeFeatures.showFlowPane,
|
||||
routeFeatures.news,
|
||||
routeFeatures.showNewsPane,
|
||||
routeFeatures.alerts,
|
||||
routeFeatures.showAlertsPane,
|
||||
routeFeatures.smartMoney,
|
||||
|
|
@ -6849,6 +7047,7 @@ const useTerminalState = () => {
|
|||
equitiesFeed.lastUpdate,
|
||||
inferredDarkFeed.lastUpdate,
|
||||
flowFeed.lastUpdate,
|
||||
newsFeed.lastUpdate,
|
||||
alertsFeed.lastUpdate,
|
||||
smartMoneyFeed.lastUpdate,
|
||||
classifierHitsFeed.lastUpdate
|
||||
|
|
@ -6861,6 +7060,8 @@ const useTerminalState = () => {
|
|||
setReplaySource,
|
||||
selectedAlert,
|
||||
setSelectedAlert,
|
||||
selectedNewsStory,
|
||||
setSelectedNewsStory,
|
||||
selectedDarkEvent,
|
||||
setSelectedDarkEvent,
|
||||
selectedClassifierHit,
|
||||
|
|
@ -6887,6 +7088,7 @@ const useTerminalState = () => {
|
|||
equityJoins: equityJoinsFeed,
|
||||
nbbo: nbboFeed,
|
||||
inferredDark: inferredDarkFeed,
|
||||
news: newsFeed,
|
||||
flow: flowFeed,
|
||||
alerts: alertsFeed,
|
||||
smartMoney: smartMoneyFeed,
|
||||
|
|
@ -6920,6 +7122,7 @@ const useTerminalState = () => {
|
|||
equitiesScopedQuiet,
|
||||
equitiesSilentWarning,
|
||||
filteredInferredDark,
|
||||
filteredNews,
|
||||
filteredFlow,
|
||||
filteredAlerts,
|
||||
filteredSmartMoneyEvents,
|
||||
|
|
@ -6953,7 +7156,8 @@ const useTerminal = (): TerminalState => {
|
|||
|
||||
export const NAV_ITEMS = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/tape", label: "Tape" }
|
||||
{ href: "/tape", label: "Tape" },
|
||||
{ href: "/news", label: "News" }
|
||||
] as const;
|
||||
|
||||
type PageFrameProps = {
|
||||
|
|
@ -7780,6 +7984,7 @@ const AlertsPane = memo(({ state, limit, withStrip = false, className }: AlertsP
|
|||
data-tape-key={key}
|
||||
style={{ transform: `translateY(${start}px)` }}
|
||||
onClick={() => {
|
||||
state.setSelectedNewsStory(null);
|
||||
state.setSelectedDarkEvent(null);
|
||||
state.setSelectedClassifierHit(null);
|
||||
state.setSelectedSmartMoneyEvent(null);
|
||||
|
|
@ -7806,6 +8011,83 @@ const AlertsPane = memo(({ state, limit, withStrip = false, className }: AlertsP
|
|||
);
|
||||
});
|
||||
|
||||
type NewsPaneProps = {
|
||||
state: TerminalState;
|
||||
limit?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const NewsPane = memo(({ state, limit, className }: NewsPaneProps) => {
|
||||
const items = limit ? state.filteredNews.slice(0, limit) : state.filteredNews;
|
||||
const canLoadOlder = state.mode === "live" && !limit && items.length > 0;
|
||||
|
||||
return (
|
||||
<Pane
|
||||
className={className}
|
||||
title="News Wire"
|
||||
status={
|
||||
limit ? (
|
||||
<Link className="terminal-button terminal-link-button" href="/news">
|
||||
View all
|
||||
</Link>
|
||||
) : (
|
||||
<div className="status-inline status-connected">
|
||||
<span className="status-dot" />
|
||||
<span>{state.mode === "live" ? "Live wire" : "Live-only in v1"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
canLoadOlder ? (
|
||||
<button className="terminal-button" type="button" onClick={() => void state.liveSession.loadOlder("news")}>
|
||||
Older
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{state.mode === "replay" ? (
|
||||
<div className="empty">News is live-only in v1.</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty">
|
||||
{state.tickerSet.size > 0 ? "No news stories match the current filter." : "Waiting for live news stories."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="news-list" role="list" aria-label="News stories">
|
||||
{items.map((story) => (
|
||||
<button
|
||||
className="news-row"
|
||||
key={`${story.trace_id}:${story.updated_ts}:${story.seq}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
state.setSelectedNewsStory(null);
|
||||
state.setSelectedAlert(null);
|
||||
state.setSelectedClassifierHit(null);
|
||||
state.setSelectedSmartMoneyEvent(null);
|
||||
state.setSelectedDarkEvent(null);
|
||||
state.setSelectedNewsStory(story);
|
||||
}}
|
||||
>
|
||||
<div className="news-row-head">
|
||||
<h3>{story.headline}</h3>
|
||||
<span className="news-row-time">{formatNewsTimestamp(story.published_ts)}</span>
|
||||
</div>
|
||||
<div className="news-row-meta">
|
||||
<span>{story.source}</span>
|
||||
{story.resolved_symbols.map((symbol) => (
|
||||
<span className="drawer-chip" key={`${story.trace_id}-${symbol}`}>
|
||||
{symbol}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{!limit && story.summary ? <p className="drawer-note">{story.summary}</p> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Pane>
|
||||
);
|
||||
});
|
||||
|
||||
type ClassifierPaneProps = {
|
||||
state: TerminalState;
|
||||
limit?: number;
|
||||
|
|
@ -8016,6 +8298,7 @@ const DarkPane = memo(({ state, limit, className }: DarkPaneProps) => {
|
|||
data-tape-key={key}
|
||||
style={{ transform: `translateY(${start}px)` }}
|
||||
onClick={() => {
|
||||
state.setSelectedNewsStory(null);
|
||||
state.setSelectedAlert(null);
|
||||
state.setSelectedClassifierHit(null);
|
||||
state.setSelectedSmartMoneyEvent(null);
|
||||
|
|
@ -8624,6 +8907,10 @@ export function TerminalAppShell({ children }: { children: ReactNode }) {
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{state.selectedNewsStory ? (
|
||||
<NewsDrawer story={state.selectedNewsStory} onClose={() => state.setSelectedNewsStory(null)} />
|
||||
) : null}
|
||||
|
||||
{state.selectedClassifierHit ? (
|
||||
<ClassifierHitDrawer
|
||||
hit={state.selectedClassifierHit}
|
||||
|
|
@ -8662,12 +8949,24 @@ export function OverviewRoute() {
|
|||
<div className="page-grid page-grid-home">
|
||||
<ChartPane state={state} />
|
||||
<EquitiesPane state={state} />
|
||||
<NewsPane state={state} limit={6} />
|
||||
<AlertsPane state={state} withStrip />
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function NewsRoute() {
|
||||
const state = useTerminal();
|
||||
return (
|
||||
<PageFrame title="News">
|
||||
<div className="page-grid page-grid-news">
|
||||
<NewsPane state={state} className="news-pane-full" />
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function TapeRoute() {
|
||||
const state = useTerminal();
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue