@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.
- package/README.md +247 -0
- package/bin/hoox-tui.js +35 -0
- package/dist/main.js +17594 -0
- package/package.json +72 -0
- package/src/app.tsx +696 -0
- package/src/cli-bridge-test-double.ts +212 -0
- package/src/components/layout/layout.test.tsx +205 -0
- package/src/components/layout/sidebar.tsx +69 -0
- package/src/components/layout/statusbar.test.tsx +423 -0
- package/src/components/layout/statusbar.tsx +348 -0
- package/src/components/shared/animated-border.tsx +58 -0
- package/src/components/shared/chrome.test.tsx +29 -0
- package/src/components/shared/command-palette.tsx +361 -0
- package/src/components/shared/cool-brackets.tsx +75 -0
- package/src/components/shared/crash-screen.tsx +167 -0
- package/src/components/shared/error-boundary.tsx +99 -0
- package/src/components/shared/keybinding-hint.tsx +37 -0
- package/src/components/shared/panel.tsx +61 -0
- package/src/components/shared/quit-modal.tsx +65 -0
- package/src/components/shared/shared.test.tsx +538 -0
- package/src/components/shared/spinner.tsx +85 -0
- package/src/components/shared/status-dot.tsx +54 -0
- package/src/components/shared/view-header.tsx +53 -0
- package/src/components/ui/connection-toasts.test.ts +50 -0
- package/src/components/ui/connection-toasts.ts +215 -0
- package/src/components/ui/dialog.tsx +281 -0
- package/src/components/ui/select.tsx +197 -0
- package/src/components/ui/toast.tsx +185 -0
- package/src/components/views/ai-chat.test.tsx +81 -0
- package/src/components/views/ai-chat.tsx +576 -0
- package/src/components/views/config-editor/action-bar.tsx +102 -0
- package/src/components/views/config-editor/code-editor.tsx +597 -0
- package/src/components/views/config-editor/file-tree.tsx +113 -0
- package/src/components/views/config-editor/types.ts +37 -0
- package/src/components/views/config-editor.test.tsx +465 -0
- package/src/components/views/config-editor.tsx +709 -0
- package/src/components/views/dashboard/alerts-panel.tsx +154 -0
- package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
- package/src/components/views/dashboard/model-health-section.tsx +276 -0
- package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
- package/src/components/views/dashboard.test.tsx +347 -0
- package/src/components/views/dashboard.tsx +647 -0
- package/src/components/views/db-query.test.tsx +115 -0
- package/src/components/views/db-query.tsx +849 -0
- package/src/components/views/edge-topology.test.tsx +198 -0
- package/src/components/views/edge-topology.tsx +572 -0
- package/src/components/views/kv-viewer.test.tsx +241 -0
- package/src/components/views/kv-viewer.tsx +717 -0
- package/src/components/views/logs-viewer.test.tsx +273 -0
- package/src/components/views/logs-viewer.tsx +616 -0
- package/src/components/views/queue-depth.test.tsx +221 -0
- package/src/components/views/queue-depth.tsx +376 -0
- package/src/components/views/secrets-viewer.test.tsx +65 -0
- package/src/components/views/secrets-viewer.tsx +548 -0
- package/src/components/views/service-manager.test.tsx +358 -0
- package/src/components/views/service-manager.tsx +946 -0
- package/src/components/views/settings.test.tsx +648 -0
- package/src/components/views/settings.tsx +1497 -0
- package/src/components/views/setup-wizard.test.tsx +297 -0
- package/src/components/views/setup-wizard.tsx +1321 -0
- package/src/components/views/trade-monitor.test.tsx +592 -0
- package/src/components/views/trade-monitor.tsx +745 -0
- package/src/components/views/worker-detail.test.tsx +289 -0
- package/src/components/views/worker-detail.tsx +676 -0
- package/src/components/views/worker-settings.test.tsx +43 -0
- package/src/components/views/worker-settings.tsx +563 -0
- package/src/components/views/workers-overview.test.tsx +260 -0
- package/src/components/views/workers-overview.tsx +631 -0
- package/src/hooks/hooks.test.ts +72 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/renderer-ref.ts +22 -0
- package/src/hooks/use-keyboard.ts +130 -0
- package/src/hooks/use-polling.ts +73 -0
- package/src/hooks/use-service-data.ts +18 -0
- package/src/main.tsx +117 -0
- package/src/network-test-double.ts +133 -0
- package/src/opentui-augment.d.ts +45 -0
- package/src/opentui-jsx.d.ts +70 -0
- package/src/services/cli-bridge/index.ts +1662 -0
- package/src/services/cli-bridge/standalone.ts +315 -0
- package/src/services/cli-bridge/types.ts +307 -0
- package/src/services/cli-bridge.test.ts +406 -0
- package/src/services/clipboard.test.ts +57 -0
- package/src/services/clipboard.ts +180 -0
- package/src/services/dashboard-settings-loader.ts +31 -0
- package/src/services/dev-log.test.ts +168 -0
- package/src/services/dev-log.ts +194 -0
- package/src/services/hoox-path-service.test.ts +183 -0
- package/src/services/hoox-path-service.ts +185 -0
- package/src/services/tui-connection.test.ts +212 -0
- package/src/services/tui-connection.ts +236 -0
- package/src/services/tui-storage.test.ts +92 -0
- package/src/services/tui-storage.ts +86 -0
- package/src/stores/config-store.test.ts +360 -0
- package/src/stores/service-store.test.ts +1026 -0
- package/src/stores/session-restore.test.ts +134 -0
- package/src/stores/ui-store.test.ts +276 -0
- package/src/test-setup.ts +265 -0
- package/src/test-utils.ts +179 -0
- package/src/types.ts +153 -0
- package/src/utils/colors.test.ts +326 -0
- package/src/utils/formatters.test.ts +236 -0
- package/src/view-registry.test.ts +132 -0
- 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
|
+
}
|