horizon-code 0.3.3 → 0.5.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/package.json +1 -1
- package/src/ai/client.ts +2 -2
- package/src/app.ts +23 -9
- package/src/components/code-panel.ts +2 -2
- package/src/strategy/dashboard.ts +459 -217
- package/src/strategy/prompts.ts +212 -13
- package/src/strategy/tools.ts +211 -23
- package/src/strategy/validator.ts +25 -1
- package/src/strategy/workspace.ts +175 -0
- package/src/syntax/setup.ts +22 -4
|
@@ -1,295 +1,399 @@
|
|
|
1
|
-
// Dashboard
|
|
2
|
-
// Serves
|
|
1
|
+
// Dashboard — local HTTP server for live strategy monitoring
|
|
2
|
+
// Serves self-contained HTML dashboard with Chart.js
|
|
3
|
+
// Supports both local processes (via runningProcesses) and platform deployments
|
|
4
|
+
// File-based mode: reads HTML from disk on each request (edits reflect immediately)
|
|
3
5
|
|
|
4
6
|
import { platform } from "../platform/client.ts";
|
|
7
|
+
import { runningProcesses, parseLocalMetrics } from "./tools.ts";
|
|
8
|
+
import { store } from "../state/store.ts";
|
|
9
|
+
import { resolveSafePath, getWorkspaceRoot } from "./workspace.ts";
|
|
10
|
+
import { existsSync } from "fs";
|
|
5
11
|
|
|
6
|
-
|
|
12
|
+
// ── Dashboard HTML ──
|
|
13
|
+
|
|
14
|
+
function buildDashboardHTML(meta: { name: string; strategyId: string; isLocal: boolean }): string {
|
|
15
|
+
const safeId = meta.strategyId.replace(/[^a-zA-Z0-9-]/g, "");
|
|
16
|
+
return `<!DOCTYPE html>
|
|
7
17
|
<html lang="en">
|
|
8
18
|
<head>
|
|
9
19
|
<meta charset="utf-8">
|
|
10
20
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
11
|
-
<title>Horizon — Strategy
|
|
21
|
+
<title>Horizon — ${meta.name || "Strategy"}</title>
|
|
12
22
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
|
13
23
|
<style>
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
24
|
+
:root {
|
|
25
|
+
--bg: #0d1117; --bg2: #161b22; --bg3: #1c2128;
|
|
26
|
+
--border: #30363d; --border-focus: #4d8ef7;
|
|
27
|
+
--text: #c9d1d9; --text-dim: #636e7b; --text-bright: #f0f6fc;
|
|
28
|
+
--accent: #4d8ef7; --accent-dim: #2557a7;
|
|
29
|
+
--green: #3fb950; --red: #f85149; --yellow: #d29922;
|
|
30
|
+
--radius: 12px;
|
|
31
|
+
}
|
|
32
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
33
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
34
|
+
|
|
35
|
+
.header {
|
|
36
|
+
padding: 14px 24px; border-bottom: 1px solid var(--border);
|
|
37
|
+
display: flex; align-items: center; gap: 16px;
|
|
38
|
+
background: var(--bg2);
|
|
39
|
+
}
|
|
40
|
+
.header .logo { font-size: 11px; color: var(--accent); letter-spacing: 3px; font-weight: 600; }
|
|
41
|
+
.header .name { font-size: 13px; color: var(--text-bright); font-weight: 500; }
|
|
42
|
+
.header .badge {
|
|
43
|
+
font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600; letter-spacing: 0.5px;
|
|
44
|
+
}
|
|
45
|
+
.badge.local { background: rgba(63,185,80,0.15); color: var(--green); border: 1px solid rgba(63,185,80,0.3); }
|
|
46
|
+
.badge.platform { background: rgba(77,142,247,0.15); color: var(--accent); border: 1px solid rgba(77,142,247,0.3); }
|
|
47
|
+
.header .status { margin-left: auto; font-size: 12px; display: flex; align-items: center; gap: 6px; }
|
|
48
|
+
.header .dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
49
|
+
.dot.live { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
|
50
|
+
.dot.off { background: var(--red); }
|
|
51
|
+
.header .uptime { font-size: 11px; color: var(--text-dim); }
|
|
52
|
+
|
|
53
|
+
.metrics-row {
|
|
54
|
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
55
|
+
gap: 1px; background: var(--border); margin: 16px 24px; border-radius: var(--radius); overflow: hidden;
|
|
56
|
+
}
|
|
57
|
+
.metric {
|
|
58
|
+
background: var(--bg2); padding: 14px 16px;
|
|
59
|
+
}
|
|
60
|
+
.metric .label { font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; }
|
|
61
|
+
.metric .value { font-size: 22px; font-weight: 600; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
|
62
|
+
.metric .sub { font-size: 10px; color: var(--text-dim); margin-top: 2px; }
|
|
63
|
+
|
|
64
|
+
.grid { display: grid; grid-template-columns: 5fr 2fr; gap: 16px; padding: 0 24px 16px; }
|
|
65
|
+
.card {
|
|
66
|
+
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
|
|
67
|
+
}
|
|
68
|
+
.card-header {
|
|
69
|
+
padding: 10px 16px; border-bottom: 1px solid var(--border); font-size: 11px;
|
|
70
|
+
color: var(--text-dim); text-transform: uppercase; letter-spacing: 1px; font-weight: 600;
|
|
71
|
+
}
|
|
72
|
+
.card-body { padding: 16px; }
|
|
73
|
+
|
|
74
|
+
.positions-list { display: flex; flex-direction: column; gap: 8px; }
|
|
75
|
+
.pos-row {
|
|
76
|
+
display: flex; align-items: center; gap: 10px; padding: 8px 0;
|
|
77
|
+
border-bottom: 1px solid var(--border); font-size: 12px;
|
|
78
|
+
}
|
|
79
|
+
.pos-row:last-child { border-bottom: none; }
|
|
80
|
+
.pos-side { font-weight: 600; width: 36px; }
|
|
81
|
+
.pos-side.buy { color: var(--green); }
|
|
82
|
+
.pos-side.sell { color: var(--red); }
|
|
83
|
+
.pos-market { flex: 1; color: var(--text); }
|
|
84
|
+
.pos-info { color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
|
85
|
+
.pos-pnl { font-weight: 600; min-width: 60px; text-align: right; font-variant-numeric: tabular-nums; }
|
|
86
|
+
|
|
87
|
+
.logs-card { margin: 0 24px 16px; }
|
|
88
|
+
.log-scroll { max-height: 180px; overflow-y: auto; padding: 12px 16px; }
|
|
89
|
+
.log-line { font-size: 11px; color: var(--text-dim); padding: 1px 0; font-family: 'SF Mono', 'Fira Code', monospace; white-space: pre-wrap; word-break: break-all; }
|
|
90
|
+
|
|
91
|
+
.empty { padding: 24px; text-align: center; color: var(--text-dim); font-size: 12px; }
|
|
92
|
+
.pulse { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-right: 6px; animation: pulse 1.5s ease-in-out infinite; }
|
|
93
|
+
@keyframes pulse { 0%,100% { opacity: 0.3; } 50% { opacity: 1; } }
|
|
94
|
+
|
|
95
|
+
.green { color: var(--green); }
|
|
96
|
+
.red { color: var(--red); }
|
|
97
|
+
.yellow { color: var(--yellow); }
|
|
38
98
|
</style>
|
|
39
99
|
</head>
|
|
40
100
|
<body>
|
|
41
101
|
<div class="header">
|
|
42
|
-
<
|
|
43
|
-
<span class="
|
|
44
|
-
<span class="
|
|
102
|
+
<span class="logo">HORIZON</span>
|
|
103
|
+
<span class="name" id="stratName">${meta.name || safeId}</span>
|
|
104
|
+
<span class="badge ${meta.isLocal ? "local" : "platform"}">${meta.isLocal ? "LOCAL" : "PLATFORM"}</span>
|
|
105
|
+
<div class="status">
|
|
106
|
+
<span class="dot" id="dot"></span>
|
|
107
|
+
<span id="statusText">connecting...</span>
|
|
108
|
+
</div>
|
|
45
109
|
<span class="uptime" id="uptime"></span>
|
|
46
110
|
</div>
|
|
47
|
-
|
|
48
|
-
<div class="
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<
|
|
111
|
+
|
|
112
|
+
<div class="metrics-row" id="metricsRow"></div>
|
|
113
|
+
|
|
114
|
+
<div class="grid">
|
|
115
|
+
<div class="card">
|
|
116
|
+
<div class="card-header">Equity Curve</div>
|
|
117
|
+
<div class="card-body">
|
|
118
|
+
<div id="chartEmpty" class="empty"><span class="pulse"></span>Waiting for data...</div>
|
|
119
|
+
<canvas id="equity" style="display:none" height="220"></canvas>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="card">
|
|
123
|
+
<div class="card-header">Positions</div>
|
|
124
|
+
<div class="card-body positions-list" id="positions">
|
|
125
|
+
<div class="empty"><span class="pulse"></span>No positions</div>
|
|
126
|
+
</div>
|
|
53
127
|
</div>
|
|
54
|
-
<div class="chart-box"><h3>Positions</h3><div id="positions"><div class="waiting"><span class="pulse"></span>Waiting for data...</div></div></div>
|
|
55
128
|
</div>
|
|
56
|
-
<div class="logs"><div class="logs-box"><h3>Logs</h3><div id="logs"><div class="waiting"><span class="pulse"></span>Waiting for logs...</div></div></div></div>
|
|
57
|
-
<script>
|
|
58
|
-
const SID = "${strategyId.replace(/[^a-zA-Z0-9-]/g, "")}";
|
|
59
|
-
const POLL_INTERVAL_S = 30; // metrics are polled every 30s
|
|
60
|
-
let equityChart = null;
|
|
61
|
-
let prevHistLen = 0;
|
|
62
|
-
let strategyStartedAt = null;
|
|
63
|
-
|
|
64
|
-
// --- Fetch strategy metadata once on load ---
|
|
65
|
-
(async function loadStrategy() {
|
|
66
|
-
try {
|
|
67
|
-
const res = await fetch("/api/strategy");
|
|
68
|
-
if (!res.ok) return;
|
|
69
|
-
const data = await res.json();
|
|
70
|
-
const name = data.name || data.strategy_name || data.title || SID;
|
|
71
|
-
document.getElementById("strategyName").textContent = "/ " + name;
|
|
72
|
-
// Try to find a start timestamp for uptime calculation
|
|
73
|
-
strategyStartedAt = data.started_at || data.created_at || data.deployed_at || null;
|
|
74
|
-
if (strategyStartedAt) updateUptime();
|
|
75
|
-
} catch { /* non-critical */ }
|
|
76
|
-
})();
|
|
77
129
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const h = Math.floor(diffS / 3600);
|
|
85
|
-
const m = Math.floor((diffS % 3600) / 60);
|
|
86
|
-
const s = diffS % 60;
|
|
87
|
-
let str = "";
|
|
88
|
-
if (h > 0) str += h + "h ";
|
|
89
|
-
if (m > 0 || h > 0) str += m + "m ";
|
|
90
|
-
str += s + "s";
|
|
91
|
-
document.getElementById("uptime").textContent = "up " + str;
|
|
92
|
-
}
|
|
93
|
-
setInterval(updateUptime, 1000);
|
|
130
|
+
<div class="card logs-card">
|
|
131
|
+
<div class="card-header">Logs</div>
|
|
132
|
+
<div class="log-scroll" id="logs">
|
|
133
|
+
<div class="empty"><span class="pulse"></span>Waiting for logs...</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
94
136
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
137
|
+
<script>
|
|
138
|
+
const SID = "${safeId}";
|
|
139
|
+
const IS_LOCAL = ${meta.isLocal};
|
|
140
|
+
let chart = null, prevLen = 0, startedAt = Date.now();
|
|
100
141
|
|
|
101
|
-
function
|
|
102
|
-
const n = parseFloat(
|
|
142
|
+
function fmtPnl(v) {
|
|
143
|
+
const n = parseFloat(v);
|
|
103
144
|
if (isNaN(n)) return "$0.00";
|
|
104
|
-
|
|
105
|
-
return sign + Math.abs(n).toFixed(2);
|
|
145
|
+
return (n >= 0 ? "+$" : "-$") + Math.abs(n).toFixed(2);
|
|
106
146
|
}
|
|
147
|
+
function pnlCls(v) { const n = parseFloat(v); return isNaN(n) || n === 0 ? "" : n > 0 ? "green" : "red"; }
|
|
107
148
|
|
|
108
|
-
function
|
|
109
|
-
// Each data point is ~POLL_INTERVAL_S apart, most recent is "now"
|
|
149
|
+
function timeLabels(count, interval) {
|
|
110
150
|
const labels = [];
|
|
111
151
|
for (let i = 0; i < count; i++) {
|
|
112
|
-
const secsAgo = (count - 1 - i) *
|
|
152
|
+
const secsAgo = (count - 1 - i) * interval;
|
|
113
153
|
if (secsAgo === 0) { labels.push("now"); continue; }
|
|
114
154
|
const m = Math.floor(secsAgo / 60);
|
|
115
|
-
|
|
116
|
-
else if (m < 60) { labels.push(m + "m"); }
|
|
117
|
-
else { labels.push(Math.floor(m / 60) + "h" + (m % 60) + "m"); }
|
|
155
|
+
labels.push(m < 1 ? secsAgo + "s" : m < 60 ? m + "m" : Math.floor(m/60) + "h" + (m%60) + "m");
|
|
118
156
|
}
|
|
119
157
|
return labels;
|
|
120
158
|
}
|
|
121
159
|
|
|
122
|
-
|
|
160
|
+
function updateUptime() {
|
|
161
|
+
const s = Math.floor((Date.now() - startedAt) / 1000);
|
|
162
|
+
const h = Math.floor(s / 3600), m = Math.floor((s%3600)/60);
|
|
163
|
+
document.getElementById("uptime").textContent = (h > 0 ? h+"h " : "") + m + "m " + (s%60) + "s";
|
|
164
|
+
}
|
|
165
|
+
setInterval(updateUptime, 1000);
|
|
166
|
+
|
|
167
|
+
async function fetchData(path) {
|
|
168
|
+
// Try local endpoints first, fall back to platform
|
|
169
|
+
if (IS_LOCAL) {
|
|
170
|
+
try {
|
|
171
|
+
const r = await fetch("/api/local" + path);
|
|
172
|
+
if (r.ok) return await r.json();
|
|
173
|
+
} catch {}
|
|
174
|
+
}
|
|
123
175
|
try {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
},
|
|
183
|
-
},
|
|
176
|
+
const r = await fetch("/api" + path);
|
|
177
|
+
if (r.ok) return await r.json();
|
|
178
|
+
} catch {}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function refresh() {
|
|
183
|
+
const m = await fetchData("/metrics");
|
|
184
|
+
const l = await fetchData("/logs");
|
|
185
|
+
|
|
186
|
+
if (!m) {
|
|
187
|
+
document.getElementById("dot").className = "dot off";
|
|
188
|
+
document.getElementById("statusText").textContent = "disconnected";
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
document.getElementById("dot").className = "dot live";
|
|
193
|
+
document.getElementById("statusText").textContent = "live";
|
|
194
|
+
if (m.uptime) startedAt = Date.now() - m.uptime * 1000;
|
|
195
|
+
|
|
196
|
+
// Metrics bar
|
|
197
|
+
const pnl = m.pnl ?? 0;
|
|
198
|
+
const row = document.getElementById("metricsRow");
|
|
199
|
+
row.innerHTML = [
|
|
200
|
+
["P&L", fmtPnl(pnl), pnlCls(pnl), m.rpnl != null ? "Realized " + fmtPnl(m.rpnl) : ""],
|
|
201
|
+
["Win Rate", ((m.win_rate||0)*100).toFixed(1)+"%", (m.win_rate||0) >= 0.5 ? "green" : "", "Trades: " + (m.trades||0)],
|
|
202
|
+
["Sharpe", (m.sharpe||0).toFixed(2), (m.sharpe||0) > 1 ? "green" : (m.sharpe||0) > 0 ? "" : "red", ""],
|
|
203
|
+
["Max DD", (m.max_dd||0).toFixed(1)+"%", (m.max_dd||0) > 5 ? "red" : "yellow", ""],
|
|
204
|
+
["Exposure", "$"+(m.exposure||0).toFixed(2), "", "Positions: " + (m.positions||0)],
|
|
205
|
+
["Orders", m.orders||0, "", m.kill ? "KILL SWITCH" : ""],
|
|
206
|
+
].map(([label, value, cls, sub]) =>
|
|
207
|
+
'<div class="metric"><div class="label">'+label+'</div><div class="value '+(cls||"")+'">'+value+'</div>'+(sub?'<div class="sub">'+sub+'</div>':'')+'</div>'
|
|
208
|
+
).join("");
|
|
209
|
+
|
|
210
|
+
// Equity chart
|
|
211
|
+
const hist = m.hist || [];
|
|
212
|
+
if (hist.length > 0) {
|
|
213
|
+
document.getElementById("chartEmpty").style.display = "none";
|
|
214
|
+
document.getElementById("equity").style.display = "block";
|
|
215
|
+
const labels = timeLabels(hist.length, 5);
|
|
216
|
+
|
|
217
|
+
if (!chart) {
|
|
218
|
+
chart = new Chart(document.getElementById("equity"), {
|
|
219
|
+
type: "line",
|
|
220
|
+
data: {
|
|
221
|
+
labels,
|
|
222
|
+
datasets: [{
|
|
223
|
+
data: hist, borderColor: "rgba(77,142,247,0.8)", borderWidth: 1.5,
|
|
224
|
+
fill: { target: "origin", above: "rgba(77,142,247,0.06)", below: "rgba(248,81,73,0.06)" },
|
|
225
|
+
pointRadius: 0, tension: 0.2,
|
|
226
|
+
}],
|
|
227
|
+
},
|
|
228
|
+
options: {
|
|
229
|
+
animation: false, responsive: true, maintainAspectRatio: false,
|
|
230
|
+
plugins: { legend: { display: false } },
|
|
231
|
+
scales: {
|
|
232
|
+
x: { ticks: { color: "#636e7b", font: { size: 10 }, maxTicksLimit: 8 }, grid: { color: "#21262d" } },
|
|
233
|
+
y: { ticks: { color: "#636e7b", callback: v => "$"+v.toFixed(2) }, grid: { color: "#21262d" } },
|
|
184
234
|
},
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
equityChart.data.datasets[0].data.push(hist[i]);
|
|
194
|
-
}
|
|
195
|
-
// Cap at 60 data points
|
|
196
|
-
while (equityChart.data.labels.length > 60) {
|
|
197
|
-
equityChart.data.labels.shift();
|
|
198
|
-
equityChart.data.datasets[0].data.shift();
|
|
199
|
-
}
|
|
200
|
-
// Recompute time labels after shift
|
|
201
|
-
const currentLen = equityChart.data.labels.length;
|
|
202
|
-
equityChart.data.labels = makeTimeLabels(currentLen);
|
|
203
|
-
} else {
|
|
204
|
-
// Full reset (data shrank or jumped)
|
|
205
|
-
equityChart.data.labels = labels;
|
|
206
|
-
equityChart.data.datasets[0].data = hist;
|
|
207
|
-
}
|
|
208
|
-
prevHistLen = hist.length;
|
|
209
|
-
equityChart.update("none"); // "none" mode skips animation
|
|
210
|
-
}
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
prevLen = hist.length;
|
|
238
|
+
} else if (hist.length !== prevLen) {
|
|
239
|
+
chart.data.labels = timeLabels(hist.length, 5);
|
|
240
|
+
chart.data.datasets[0].data = hist;
|
|
241
|
+
prevLen = hist.length;
|
|
242
|
+
chart.update("none");
|
|
211
243
|
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Positions
|
|
247
|
+
const pos = m.pos || [];
|
|
248
|
+
document.getElementById("positions").innerHTML = pos.length === 0
|
|
249
|
+
? '<div class="empty">No open positions</div>'
|
|
250
|
+
: pos.map(p => {
|
|
251
|
+
const side = (p.side||"").includes("Yes") || (p.side||"").includes("Buy") ? "buy" : "sell";
|
|
252
|
+
const pnlVal = (p.rpnl||0) + (p.upnl||0);
|
|
253
|
+
return '<div class="pos-row">'
|
|
254
|
+
+ '<span class="pos-side '+side+'">'+(side==="buy"?"BUY":"SELL")+'</span>'
|
|
255
|
+
+ '<span class="pos-market">'+(p.id||"")+'</span>'
|
|
256
|
+
+ '<span class="pos-info">'+p.sz+' @ '+Number(p.entry).toFixed(3)+'</span>'
|
|
257
|
+
+ '<span class="pos-pnl '+pnlCls(pnlVal)+'">'+fmtPnl(pnlVal)+'</span>'
|
|
258
|
+
+ '</div>';
|
|
259
|
+
}).join("");
|
|
212
260
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
// --- Logs ---
|
|
225
|
-
const logs = lRes.logs || lRes || [];
|
|
226
|
-
const logArr = Array.isArray(logs) ? logs : [];
|
|
227
|
-
document.getElementById("logs").innerHTML = logArr.length === 0
|
|
228
|
-
? '<div class="waiting"><span class="pulse"></span>Waiting for logs...</div>'
|
|
229
|
-
: logArr.slice(-20).map(l =>
|
|
230
|
-
'<div class="log-line">'+(l.message||l.text||JSON.stringify(l))+'</div>'
|
|
231
|
-
).join("");
|
|
232
|
-
|
|
233
|
-
} catch {
|
|
234
|
-
document.getElementById("status").textContent = "● disconnected";
|
|
235
|
-
document.getElementById("status").style.color = "#e06c75";
|
|
261
|
+
// Logs
|
|
262
|
+
if (l) {
|
|
263
|
+
const lines = Array.isArray(l) ? l : (l.logs || l.lines || []);
|
|
264
|
+
if (lines.length > 0) {
|
|
265
|
+
document.getElementById("logs").innerHTML = lines.slice(-30).map(line => {
|
|
266
|
+
const text = typeof line === "string" ? line : (line.message || line.text || JSON.stringify(line));
|
|
267
|
+
return '<div class="log-line">'+text.replace(/</g,"<")+'</div>';
|
|
268
|
+
}).join("");
|
|
269
|
+
document.getElementById("logs").scrollTop = document.getElementById("logs").scrollHeight;
|
|
270
|
+
}
|
|
236
271
|
}
|
|
237
272
|
}
|
|
273
|
+
|
|
238
274
|
refresh();
|
|
239
|
-
setInterval(refresh, 3000);
|
|
275
|
+
setInterval(refresh, ${meta.isLocal ? 3000 : 10000});
|
|
240
276
|
</script>
|
|
241
277
|
</body>
|
|
242
278
|
</html>`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Dashboard Server ──
|
|
282
|
+
|
|
283
|
+
// Error-catching script injected before </head> in file-based dashboards
|
|
284
|
+
const ERROR_CAPTURE_SCRIPT = `<script>
|
|
285
|
+
(function(){
|
|
286
|
+
var _hzErrors = [];
|
|
287
|
+
window.onerror = function(msg, src, line, col, err) {
|
|
288
|
+
_hzErrors.push({type:"error", message:String(msg), source:src, line:line, col:col, stack:err?err.stack:null, ts:Date.now()});
|
|
289
|
+
if (_hzErrors.length > 50) _hzErrors.shift();
|
|
290
|
+
try { fetch("/api/error",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(_hzErrors[_hzErrors.length-1])}); } catch(e){}
|
|
291
|
+
};
|
|
292
|
+
window.addEventListener("unhandledrejection", function(ev) {
|
|
293
|
+
var msg = ev.reason ? (ev.reason.message || String(ev.reason)) : "Unhandled promise rejection";
|
|
294
|
+
_hzErrors.push({type:"unhandledrejection", message:msg, stack:ev.reason?ev.reason.stack:null, ts:Date.now()});
|
|
295
|
+
if (_hzErrors.length > 50) _hzErrors.shift();
|
|
296
|
+
try { fetch("/api/error",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(_hzErrors[_hzErrors.length-1])}); } catch(e){}
|
|
297
|
+
});
|
|
298
|
+
})();
|
|
299
|
+
</script>`;
|
|
300
|
+
|
|
301
|
+
function injectErrorScript(html: string): string {
|
|
302
|
+
// Inject before </head> if present, otherwise before </body>, otherwise prepend
|
|
303
|
+
if (html.includes("</head>")) {
|
|
304
|
+
return html.replace("</head>", ERROR_CAPTURE_SCRIPT + "\n</head>");
|
|
305
|
+
} else if (html.includes("</body>")) {
|
|
306
|
+
return html.replace("</body>", ERROR_CAPTURE_SCRIPT + "\n</body>");
|
|
307
|
+
}
|
|
308
|
+
return ERROR_CAPTURE_SCRIPT + "\n" + html;
|
|
309
|
+
}
|
|
243
310
|
|
|
244
311
|
export class DashboardServer {
|
|
245
312
|
private server: ReturnType<typeof Bun.serve> | null = null;
|
|
246
313
|
private _port = 0;
|
|
247
314
|
private _strategyId = "";
|
|
315
|
+
private _isLocal = false;
|
|
316
|
+
private _capturedErrors: any[] = [];
|
|
248
317
|
|
|
249
318
|
get port(): number { return this._port; }
|
|
250
319
|
get url(): string { return `http://localhost:${this._port}`; }
|
|
251
320
|
get running(): boolean { return this.server !== null; }
|
|
321
|
+
get capturedErrors(): any[] { return this._capturedErrors; }
|
|
252
322
|
|
|
253
|
-
start(strategyId: string, port = 0): string {
|
|
323
|
+
start(strategyId: string, port = 0, isLocal = false): string {
|
|
254
324
|
if (this.server) this.stop();
|
|
255
325
|
|
|
256
326
|
this._strategyId = strategyId;
|
|
327
|
+
this._isLocal = isLocal;
|
|
328
|
+
|
|
329
|
+
const draft = store.getActiveSession()?.strategyDraft;
|
|
330
|
+
const name = draft?.name ?? strategyId;
|
|
257
331
|
|
|
258
332
|
this.server = Bun.serve({
|
|
259
333
|
port: port || 0,
|
|
260
|
-
hostname: "127.0.0.1",
|
|
334
|
+
hostname: "127.0.0.1",
|
|
261
335
|
fetch: async (req) => {
|
|
262
336
|
const url = new URL(req.url);
|
|
263
337
|
|
|
338
|
+
// ── Local process metrics ──
|
|
339
|
+
if (url.pathname === "/api/local/metrics") {
|
|
340
|
+
const metrics = this.getLocalMetrics();
|
|
341
|
+
if (metrics) return Response.json(metrics);
|
|
342
|
+
return Response.json({ error: "No local process" }, { status: 404 });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Local process logs ──
|
|
346
|
+
if (url.pathname === "/api/local/logs") {
|
|
347
|
+
const logs = this.getLocalLogs();
|
|
348
|
+
return Response.json(logs);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Platform metrics (deployed strategies) ──
|
|
264
352
|
if (url.pathname === "/api/metrics") {
|
|
265
353
|
try {
|
|
266
|
-
const data = await platform.getMetrics(strategyId, 20);
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return Response.json({
|
|
354
|
+
const data: any = await platform.getMetrics(strategyId, 20);
|
|
355
|
+
// Normalize to flat format for the dashboard
|
|
356
|
+
const latest: any = data?.latest ?? {};
|
|
357
|
+
return Response.json({
|
|
358
|
+
pnl: latest.total_pnl ?? 0,
|
|
359
|
+
rpnl: latest.realized_pnl ?? 0,
|
|
360
|
+
upnl: latest.unrealized_pnl ?? 0,
|
|
361
|
+
orders: latest.open_order_count ?? 0,
|
|
362
|
+
positions: latest.position_count ?? 0,
|
|
363
|
+
trades: latest.total_trades ?? 0,
|
|
364
|
+
win_rate: latest.win_rate ?? 0,
|
|
365
|
+
sharpe: latest.sharpe_ratio ?? 0,
|
|
366
|
+
max_dd: latest.max_drawdown_pct ?? 0,
|
|
367
|
+
exposure: latest.total_exposure ?? 0,
|
|
368
|
+
kill: false,
|
|
369
|
+
pos: (latest.positions ?? []).map((p: any) => ({
|
|
370
|
+
id: p.slug ?? p.market_id, side: p.side, sz: p.size,
|
|
371
|
+
entry: p.avg_entry_price, rpnl: p.realized_pnl ?? 0, upnl: p.unrealized_pnl ?? 0,
|
|
372
|
+
})),
|
|
373
|
+
hist: (data?.pnl_history ?? []).map((h: any) => h.total_pnl ?? 0),
|
|
374
|
+
});
|
|
375
|
+
} catch {
|
|
376
|
+
return Response.json({ error: "Platform unavailable" }, { status: 502 });
|
|
270
377
|
}
|
|
271
378
|
}
|
|
272
379
|
|
|
380
|
+
// ── Platform logs ──
|
|
273
381
|
if (url.pathname === "/api/logs") {
|
|
274
382
|
try {
|
|
275
383
|
const data = await platform.getLogs(strategyId, 50);
|
|
276
384
|
return Response.json(data);
|
|
277
|
-
} catch
|
|
278
|
-
return Response.json(
|
|
385
|
+
} catch {
|
|
386
|
+
return Response.json([], { status: 502 });
|
|
279
387
|
}
|
|
280
388
|
}
|
|
281
389
|
|
|
390
|
+
// ── Strategy metadata ──
|
|
282
391
|
if (url.pathname === "/api/strategy") {
|
|
283
|
-
|
|
284
|
-
const data = await platform.getStrategy(strategyId);
|
|
285
|
-
return Response.json(data);
|
|
286
|
-
} catch (err) {
|
|
287
|
-
return Response.json({ error: String(err) }, { status: 500 });
|
|
288
|
-
}
|
|
392
|
+
return Response.json({ name, strategy_id: strategyId, is_local: this._isLocal });
|
|
289
393
|
}
|
|
290
394
|
|
|
291
|
-
// Serve dashboard HTML
|
|
292
|
-
return new Response(
|
|
395
|
+
// ── Serve dashboard HTML ──
|
|
396
|
+
return new Response(buildDashboardHTML({ name, strategyId, isLocal: this._isLocal }), {
|
|
293
397
|
headers: { "Content-Type": "text/html" },
|
|
294
398
|
});
|
|
295
399
|
},
|
|
@@ -299,11 +403,149 @@ export class DashboardServer {
|
|
|
299
403
|
return this.url;
|
|
300
404
|
}
|
|
301
405
|
|
|
406
|
+
/** Get metrics from the latest running local process */
|
|
407
|
+
private getLocalMetrics(): any | null {
|
|
408
|
+
for (const [, m] of runningProcesses) {
|
|
409
|
+
if (m.proc.exitCode === null) {
|
|
410
|
+
const metrics = parseLocalMetrics(m);
|
|
411
|
+
if (metrics) return metrics;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Get logs from local processes, filtered */
|
|
418
|
+
private getLocalLogs(): string[] {
|
|
419
|
+
const lines: string[] = [];
|
|
420
|
+
for (const [, m] of runningProcesses) {
|
|
421
|
+
lines.push(...m.stdout.slice(-30));
|
|
422
|
+
const stderr = m.stderr.filter((l: string) => !l.startsWith("__HZ_METRICS__")).slice(-10);
|
|
423
|
+
if (stderr.length > 0) lines.push("--- stderr ---", ...stderr);
|
|
424
|
+
}
|
|
425
|
+
return lines;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Start dashboard from a file in the workspace. Re-reads on every request (live reload). */
|
|
429
|
+
startFromFile(filePath: string, strategyId?: string, port = 0): string {
|
|
430
|
+
if (this.server) this.stop();
|
|
431
|
+
|
|
432
|
+
const absolutePath = resolveSafePath(filePath);
|
|
433
|
+
if (!existsSync(absolutePath)) {
|
|
434
|
+
throw new Error(`Dashboard file not found: ${filePath}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
this._strategyId = strategyId ?? "local";
|
|
438
|
+
this._isLocal = true;
|
|
439
|
+
this._capturedErrors = [];
|
|
440
|
+
|
|
441
|
+
const sid = this._strategyId;
|
|
442
|
+
const hasLocal = [...runningProcesses.values()].some(m => m.proc.exitCode === null);
|
|
443
|
+
|
|
444
|
+
this.server = Bun.serve({
|
|
445
|
+
port: port || 0,
|
|
446
|
+
hostname: "127.0.0.1",
|
|
447
|
+
fetch: async (req) => {
|
|
448
|
+
const url = new URL(req.url);
|
|
449
|
+
|
|
450
|
+
// ── Error capture endpoint ──
|
|
451
|
+
if (url.pathname === "/api/error" && req.method === "POST") {
|
|
452
|
+
try {
|
|
453
|
+
const error = await req.json();
|
|
454
|
+
this._capturedErrors.push(error);
|
|
455
|
+
if (this._capturedErrors.length > 50) this._capturedErrors.shift();
|
|
456
|
+
} catch {}
|
|
457
|
+
return Response.json({ ok: true });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Return captured errors ──
|
|
461
|
+
if (url.pathname === "/api/errors") {
|
|
462
|
+
return Response.json(this._capturedErrors.slice(-10));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── Local process metrics ──
|
|
466
|
+
if (url.pathname === "/api/local/metrics") {
|
|
467
|
+
const metrics = this.getLocalMetrics();
|
|
468
|
+
if (metrics) return Response.json(metrics);
|
|
469
|
+
return Response.json({ error: "No local process" }, { status: 404 });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── Local process logs ──
|
|
473
|
+
if (url.pathname === "/api/local/logs") {
|
|
474
|
+
return Response.json(this.getLocalLogs());
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ── Platform metrics ──
|
|
478
|
+
if (url.pathname === "/api/metrics" && sid !== "local") {
|
|
479
|
+
try {
|
|
480
|
+
const data = await platform.getMetrics(sid, 20);
|
|
481
|
+
const latest: any = data?.latest ?? {};
|
|
482
|
+
return Response.json({
|
|
483
|
+
pnl: latest.total_pnl ?? 0, rpnl: latest.realized_pnl ?? 0,
|
|
484
|
+
upnl: latest.unrealized_pnl ?? 0, orders: latest.open_order_count ?? 0,
|
|
485
|
+
positions: latest.position_count ?? 0, trades: latest.total_trades ?? 0,
|
|
486
|
+
win_rate: latest.win_rate ?? 0, sharpe: latest.sharpe_ratio ?? 0,
|
|
487
|
+
max_dd: latest.max_drawdown_pct ?? 0, exposure: latest.total_exposure ?? 0,
|
|
488
|
+
});
|
|
489
|
+
} catch {
|
|
490
|
+
return Response.json({ error: "Platform unavailable" }, { status: 502 });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── Platform logs ──
|
|
495
|
+
if (url.pathname === "/api/logs" && sid !== "local") {
|
|
496
|
+
try { return Response.json(await platform.getLogs(sid, 50)); }
|
|
497
|
+
catch { return Response.json([], { status: 502 }); }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ── Strategy metadata ──
|
|
501
|
+
if (url.pathname === "/api/strategy") {
|
|
502
|
+
const draft = store.getActiveSession()?.strategyDraft;
|
|
503
|
+
return Response.json(draft
|
|
504
|
+
? { name: draft.name, code: draft.code, params: draft.params, riskConfig: draft.riskConfig }
|
|
505
|
+
: { error: "No strategy loaded" });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── Local process logs (for custom dashboard compatibility) ──
|
|
509
|
+
if (url.pathname === "/api/local-logs") {
|
|
510
|
+
const allLogs: Record<string, { stdout: string[]; stderr: string[]; alive: boolean }> = {};
|
|
511
|
+
for (const [pid, managed] of runningProcesses) {
|
|
512
|
+
allLogs[pid] = { stdout: managed.stdout.slice(-50), stderr: managed.stderr.slice(-20), alive: managed.proc.exitCode === null };
|
|
513
|
+
}
|
|
514
|
+
return Response.json(allLogs);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ── Serve HTML from file (re-read each request for live editing) ──
|
|
518
|
+
try {
|
|
519
|
+
const file = Bun.file(absolutePath);
|
|
520
|
+
const html = await file.text();
|
|
521
|
+
const injected = injectErrorScript(html);
|
|
522
|
+
return new Response(injected, {
|
|
523
|
+
headers: { "Content-Type": "text/html" },
|
|
524
|
+
});
|
|
525
|
+
} catch (e: any) {
|
|
526
|
+
return new Response(`<pre>Error reading dashboard file: ${e.message}</pre>`, {
|
|
527
|
+
status: 500,
|
|
528
|
+
headers: { "Content-Type": "text/html" },
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
this._port = this.server.port ?? 0;
|
|
535
|
+
return this.url;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Clear captured JS errors */
|
|
539
|
+
clearErrors(): void {
|
|
540
|
+
this._capturedErrors = [];
|
|
541
|
+
}
|
|
542
|
+
|
|
302
543
|
stop(): void {
|
|
303
544
|
if (this.server) {
|
|
304
545
|
this.server.stop();
|
|
305
546
|
this.server = null;
|
|
306
547
|
this._port = 0;
|
|
548
|
+
this._capturedErrors = [];
|
|
307
549
|
}
|
|
308
550
|
}
|
|
309
551
|
}
|