@hoox-sh/hoox-tui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +247 -0
  2. package/bin/hoox-tui.js +35 -0
  3. package/dist/main.js +17594 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +696 -0
  6. package/src/cli-bridge-test-double.ts +212 -0
  7. package/src/components/layout/layout.test.tsx +205 -0
  8. package/src/components/layout/sidebar.tsx +69 -0
  9. package/src/components/layout/statusbar.test.tsx +423 -0
  10. package/src/components/layout/statusbar.tsx +348 -0
  11. package/src/components/shared/animated-border.tsx +58 -0
  12. package/src/components/shared/chrome.test.tsx +29 -0
  13. package/src/components/shared/command-palette.tsx +361 -0
  14. package/src/components/shared/cool-brackets.tsx +75 -0
  15. package/src/components/shared/crash-screen.tsx +167 -0
  16. package/src/components/shared/error-boundary.tsx +99 -0
  17. package/src/components/shared/keybinding-hint.tsx +37 -0
  18. package/src/components/shared/panel.tsx +61 -0
  19. package/src/components/shared/quit-modal.tsx +65 -0
  20. package/src/components/shared/shared.test.tsx +538 -0
  21. package/src/components/shared/spinner.tsx +85 -0
  22. package/src/components/shared/status-dot.tsx +54 -0
  23. package/src/components/shared/view-header.tsx +53 -0
  24. package/src/components/ui/connection-toasts.test.ts +50 -0
  25. package/src/components/ui/connection-toasts.ts +215 -0
  26. package/src/components/ui/dialog.tsx +281 -0
  27. package/src/components/ui/select.tsx +197 -0
  28. package/src/components/ui/toast.tsx +185 -0
  29. package/src/components/views/ai-chat.test.tsx +81 -0
  30. package/src/components/views/ai-chat.tsx +576 -0
  31. package/src/components/views/config-editor/action-bar.tsx +102 -0
  32. package/src/components/views/config-editor/code-editor.tsx +597 -0
  33. package/src/components/views/config-editor/file-tree.tsx +113 -0
  34. package/src/components/views/config-editor/types.ts +37 -0
  35. package/src/components/views/config-editor.test.tsx +465 -0
  36. package/src/components/views/config-editor.tsx +709 -0
  37. package/src/components/views/dashboard/alerts-panel.tsx +154 -0
  38. package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
  39. package/src/components/views/dashboard/model-health-section.tsx +276 -0
  40. package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
  41. package/src/components/views/dashboard.test.tsx +347 -0
  42. package/src/components/views/dashboard.tsx +647 -0
  43. package/src/components/views/db-query.test.tsx +115 -0
  44. package/src/components/views/db-query.tsx +849 -0
  45. package/src/components/views/edge-topology.test.tsx +198 -0
  46. package/src/components/views/edge-topology.tsx +572 -0
  47. package/src/components/views/kv-viewer.test.tsx +241 -0
  48. package/src/components/views/kv-viewer.tsx +717 -0
  49. package/src/components/views/logs-viewer.test.tsx +273 -0
  50. package/src/components/views/logs-viewer.tsx +616 -0
  51. package/src/components/views/queue-depth.test.tsx +221 -0
  52. package/src/components/views/queue-depth.tsx +376 -0
  53. package/src/components/views/secrets-viewer.test.tsx +65 -0
  54. package/src/components/views/secrets-viewer.tsx +548 -0
  55. package/src/components/views/service-manager.test.tsx +358 -0
  56. package/src/components/views/service-manager.tsx +946 -0
  57. package/src/components/views/settings.test.tsx +648 -0
  58. package/src/components/views/settings.tsx +1497 -0
  59. package/src/components/views/setup-wizard.test.tsx +297 -0
  60. package/src/components/views/setup-wizard.tsx +1321 -0
  61. package/src/components/views/trade-monitor.test.tsx +592 -0
  62. package/src/components/views/trade-monitor.tsx +745 -0
  63. package/src/components/views/worker-detail.test.tsx +289 -0
  64. package/src/components/views/worker-detail.tsx +676 -0
  65. package/src/components/views/worker-settings.test.tsx +43 -0
  66. package/src/components/views/worker-settings.tsx +563 -0
  67. package/src/components/views/workers-overview.test.tsx +260 -0
  68. package/src/components/views/workers-overview.tsx +631 -0
  69. package/src/hooks/hooks.test.ts +72 -0
  70. package/src/hooks/index.ts +11 -0
  71. package/src/hooks/renderer-ref.ts +22 -0
  72. package/src/hooks/use-keyboard.ts +130 -0
  73. package/src/hooks/use-polling.ts +73 -0
  74. package/src/hooks/use-service-data.ts +18 -0
  75. package/src/main.tsx +117 -0
  76. package/src/network-test-double.ts +133 -0
  77. package/src/opentui-augment.d.ts +45 -0
  78. package/src/opentui-jsx.d.ts +70 -0
  79. package/src/services/cli-bridge/index.ts +1662 -0
  80. package/src/services/cli-bridge/standalone.ts +315 -0
  81. package/src/services/cli-bridge/types.ts +307 -0
  82. package/src/services/cli-bridge.test.ts +406 -0
  83. package/src/services/clipboard.test.ts +57 -0
  84. package/src/services/clipboard.ts +180 -0
  85. package/src/services/dashboard-settings-loader.ts +31 -0
  86. package/src/services/dev-log.test.ts +168 -0
  87. package/src/services/dev-log.ts +194 -0
  88. package/src/services/hoox-path-service.test.ts +183 -0
  89. package/src/services/hoox-path-service.ts +185 -0
  90. package/src/services/tui-connection.test.ts +212 -0
  91. package/src/services/tui-connection.ts +236 -0
  92. package/src/services/tui-storage.test.ts +92 -0
  93. package/src/services/tui-storage.ts +86 -0
  94. package/src/stores/config-store.test.ts +360 -0
  95. package/src/stores/service-store.test.ts +1026 -0
  96. package/src/stores/session-restore.test.ts +134 -0
  97. package/src/stores/ui-store.test.ts +276 -0
  98. package/src/test-setup.ts +265 -0
  99. package/src/test-utils.ts +179 -0
  100. package/src/types.ts +153 -0
  101. package/src/utils/colors.test.ts +326 -0
  102. package/src/utils/formatters.test.ts +236 -0
  103. package/src/view-registry.test.ts +132 -0
  104. package/src/view-registry.tsx +368 -0
@@ -0,0 +1,745 @@
1
+ /**
2
+ * Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /** @jsxImportSource @opentui/react */
7
+ /**
8
+ * Trade Monitor View — live trade feed, open positions, and performance summary.
9
+ *
10
+ * Layout (3-panel):
11
+ * 1. Header: "TRADE MONITOR" title + [LIVE]/[PAUSED] indicator + trade count
12
+ * 2. Left panel (LiveTradeFeed): scrollable ring buffer, newest first
13
+ * - BUY → green (Colors.success) | SELL → red (Colors.error)
14
+ * - Each row: timestamp, side, symbol, quantity, price, exchange, latency ms
15
+ * 3. Right panels (stacked):
16
+ * a. OpenPositions: derived from trades by symbol with P&L, total P&L header
17
+ * b. PerformanceSummary: Today/Week/Month P&L, WinRate%, Sharpe ratio
18
+ *
19
+ * Space toggles pause/resume of the live feed. Feed capped at 500 via store ring buffer.
20
+ *
21
+ * Follows Pattern 1 (View Composition), Pattern 2 (Store Subscription).
22
+ * Colors from @hoox-sh/hoox-shared design tokens. No CSS, no DOM.
23
+ */
24
+ import { useState, useMemo, useRef, useEffect } from "react";
25
+ import { useKeyboard } from "@opentui/react";
26
+ import { Colors, useServiceStore, useUIStore } from "@hoox-sh/hoox-shared";
27
+ import type { Trade, TradeSide } from "@hoox-sh/hoox-shared";
28
+ import { ErrorBoundary } from "../shared/error-boundary";
29
+ import { Spinner, EmptyState } from "../shared/spinner";
30
+ import { ViewHeader } from "../shared/view-header";
31
+ import { Panel } from "../shared/panel";
32
+
33
+ // ─── Constants ────────────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Maximum trades rendered in the live feed. Store ring buffer is 500;
37
+ * rendering all 500 on every SSE tick is expensive in OpenTUI, so we
38
+ * virtualize by rendering only the newest slice.
39
+ */
40
+ export const MAX_VISIBLE_TRADES = 120;
41
+
42
+ /** Store ring-buffer capacity (documented contract; owned by service-store). */
43
+ export const TRADE_RING_BUFFER_CAP = 500;
44
+
45
+ /** Side-based display color tokens */
46
+ const SIDE_COLOR: Record<TradeSide, string> = {
47
+ buy: Colors.success,
48
+ sell: Colors.error,
49
+ };
50
+
51
+ /** Side label for display */
52
+ const SIDE_LABEL: Record<TradeSide, string> = {
53
+ buy: "BUY ",
54
+ sell: "SELL",
55
+ };
56
+
57
+ // ─── Formatters ───────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Format a timestamp (ms) to HH:MM:SS.
61
+ */
62
+ function formatTime(ts: number): string {
63
+ const d = new Date(ts);
64
+ return [
65
+ d.getHours().toString().padStart(2, "0"),
66
+ d.getMinutes().toString().padStart(2, "0"),
67
+ d.getSeconds().toString().padStart(2, "0"),
68
+ ].join(":");
69
+ }
70
+
71
+ /**
72
+ * Format a P&L value with +/- prefix.
73
+ * Shows 2 decimal places only when the value has a fractional part.
74
+ */
75
+ function formatPnL(value: number): string {
76
+ const sign = value >= 0 ? "+" : "-";
77
+ const abs = Math.abs(value);
78
+ const hasCents = abs !== Math.round(abs);
79
+ const decimals = hasCents ? 2 : 0;
80
+ return `${sign}${abs.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}`;
81
+ }
82
+
83
+ /**
84
+ * Format a ratio as a percentage string.
85
+ */
86
+ function formatPct(value: number, decimals = 1): string {
87
+ return `${(value * 100).toFixed(decimals)}%`;
88
+ }
89
+
90
+ /**
91
+ * Format a number with comma separators and optional decimals.
92
+ */
93
+ function formatNum(value: number, decimals = 0): string {
94
+ return value.toLocaleString("en-US", {
95
+ minimumFractionDigits: decimals,
96
+ maximumFractionDigits: decimals,
97
+ });
98
+ }
99
+
100
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Strip C0 control characters (except tab/newline) so untrusted stream
104
+ * fields cannot inject terminal control sequences into the TUI.
105
+ * XSS is N/A in a terminal, but `\x1b` / CR / BEL can still corrupt the UI.
106
+ */
107
+ export function sanitizeTerminalText(value: string, maxLen = 80): string {
108
+ const cleaned = value.replace(
109
+ /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g,
110
+ ""
111
+ );
112
+ if (cleaned.length <= maxLen) return cleaned;
113
+ return cleaned.slice(0, maxLen - 1) + "\u2026";
114
+ }
115
+
116
+ /** Get Unix timestamp for midnight today (local time) */
117
+ function startOfToday(): number {
118
+ const d = new Date();
119
+ d.setHours(0, 0, 0, 0);
120
+ return d.getTime();
121
+ }
122
+
123
+ /** Get Unix timestamp for N days ago at midnight */
124
+ function startOfDaysAgo(days: number): number {
125
+ const d = new Date();
126
+ d.setDate(d.getDate() - days);
127
+ d.setHours(0, 0, 0, 0);
128
+ return d.getTime();
129
+ }
130
+
131
+ /**
132
+ * Age of the trade event in ms (now − trade timestamp).
133
+ * This is feed staleness, not exchange round-trip latency.
134
+ * Returns null if trade is in the future (clock skew).
135
+ */
136
+ export function calcLatency(tradeTs: number, now = Date.now()): number | null {
137
+ const latency = now - tradeTs;
138
+ return latency >= 0 ? latency : null;
139
+ }
140
+
141
+ /**
142
+ * Newest-first slice of the trade stream for rendering.
143
+ * Store keeps newest last; we reverse only the visible window.
144
+ */
145
+ export function selectVisibleTrades(
146
+ stream: readonly Trade[],
147
+ maxVisible: number = MAX_VISIBLE_TRADES
148
+ ): Trade[] {
149
+ if (stream.length === 0) return [];
150
+ const start = Math.max(0, stream.length - maxVisible);
151
+ const slice = stream.slice(start);
152
+ return slice.reverse();
153
+ }
154
+
155
+ // ─── Sub-Components ──────────────────────────────────────────────────────────
156
+
157
+ /**
158
+ * TradeMonitorHeader — view title with paused/live indicator and trade count.
159
+ */
160
+ function TradeMonitorHeader({
161
+ paused,
162
+ tradeCount,
163
+ connectionStatus,
164
+ }: {
165
+ paused: boolean;
166
+ tradeCount: number;
167
+ connectionStatus: string;
168
+ }) {
169
+ const offline = connectionStatus === "offline";
170
+ const liveLabel = paused
171
+ ? "PAUSED"
172
+ : offline
173
+ ? "OFFLINE"
174
+ : connectionStatus === "reconnecting"
175
+ ? "RECONNECT"
176
+ : connectionStatus === "polling"
177
+ ? "POLL"
178
+ : "LIVE";
179
+ const liveColor = paused
180
+ ? Colors.warning
181
+ : offline || connectionStatus === "reconnecting"
182
+ ? Colors.error
183
+ : connectionStatus === "polling"
184
+ ? Colors.warning
185
+ : Colors.success;
186
+
187
+ return (
188
+ <ViewHeader
189
+ title="TRADE MONITOR"
190
+ showDivider={false}
191
+ meta={
192
+ <box flexDirection="row" gap={2}>
193
+ <box flexDirection="row" gap={1}>
194
+ <text fg={liveColor} bold blink={!paused && !offline}>
195
+ {paused || offline ? "▌" : "█"}
196
+ </text>
197
+ <text fg={liveColor}>{liveLabel}</text>
198
+ </box>
199
+ <text fg={Colors.muted} dim>
200
+ {`${tradeCount} trades`}
201
+ {tradeCount > MAX_VISIBLE_TRADES
202
+ ? ` · showing ${MAX_VISIBLE_TRADES}`
203
+ : ""}
204
+ </text>
205
+ <text fg={Colors.dim} dim>
206
+ Space to {paused ? "resume" : "pause"}
207
+ </text>
208
+ </box>
209
+ }
210
+ />
211
+ );
212
+ }
213
+
214
+ /**
215
+ * LiveTradeFeed — scrollable list of trades, newest first.
216
+ *
217
+ * Each row shows:
218
+ * HH:MM:SS BUY/SELL SYMBOL QTY @ $PRICE EXCHANGE · LATENCYms
219
+ *
220
+ * Buy trades colored green (Colors.success), sell trades red (Colors.error).
221
+ * Selected row highlighted with accent border color and card background.
222
+ * Feed is capped at the store's ring buffer size (500).
223
+ */
224
+ function LiveTradeFeed({
225
+ paused,
226
+ isActive,
227
+ offline,
228
+ }: {
229
+ paused: boolean;
230
+ isActive: boolean;
231
+ offline: boolean;
232
+ }) {
233
+ const tradeStream = useServiceStore((s) => s.tradeStream);
234
+ const [selectedIndex, setSelectedIndex] = useState(0);
235
+
236
+ // Snapshot frozen trades when paused; keep updating otherwise
237
+ const frozenRef = useRef<Trade[]>([]);
238
+ const wasPausedRef = useRef(false);
239
+
240
+ // Capture snapshot on pause edge, release on resume
241
+ if (!paused && wasPausedRef.current) {
242
+ frozenRef.current = [];
243
+ }
244
+ if (paused && !wasPausedRef.current) {
245
+ frozenRef.current = [...tradeStream];
246
+ }
247
+ wasPausedRef.current = paused;
248
+
249
+ // Use frozen snapshot when paused, live stream when not
250
+ const effectiveStream = paused ? frozenRef.current : tradeStream;
251
+
252
+ // Newest first, only the render window (store is newest-last)
253
+ const sortedTrades = useMemo(
254
+ () => selectVisibleTrades(effectiveStream, MAX_VISIBLE_TRADES),
255
+ [effectiveStream]
256
+ );
257
+
258
+ const maxIndex = Math.max(0, sortedTrades.length - 1);
259
+
260
+ // Keep selection in range when the stream shrinks/grows
261
+ useEffect(() => {
262
+ setSelectedIndex((i) => Math.min(i, maxIndex));
263
+ }, [maxIndex]);
264
+
265
+ const safeIndex = Math.min(selectedIndex, maxIndex);
266
+
267
+ // Keyboard: navigate trade rows only while this view is active
268
+ useKeyboard((key) => {
269
+ if (!isActive) return;
270
+ if (key.name === "up") {
271
+ setSelectedIndex((i) => Math.max(0, i - 1));
272
+ } else if (key.name === "down") {
273
+ setSelectedIndex((i) => Math.min(maxIndex, i + 1));
274
+ }
275
+ });
276
+
277
+ return (
278
+ <Panel flexGrow={1} elevated={false} compact title="LIVE TRADE FEED">
279
+ {paused ? (
280
+ <text fg={Colors.warning} dim>
281
+ PAUSED
282
+ </text>
283
+ ) : null}
284
+
285
+ {/* Column header */}
286
+ <box flexDirection="row" gap={1} paddingTop={0}>
287
+ <text fg={Colors.muted} dim>
288
+ TIME
289
+ </text>
290
+ <text fg={Colors.muted} dim>
291
+ SIDE
292
+ </text>
293
+ <text fg={Colors.muted} dim>
294
+ SYMBOL
295
+ </text>
296
+ <text fg={Colors.muted} dim>
297
+ QTY @ PRICE
298
+ </text>
299
+ <text fg={Colors.muted} dim>
300
+ EXCHANGE
301
+ </text>
302
+ <text fg={Colors.muted} dim>
303
+ AGE
304
+ </text>
305
+ </box>
306
+
307
+ {sortedTrades.length === 0 ? (
308
+ <box
309
+ paddingTop={1}
310
+ alignItems="center"
311
+ justifyContent="center"
312
+ flexGrow={1}
313
+ >
314
+ {offline ? (
315
+ <EmptyState
316
+ message="Feed offline — no live trades"
317
+ suggestion="Check SSE connection in status bar"
318
+ icon="📡"
319
+ />
320
+ ) : (
321
+ <Spinner label="Waiting for live data..." />
322
+ )}
323
+ </box>
324
+ ) : (
325
+ <scrollbox
326
+ width="100%"
327
+ flexGrow={1}
328
+ height={14}
329
+ paddingX={1}
330
+ paddingY={0}
331
+ >
332
+ {sortedTrades.map((trade, i) => {
333
+ const color = SIDE_COLOR[trade.side] ?? Colors.foreground;
334
+ const label = SIDE_LABEL[trade.side] ?? String(trade.side);
335
+ const latency = calcLatency(trade.timestamp);
336
+ const isSelected = i === safeIndex;
337
+ const latencyStr =
338
+ latency !== null
339
+ ? latency < 1000
340
+ ? `${latency}ms`
341
+ : `${(latency / 1000).toFixed(1)}s`
342
+ : "—";
343
+ const symbol = sanitizeTerminalText(trade.symbol ?? "?", 12);
344
+ const exchange = sanitizeTerminalText(trade.exchange ?? "—", 16);
345
+
346
+ return (
347
+ <box
348
+ key={trade.id}
349
+ flexDirection="row"
350
+ gap={1}
351
+ backgroundColor={isSelected ? Colors.card : undefined}
352
+ >
353
+ {/* Timestamp */}
354
+ <text fg={Colors.muted} dim selectable>
355
+ {formatTime(trade.timestamp)}
356
+ </text>
357
+
358
+ {/* Side — color-coded */}
359
+ <text fg={color} bold selectable>
360
+ {label}
361
+ </text>
362
+
363
+ {/* Symbol */}
364
+ <text
365
+ fg={isSelected ? Colors.accent : Colors.foreground}
366
+ bold={isSelected}
367
+ selectable
368
+ >
369
+ {symbol.padEnd(6)}
370
+ </text>
371
+
372
+ {/* Quantity @ Price */}
373
+ <text fg={Colors.foreground} selectable>
374
+ {trade.quantity} @ ${formatNum(trade.price, 2)}
375
+ </text>
376
+
377
+ {/* Exchange */}
378
+ <text fg={Colors.muted} dim selectable>
379
+ {exchange}
380
+ </text>
381
+
382
+ {/* Latency */}
383
+ <text
384
+ fg={
385
+ latency !== null && latency < 100
386
+ ? Colors.success
387
+ : latency !== null && latency < 500
388
+ ? Colors.warning
389
+ : Colors.muted
390
+ }
391
+ dim={latency === null || latency >= 500}
392
+ selectable
393
+ >
394
+ · {latencyStr}
395
+ </text>
396
+ </box>
397
+ );
398
+ })}
399
+ </scrollbox>
400
+ )}
401
+
402
+ {/* Scroll position hint */}
403
+ {sortedTrades.length > 0 && (
404
+ <text fg={Colors.dim} dim>
405
+ ↑↓ navigate · {safeIndex + 1}/{sortedTrades.length}
406
+ </text>
407
+ )}
408
+ </Panel>
409
+ );
410
+ }
411
+
412
+ /**
413
+ * SymbolPnlSummary — per-symbol P&L rolled up from the live trade stream.
414
+ *
415
+ * This is NOT exchange inventory / open-position state. It groups stream
416
+ * trades by symbol and sums reported pnl. Labels in the UI say so explicitly.
417
+ */
418
+ function OpenPositions() {
419
+ const tradeStream = useServiceStore((s) => s.tradeStream);
420
+
421
+ // Derive per-symbol P&L from stream (not real open positions)
422
+ const positions = useMemo(() => {
423
+ const map = new Map<
424
+ string,
425
+ { pnl: number; tradeCount: number; lastPrice: number; side: TradeSide }
426
+ >();
427
+
428
+ for (const trade of tradeStream) {
429
+ const existing = map.get(trade.symbol);
430
+ if (existing) {
431
+ if (trade.pnl !== undefined) {
432
+ existing.pnl += trade.pnl;
433
+ }
434
+ existing.tradeCount++;
435
+ existing.lastPrice = trade.price;
436
+ existing.side = trade.side;
437
+ } else {
438
+ map.set(trade.symbol, {
439
+ pnl: trade.pnl ?? 0,
440
+ tradeCount: 1,
441
+ lastPrice: trade.price,
442
+ side: trade.side,
443
+ });
444
+ }
445
+ }
446
+
447
+ // Convert to array, filter out zero-trade symbols, sort by abs P&L desc
448
+ return Array.from(map.entries())
449
+ .map(([symbol, data]) => ({ symbol, ...data }))
450
+ .filter((p) => p.tradeCount > 0)
451
+ .sort((a, b) => Math.abs(b.pnl) - Math.abs(a.pnl));
452
+ }, [tradeStream]);
453
+
454
+ const totalPnl = positions.reduce((sum, p) => sum + p.pnl, 0);
455
+
456
+ return (
457
+ <Panel elevated={false} compact title="SYMBOL P&L">
458
+ {/* Total P&L (stream rollup, not exchange inventory) */}
459
+ <box flexDirection="row" gap={2} alignItems="center">
460
+ <text fg={Colors.muted} dim>
461
+ (stream)
462
+ </text>
463
+ <text fg={totalPnl >= 0 ? Colors.success : Colors.error} bold>
464
+ {formatPnL(totalPnl)}
465
+ </text>
466
+ </box>
467
+
468
+ {/* Column header */}
469
+ <box flexDirection="row" gap={1} paddingTop={0}>
470
+ <text fg={Colors.muted} dim>
471
+ SYMBOL
472
+ </text>
473
+ <text fg={Colors.muted} dim>
474
+ SIDE
475
+ </text>
476
+ <text fg={Colors.muted} dim>
477
+ P&L
478
+ </text>
479
+ </box>
480
+
481
+ {positions.length === 0 ? (
482
+ <box paddingTop={1} flexGrow={1}>
483
+ <EmptyState message="No stream P&L yet" icon="📊" />
484
+ </box>
485
+ ) : (
486
+ <scrollbox
487
+ width="100%"
488
+ flexGrow={1}
489
+ height={5}
490
+ paddingX={1}
491
+ paddingY={0}
492
+ >
493
+ {positions.map((pos) => (
494
+ <box key={pos.symbol} flexDirection="row" gap={1}>
495
+ {/* Symbol */}
496
+ <text fg={Colors.foreground} bold>
497
+ {pos.symbol.padEnd(6)}
498
+ </text>
499
+
500
+ {/* Side badge */}
501
+ <text fg={SIDE_COLOR[pos.side]} bold>
502
+ {SIDE_LABEL[pos.side]}
503
+ </text>
504
+
505
+ {/* P&L — green for profit, red for loss */}
506
+ <text
507
+ fg={pos.pnl >= 0 ? Colors.success : Colors.error}
508
+ bold={Math.abs(pos.pnl) > 0}
509
+ >
510
+ {formatPnL(pos.pnl)}
511
+ </text>
512
+ </box>
513
+ ))}
514
+ </scrollbox>
515
+ )}
516
+
517
+ {/* Total row */}
518
+ {positions.length > 0 && (
519
+ <box flexDirection="row" gap={1} paddingTop={0}>
520
+ <text fg={Colors.muted} dim>
521
+ TOTAL
522
+ </text>
523
+ <text fg={totalPnl >= 0 ? Colors.success : Colors.error} bold>
524
+ {formatPnL(totalPnl)}
525
+ </text>
526
+ </box>
527
+ )}
528
+ </Panel>
529
+ );
530
+ }
531
+
532
+ /**
533
+ * PerformanceSummary — key metrics panel.
534
+ *
535
+ * Shows:
536
+ * - Today P&L (trades since midnight)
537
+ * - Week P&L (trades since 7 days ago)
538
+ * - Month P&L (trades since 30 days ago)
539
+ * - WinRate % (trades with positive pnl / trades with pnl data)
540
+ * - Sharpe ratio (calculated from trade returns when enough data)
541
+ */
542
+ function PerformanceSummary() {
543
+ const tradeStream = useServiceStore((s) => s.tradeStream);
544
+ const metrics = useServiceStore((s) => s.metrics);
545
+
546
+ const perf = useMemo(() => {
547
+ const todayStart = startOfToday();
548
+ const weekStart = startOfDaysAgo(7);
549
+ const monthStart = startOfDaysAgo(30);
550
+
551
+ let todayPnl = 0;
552
+ let weekPnl = 0;
553
+ let monthPnl = 0;
554
+ let winCount = 0;
555
+ let totalWithPnl = 0;
556
+
557
+ for (const trade of tradeStream) {
558
+ const pnl = trade.pnl ?? 0;
559
+ if (trade.timestamp >= todayStart) todayPnl += pnl;
560
+ if (trade.timestamp >= weekStart) weekPnl += pnl;
561
+ if (trade.timestamp >= monthStart) monthPnl += pnl;
562
+
563
+ if (trade.pnl !== undefined) {
564
+ totalWithPnl++;
565
+ if (trade.pnl > 0) winCount++;
566
+ }
567
+ }
568
+
569
+ const winRate = totalWithPnl > 0 ? winCount / totalWithPnl : 0;
570
+
571
+ // Sharpe ratio: (mean return / std deviation of returns) * sqrt(252)
572
+ // Using trade pnl values as returns proxy, annualized
573
+ let sharpe = 0;
574
+ if (totalWithPnl >= 5) {
575
+ const returns = tradeStream
576
+ .filter((t) => t.pnl !== undefined)
577
+ .map((t) => t.pnl!);
578
+ const mean = returns.reduce((s, r) => s + r, 0) / returns.length;
579
+ const variance =
580
+ returns.reduce((s, r) => s + Math.pow(r - mean, 2), 0) / returns.length;
581
+ const stdDev = Math.sqrt(variance);
582
+ if (stdDev > 0 && mean !== 0) {
583
+ sharpe = (mean / stdDev) * Math.sqrt(252);
584
+ }
585
+ }
586
+
587
+ return { todayPnl, weekPnl, monthPnl, winRate, sharpe, totalWithPnl };
588
+ }, [tradeStream]);
589
+
590
+ // Also show overall P&L from system metrics if available
591
+ const overallPnl = metrics?.totalPnl;
592
+
593
+ // Two metrics per row — Panel chrome costs height in the narrow right column.
594
+ // Never nest <text> inside <text> (OpenTUI rule).
595
+ const sharpeLabel = perf.totalWithPnl >= 5 ? perf.sharpe.toFixed(2) : "N/A";
596
+ const sharpeColor =
597
+ perf.totalWithPnl < 5
598
+ ? Colors.muted
599
+ : perf.sharpe >= 1
600
+ ? Colors.success
601
+ : perf.sharpe >= 0
602
+ ? Colors.warning
603
+ : Colors.error;
604
+
605
+ return (
606
+ <Panel elevated={false} compact title="PERFORMANCE">
607
+ <box flexDirection="row" gap={1}>
608
+ <text fg={Colors.muted} dim>
609
+ Today
610
+ </text>
611
+ <text fg={perf.todayPnl >= 0 ? Colors.success : Colors.error} bold>
612
+ {formatPnL(perf.todayPnl)}
613
+ </text>
614
+ <text fg={Colors.muted} dim>
615
+ 7-Day
616
+ </text>
617
+ <text fg={perf.weekPnl >= 0 ? Colors.success : Colors.error} bold>
618
+ {formatPnL(perf.weekPnl)}
619
+ </text>
620
+ </box>
621
+ <box flexDirection="row" gap={1}>
622
+ <text fg={Colors.muted} dim>
623
+ 30-Day
624
+ </text>
625
+ <text fg={perf.monthPnl >= 0 ? Colors.success : Colors.error} bold>
626
+ {formatPnL(perf.monthPnl)}
627
+ </text>
628
+ <text fg={Colors.muted} dim>
629
+ WinRate
630
+ </text>
631
+ <text fg={perf.winRate >= 0.5 ? Colors.success : Colors.warning} bold>
632
+ {formatPct(perf.winRate)}
633
+ </text>
634
+ </box>
635
+ <box flexDirection="row" gap={1}>
636
+ <text fg={Colors.muted} dim>
637
+ Sharpe
638
+ </text>
639
+ <text
640
+ fg={sharpeColor}
641
+ bold={perf.totalWithPnl >= 5}
642
+ dim={perf.totalWithPnl < 5}
643
+ >
644
+ {sharpeLabel}
645
+ </text>
646
+ {overallPnl !== undefined && overallPnl !== null ? (
647
+ <>
648
+ <text fg={Colors.muted} dim>
649
+ Total P&L
650
+ </text>
651
+ <text fg={overallPnl >= 0 ? Colors.success : Colors.error} bold>
652
+ {formatPnL(overallPnl)}
653
+ </text>
654
+ </>
655
+ ) : null}
656
+ </box>
657
+ {tradeStream.length === 0 ? (
658
+ <text fg={Colors.muted} dim>
659
+ Awaiting trade data...
660
+ </text>
661
+ ) : null}
662
+ </Panel>
663
+ );
664
+ }
665
+
666
+ // ─── Main View ───────────────────────────────────────────────────────────────
667
+
668
+ /**
669
+ * TradeMonitor — full trade monitoring view.
670
+ *
671
+ * Composes: Header → [LiveTradeFeed | OpenPositions + PerformanceSummary]
672
+ * Wrapped in an ErrorBoundary for crash recovery.
673
+ *
674
+ * Keyboard:
675
+ * Space — toggle pause/resume of live feed
676
+ * ↑↓ — navigate trade rows in live feed
677
+ *
678
+ * View subscribes to service-store (tradeStream, metrics) and
679
+ * re-renders on data changes via Zustand selectors.
680
+ */
681
+ export function TradeMonitor() {
682
+ const [paused, setPaused] = useState(false);
683
+ const tradeStream = useServiceStore((s) => s.tradeStream);
684
+ const connectionStatus = useServiceStore((s) => s.connectionStatus);
685
+ const activeView = useUIStore((s) => s.activeView);
686
+ const isActive = activeView === "trade-monitor";
687
+ const offline = connectionStatus === "offline";
688
+
689
+ // Keyboard: space toggles pause/resume only while this view is active
690
+ useKeyboard((key) => {
691
+ if (!isActive) return;
692
+ if (key.name === "space") {
693
+ setPaused((p) => !p);
694
+ }
695
+ });
696
+
697
+ return (
698
+ <ErrorBoundary viewName="Trade Monitor">
699
+ <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
700
+ {/* 1. Header */}
701
+ <TradeMonitorHeader
702
+ paused={paused}
703
+ tradeCount={tradeStream.length}
704
+ connectionStatus={connectionStatus}
705
+ />
706
+
707
+ {/* Divider */}
708
+ <text fg={Colors.border} dim>
709
+ {"─".repeat(78)}
710
+ </text>
711
+
712
+ {/* 2. Main panels: Feed (left) | Positions + Performance (right) */}
713
+ <box flexDirection="row" flexGrow={1} gap={1}>
714
+ {/* Left: Live Trade Feed */}
715
+ <box flexDirection="column" flexGrow={1}>
716
+ <LiveTradeFeed
717
+ paused={paused}
718
+ isActive={isActive}
719
+ offline={offline}
720
+ />
721
+ </box>
722
+
723
+ {/* Divider between panels */}
724
+ <text fg={Colors.border} dim>
725
+ │
726
+ </text>
727
+
728
+ {/* Right: Positions + Performance stacked */}
729
+ <box flexDirection="column" width={34} gap={1}>
730
+ {/* Open Positions */}
731
+ <OpenPositions />
732
+
733
+ {/* Section divider */}
734
+ <text fg={Colors.dim} dim>
735
+ {"─".repeat(32)}
736
+ </text>
737
+
738
+ {/* Performance Summary */}
739
+ <PerformanceSummary />
740
+ </box>
741
+ </box>
742
+ </box>
743
+ </ErrorBoundary>
744
+ );
745
+ }