horizon-code 0.1.2 → 0.3.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/bin/horizon.js +18 -1
- package/package.json +1 -1
- package/src/ai/client.ts +33 -8
- package/src/ai/system-prompt.ts +48 -6
- package/src/app.ts +167 -14
- package/src/components/code-panel.ts +223 -17
- package/src/components/footer.ts +3 -0
- package/src/components/settings-panel.ts +14 -0
- package/src/platform/exchanges.ts +154 -0
- package/src/platform/session-sync.ts +1 -1
- package/src/research/apis.ts +208 -11
- package/src/research/stock-apis.ts +117 -0
- package/src/research/tools.ts +929 -17
- package/src/research/widgets.ts +1042 -29
- package/src/state/types.ts +1 -0
- package/src/strategy/code-stream.ts +3 -1
- package/src/strategy/dashboard.ts +189 -18
- package/src/strategy/prompts.ts +426 -6
- package/src/strategy/tools.ts +311 -54
- package/src/strategy/validator.ts +98 -0
- package/src/updater.ts +118 -0
|
@@ -12,9 +12,32 @@ import {
|
|
|
12
12
|
} from "@opentui/core";
|
|
13
13
|
type TreeSitterClient = any;
|
|
14
14
|
import { COLORS } from "../theme/colors.ts";
|
|
15
|
+
import { store } from "../state/store.ts";
|
|
16
|
+
import type { DeploymentMetrics, Position, Order } from "../state/types.ts";
|
|
15
17
|
|
|
16
18
|
const h = (hex: string) => RGBA.fromHex(hex);
|
|
17
19
|
|
|
20
|
+
function sparkline(values: number[], width: number): string {
|
|
21
|
+
if (values.length < 2) return "";
|
|
22
|
+
const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
|
|
23
|
+
const min = Math.min(...values);
|
|
24
|
+
const max = Math.max(...values);
|
|
25
|
+
const range = max - min || 1;
|
|
26
|
+
const step = values.length / width;
|
|
27
|
+
let result = "";
|
|
28
|
+
for (let i = 0; i < width && i * step < values.length; i++) {
|
|
29
|
+
const idx = Math.min(Math.floor(i * step), values.length - 1);
|
|
30
|
+
const normalized = (values[idx]! - min) / range;
|
|
31
|
+
const charIdx = Math.min(Math.floor(normalized * (chars.length - 1)), chars.length - 1);
|
|
32
|
+
result += chars[charIdx];
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pad(s: string, width: number): string {
|
|
38
|
+
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
39
|
+
}
|
|
40
|
+
|
|
18
41
|
// Python syntax colors (matches highlights.scm groups)
|
|
19
42
|
const syntaxStyle = SyntaxStyle.fromStyles({
|
|
20
43
|
"@keyword": { fg: h("#C586C0") }, // purple
|
|
@@ -36,7 +59,7 @@ const syntaxStyle = SyntaxStyle.fromStyles({
|
|
|
36
59
|
"@punctuation.special": { fg: h("#D7BA7D") }, // gold
|
|
37
60
|
});
|
|
38
61
|
|
|
39
|
-
type PanelTab = "code" | "logs" | "dashboard";
|
|
62
|
+
type PanelTab = "code" | "logs" | "dashboard" | "widgets";
|
|
40
63
|
|
|
41
64
|
export class CodePanel {
|
|
42
65
|
readonly container: BoxRenderable;
|
|
@@ -51,6 +74,8 @@ export class CodePanel {
|
|
|
51
74
|
private logsMd: MarkdownRenderable;
|
|
52
75
|
private dashScroll: ScrollBoxRenderable;
|
|
53
76
|
private dashMd: MarkdownRenderable;
|
|
77
|
+
private widgetsScroll: ScrollBoxRenderable;
|
|
78
|
+
private widgetsList: BoxRenderable;
|
|
54
79
|
|
|
55
80
|
private footerText: TextRenderable;
|
|
56
81
|
private _visible = false;
|
|
@@ -59,7 +84,16 @@ export class CodePanel {
|
|
|
59
84
|
private _name = "";
|
|
60
85
|
private _phase = "";
|
|
61
86
|
private _logs = "";
|
|
62
|
-
private
|
|
87
|
+
private _metricsData: {
|
|
88
|
+
name: string;
|
|
89
|
+
status: string;
|
|
90
|
+
dryRun: boolean;
|
|
91
|
+
metrics: DeploymentMetrics;
|
|
92
|
+
positions: Position[];
|
|
93
|
+
orders: Order[];
|
|
94
|
+
pnlHistory: number[];
|
|
95
|
+
startedAt: number;
|
|
96
|
+
} | null = null;
|
|
63
97
|
private _validationStatus: "pending" | "valid" | "invalid" | "none" = "none";
|
|
64
98
|
|
|
65
99
|
constructor(private renderer: CliRenderer) {
|
|
@@ -95,7 +129,8 @@ export class CodePanel {
|
|
|
95
129
|
const tabs: { id: PanelTab; label: string }[] = [
|
|
96
130
|
{ id: "code", label: "Code" },
|
|
97
131
|
{ id: "logs", label: "Logs" },
|
|
98
|
-
{ id: "dashboard", label: "
|
|
132
|
+
{ id: "dashboard", label: "Metrics" },
|
|
133
|
+
{ id: "widgets", label: "Widgets" },
|
|
99
134
|
];
|
|
100
135
|
for (const tab of tabs) {
|
|
101
136
|
const text = new TextRenderable(renderer, {
|
|
@@ -157,16 +192,40 @@ export class CodePanel {
|
|
|
157
192
|
this.dashScroll.visible = false;
|
|
158
193
|
this.dashMd = new MarkdownRenderable(renderer, {
|
|
159
194
|
id: "dash-md",
|
|
160
|
-
content: "*no
|
|
195
|
+
content: "*no active deployment*\n\n*Deploy a strategy to see live metrics here.*",
|
|
161
196
|
syntaxStyle,
|
|
162
197
|
});
|
|
163
198
|
this.dashScroll.add(this.dashMd);
|
|
164
199
|
this.container.add(this.dashScroll);
|
|
165
200
|
|
|
201
|
+
// ── Widgets tab content ──
|
|
202
|
+
this.widgetsScroll = new ScrollBoxRenderable(renderer, {
|
|
203
|
+
id: "widgets-scroll",
|
|
204
|
+
flexGrow: 1,
|
|
205
|
+
paddingLeft: 1,
|
|
206
|
+
paddingRight: 1,
|
|
207
|
+
paddingTop: 1,
|
|
208
|
+
stickyScroll: true,
|
|
209
|
+
stickyStart: "bottom",
|
|
210
|
+
});
|
|
211
|
+
this.widgetsScroll.visible = false;
|
|
212
|
+
this.widgetsList = new BoxRenderable(renderer, {
|
|
213
|
+
id: "widgets-list",
|
|
214
|
+
flexDirection: "column",
|
|
215
|
+
width: "100%",
|
|
216
|
+
});
|
|
217
|
+
this.widgetsList.add(new TextRenderable(renderer, {
|
|
218
|
+
id: "widgets-empty",
|
|
219
|
+
content: "*no widgets yet*\n\n*Tool results will appear here when \"Widgets in Tab\" is enabled in settings.*",
|
|
220
|
+
fg: COLORS.textMuted,
|
|
221
|
+
}));
|
|
222
|
+
this.widgetsScroll.add(this.widgetsList);
|
|
223
|
+
this.container.add(this.widgetsScroll);
|
|
224
|
+
|
|
166
225
|
// ── Footer ──
|
|
167
226
|
this.footerText = new TextRenderable(renderer, {
|
|
168
227
|
id: "code-footer",
|
|
169
|
-
content: " ^G close | Tab cycle | 1 code 2 logs 3 dash",
|
|
228
|
+
content: " ^G close | Tab cycle | 1 code 2 logs 3 dash 4 widgets",
|
|
170
229
|
fg: COLORS.borderDim,
|
|
171
230
|
});
|
|
172
231
|
this.container.add(this.footerText);
|
|
@@ -192,20 +251,72 @@ export class CodePanel {
|
|
|
192
251
|
this.codeScroll.visible = tab === "code";
|
|
193
252
|
this.logsScroll.visible = tab === "logs";
|
|
194
253
|
this.dashScroll.visible = tab === "dashboard";
|
|
254
|
+
this.widgetsScroll.visible = tab === "widgets";
|
|
195
255
|
// Refresh content for the active tab
|
|
196
256
|
if (tab === "code") this.updateCodeContent();
|
|
197
257
|
else if (tab === "logs") this.updateLogsContent();
|
|
198
258
|
else if (tab === "dashboard") this.updateDashContent();
|
|
259
|
+
// Widgets tab: force scroll to bottom to show latest
|
|
260
|
+
if (tab === "widgets") this.widgetsScroll.scrollToBottom?.();
|
|
199
261
|
this.updateTabBar();
|
|
200
262
|
this.renderer.requestRender();
|
|
201
263
|
}
|
|
202
264
|
|
|
203
265
|
cycleTab(): void {
|
|
204
|
-
const order: PanelTab[] = ["code", "logs", "dashboard"];
|
|
266
|
+
const order: PanelTab[] = ["code", "logs", "dashboard", "widgets"];
|
|
205
267
|
const idx = order.indexOf(this._activeTab);
|
|
206
268
|
this.setTab(order[(idx + 1) % order.length]!);
|
|
207
269
|
}
|
|
208
270
|
|
|
271
|
+
private _widgetCount = 0;
|
|
272
|
+
|
|
273
|
+
/** Add a widget renderable to the widgets tab */
|
|
274
|
+
addWidget(toolName: string, widget: BoxRenderable): void {
|
|
275
|
+
// Remove placeholder on first widget
|
|
276
|
+
if (this._widgetCount === 0) {
|
|
277
|
+
const children = this.widgetsList.getChildren();
|
|
278
|
+
for (const child of [...children]) {
|
|
279
|
+
this.widgetsList.remove(child);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
this._widgetCount++;
|
|
283
|
+
|
|
284
|
+
// Separator between widgets
|
|
285
|
+
if (this._widgetCount > 1) {
|
|
286
|
+
this.widgetsList.add(new TextRenderable(this.renderer, {
|
|
287
|
+
id: `wt-sep-${Date.now()}-${this._widgetCount}`,
|
|
288
|
+
content: "",
|
|
289
|
+
fg: COLORS.borderDim,
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Tool name header
|
|
294
|
+
this.widgetsList.add(new TextRenderable(this.renderer, {
|
|
295
|
+
id: `wt-hdr-${Date.now()}-${this._widgetCount}`,
|
|
296
|
+
content: `── ${toolName.replace(/_/g, " ")} ${"─".repeat(40)}`,
|
|
297
|
+
fg: COLORS.borderDim,
|
|
298
|
+
}));
|
|
299
|
+
|
|
300
|
+
// The widget itself
|
|
301
|
+
this.widgetsList.add(widget);
|
|
302
|
+
|
|
303
|
+
this.renderer.requestRender();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Clear all widgets from the tab */
|
|
307
|
+
clearWidgets(): void {
|
|
308
|
+
const children = this.widgetsList.getChildren();
|
|
309
|
+
for (const child of [...children]) {
|
|
310
|
+
this.widgetsList.remove(child);
|
|
311
|
+
}
|
|
312
|
+
this._widgetCount = 0;
|
|
313
|
+
this.widgetsList.add(new TextRenderable(this.renderer, {
|
|
314
|
+
id: `widgets-empty-${Date.now()}`,
|
|
315
|
+
content: "*no widgets yet*\n\n*Enable \"Widgets in Tab\" in /settings.*",
|
|
316
|
+
fg: COLORS.textMuted,
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
|
|
209
320
|
// ── Content setters ──
|
|
210
321
|
|
|
211
322
|
setCode(code: string, status: "pending" | "valid" | "invalid" | "none" = "none"): void {
|
|
@@ -234,8 +345,17 @@ export class CodePanel {
|
|
|
234
345
|
if (this._activeTab === "logs") this.updateLogsContent();
|
|
235
346
|
}
|
|
236
347
|
|
|
237
|
-
|
|
238
|
-
|
|
348
|
+
setMetrics(data: {
|
|
349
|
+
name: string;
|
|
350
|
+
status: string;
|
|
351
|
+
dryRun: boolean;
|
|
352
|
+
metrics: DeploymentMetrics;
|
|
353
|
+
positions: Position[];
|
|
354
|
+
orders: Order[];
|
|
355
|
+
pnlHistory: number[];
|
|
356
|
+
startedAt: number;
|
|
357
|
+
} | null): void {
|
|
358
|
+
this._metricsData = data;
|
|
239
359
|
if (this._activeTab === "dashboard") this.updateDashContent();
|
|
240
360
|
}
|
|
241
361
|
|
|
@@ -262,16 +382,17 @@ export class CodePanel {
|
|
|
262
382
|
|
|
263
383
|
private updateTabBar(): void {
|
|
264
384
|
for (const [id, text] of this.tabTexts) {
|
|
385
|
+
const label = id === "code" ? (this._name || "Code")
|
|
386
|
+
: id === "dashboard" ? "Metrics"
|
|
387
|
+
: id.charAt(0).toUpperCase() + id.slice(1);
|
|
265
388
|
if (id === this._activeTab) {
|
|
266
389
|
text.fg = "#212121";
|
|
267
390
|
text.bg = COLORS.accent;
|
|
268
|
-
text.content =
|
|
269
|
-
? ` ${this._name || "Code"} `
|
|
270
|
-
: ` ${id.charAt(0).toUpperCase() + id.slice(1)} `;
|
|
391
|
+
text.content = ` ${label} `;
|
|
271
392
|
} else {
|
|
272
393
|
text.fg = COLORS.textMuted;
|
|
273
394
|
text.bg = undefined;
|
|
274
|
-
text.content = ` ${
|
|
395
|
+
text.content = ` ${label} `;
|
|
275
396
|
}
|
|
276
397
|
}
|
|
277
398
|
}
|
|
@@ -304,7 +425,20 @@ export class CodePanel {
|
|
|
304
425
|
this.codeMd.content = "*no strategy loaded*\n\n*Describe a strategy in the chat to get started.*";
|
|
305
426
|
}
|
|
306
427
|
} else {
|
|
307
|
-
|
|
428
|
+
let content = "```python\n" + this._code + "\n```";
|
|
429
|
+
|
|
430
|
+
// Show warnings if any
|
|
431
|
+
const draft = store.getActiveSession()?.strategyDraft;
|
|
432
|
+
if (draft?.validationWarnings?.length) {
|
|
433
|
+
content += "\n\n---\n";
|
|
434
|
+
for (const w of draft.validationWarnings) {
|
|
435
|
+
const icon = w.severity === "warning" ? "!" : "i";
|
|
436
|
+
const lineRef = w.line ? ` (line ${w.line})` : "";
|
|
437
|
+
content += `\n${icon} ${w.message}${lineRef}`;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
this.codeMd.content = content;
|
|
308
442
|
}
|
|
309
443
|
this.renderer.requestRender();
|
|
310
444
|
}
|
|
@@ -319,11 +453,83 @@ export class CodePanel {
|
|
|
319
453
|
}
|
|
320
454
|
|
|
321
455
|
private updateDashContent(): void {
|
|
322
|
-
if (!this.
|
|
323
|
-
this.dashMd.content = "*no
|
|
324
|
-
|
|
325
|
-
|
|
456
|
+
if (!this._metricsData) {
|
|
457
|
+
this.dashMd.content = "*no active deployment*\n\n*Deploy a strategy to see live metrics here.*";
|
|
458
|
+
this.renderer.requestRender();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const d = this._metricsData;
|
|
463
|
+
const m = d.metrics;
|
|
464
|
+
const statusIcon = d.status === "running" ? "\u25CF" : d.status === "error" ? "x" : "\u25CB";
|
|
465
|
+
const modeLabel = d.dryRun ? "paper" : "LIVE";
|
|
466
|
+
|
|
467
|
+
// Uptime
|
|
468
|
+
const uptimeMs = Date.now() - d.startedAt;
|
|
469
|
+
const hours = Math.floor(uptimeMs / 3600000);
|
|
470
|
+
const mins = Math.floor((uptimeMs % 3600000) / 60000);
|
|
471
|
+
const uptime = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
|
472
|
+
|
|
473
|
+
// Sparkline from pnl history
|
|
474
|
+
const spark = sparkline(d.pnlHistory, 30);
|
|
475
|
+
|
|
476
|
+
// Format helpers
|
|
477
|
+
const pnl = (v: number) => (v >= 0 ? `+$${v.toFixed(2)}` : `-$${Math.abs(v).toFixed(2)}`);
|
|
478
|
+
const pct = (v: number) => `${v.toFixed(1)}%`;
|
|
479
|
+
|
|
480
|
+
const lines: string[] = [];
|
|
481
|
+
lines.push(`**${d.name}** ${statusIcon} ${d.status} | ${modeLabel} | ${uptime}`);
|
|
482
|
+
lines.push("---");
|
|
483
|
+
lines.push("");
|
|
484
|
+
|
|
485
|
+
// P&L section
|
|
486
|
+
lines.push("```");
|
|
487
|
+
lines.push(` Total P&L ${pad(pnl(m.total_pnl), 12)} Realized ${pad(pnl(m.realized_pnl), 12)}`);
|
|
488
|
+
lines.push(` Unrealized ${pad(pnl(m.unrealized_pnl), 12)} Exposure ${pad("$" + m.total_exposure.toFixed(2), 12)}`);
|
|
489
|
+
lines.push("");
|
|
490
|
+
lines.push(` Sharpe ${pad(m.sharpe_ratio.toFixed(2), 12)} Win Rate ${pad(pct(m.win_rate * 100), 12)}`);
|
|
491
|
+
lines.push(` Max DD ${pad(pct(m.max_drawdown_pct), 12)} Trades ${pad(String(m.total_trades), 12)}`);
|
|
492
|
+
lines.push(` Profit F ${pad(m.profit_factor.toFixed(2), 12)} Avg Return ${pad(pnl(m.avg_return_per_trade), 12)}`);
|
|
493
|
+
lines.push("```");
|
|
494
|
+
lines.push("");
|
|
495
|
+
|
|
496
|
+
// Equity sparkline
|
|
497
|
+
if (spark) {
|
|
498
|
+
lines.push("**Equity**");
|
|
499
|
+
lines.push("```");
|
|
500
|
+
lines.push(" " + spark);
|
|
501
|
+
lines.push("```");
|
|
502
|
+
lines.push("");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Positions
|
|
506
|
+
if (d.positions.length > 0) {
|
|
507
|
+
lines.push(`**Positions** (${d.positions.length})`);
|
|
508
|
+
lines.push("```");
|
|
509
|
+
for (const p of d.positions.slice(0, 8)) {
|
|
510
|
+
const side = p.side === "BUY" ? "BUY " : "SELL";
|
|
511
|
+
const slug = p.slug.length > 24 ? p.slug.slice(0, 24) + "..." : p.slug;
|
|
512
|
+
lines.push(` ${side} ${pad(slug, 28)} ${pad(String(p.size), 5)} @ ${p.avg_entry_price.toFixed(2)} ${pnl(p.unrealized_pnl)}`);
|
|
513
|
+
}
|
|
514
|
+
if (d.positions.length > 8) lines.push(` ... and ${d.positions.length - 8} more`);
|
|
515
|
+
lines.push("```");
|
|
516
|
+
lines.push("");
|
|
326
517
|
}
|
|
518
|
+
|
|
519
|
+
// Recent orders
|
|
520
|
+
if (d.orders.length > 0) {
|
|
521
|
+
lines.push(`**Recent Orders** (${d.orders.length})`);
|
|
522
|
+
lines.push("```");
|
|
523
|
+
for (const o of d.orders.slice(0, 5)) {
|
|
524
|
+
const time = new Date(o.created_at).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" });
|
|
525
|
+
const side = o.side === "BUY" ? "BUY " : "SELL";
|
|
526
|
+
const slug = o.slug.length > 20 ? o.slug.slice(0, 20) + "..." : o.slug;
|
|
527
|
+
lines.push(` ${time} ${side} ${pad(String(o.size), 4)} @ ${o.price.toFixed(2)} ${pad(o.status, 10)} ${slug}`);
|
|
528
|
+
}
|
|
529
|
+
lines.push("```");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
this.dashMd.content = lines.join("\n");
|
|
327
533
|
this.renderer.requestRender();
|
|
328
534
|
}
|
|
329
535
|
}
|
package/src/components/footer.ts
CHANGED
|
@@ -47,6 +47,9 @@ export class Footer {
|
|
|
47
47
|
this.streamingText.content = `${BRAILLE[this.spinnerFrame]} generating`;
|
|
48
48
|
this.streamingText.fg = COLORS.accent;
|
|
49
49
|
this.renderer.requestRender();
|
|
50
|
+
} else if (this.streamingText.content !== "") {
|
|
51
|
+
this.streamingText.content = "";
|
|
52
|
+
this.renderer.requestRender();
|
|
50
53
|
}
|
|
51
54
|
}, 60);
|
|
52
55
|
}
|
|
@@ -12,6 +12,8 @@ export interface HorizonSettings {
|
|
|
12
12
|
autoCompact: boolean;
|
|
13
13
|
compactThreshold: number;
|
|
14
14
|
showToolCalls: boolean;
|
|
15
|
+
showWidgets: boolean;
|
|
16
|
+
widgetsInTab: boolean;
|
|
15
17
|
soundEnabled: boolean;
|
|
16
18
|
theme: ThemeName;
|
|
17
19
|
payAsYouGo: boolean;
|
|
@@ -25,6 +27,8 @@ const DEFAULT_SETTINGS: HorizonSettings = {
|
|
|
25
27
|
autoCompact: true,
|
|
26
28
|
compactThreshold: 80,
|
|
27
29
|
showToolCalls: true,
|
|
30
|
+
showWidgets: true,
|
|
31
|
+
widgetsInTab: false,
|
|
28
32
|
soundEnabled: false,
|
|
29
33
|
theme: "dark",
|
|
30
34
|
payAsYouGo: false,
|
|
@@ -72,6 +76,16 @@ const SETTINGS_DEFS: SettingDef[] = [
|
|
|
72
76
|
description: "Display tool call indicators in chat",
|
|
73
77
|
type: "toggle",
|
|
74
78
|
},
|
|
79
|
+
{
|
|
80
|
+
key: "showWidgets", label: "Show Widgets",
|
|
81
|
+
description: "Render tool results as visual widgets (off = text only, LLM still gets data)",
|
|
82
|
+
type: "toggle",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
key: "widgetsInTab", label: "Widgets in Tab",
|
|
86
|
+
description: "Render widgets in a side tab instead of inline in chat",
|
|
87
|
+
type: "toggle",
|
|
88
|
+
},
|
|
75
89
|
{
|
|
76
90
|
key: "soundEnabled", label: "Sound",
|
|
77
91
|
description: "Play terminal bell when generation completes",
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Exchange credential profiles — defines what keys each exchange needs
|
|
2
|
+
// Keys are stored encrypted via /env system
|
|
3
|
+
|
|
4
|
+
import { listEncryptedEnvNames, getDecryptedEnv, setEncryptedEnv, removeEncryptedEnv } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
export interface ExchangeProfile {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
category: "prediction" | "stock" | "crypto";
|
|
10
|
+
description: string;
|
|
11
|
+
keys: { env: string; label: string; hint: string; required: boolean }[];
|
|
12
|
+
testUrl?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const EXCHANGE_PROFILES: ExchangeProfile[] = [
|
|
16
|
+
{
|
|
17
|
+
id: "polymarket",
|
|
18
|
+
name: "Polymarket",
|
|
19
|
+
category: "prediction",
|
|
20
|
+
description: "Prediction market on Polygon. Requires a funded wallet private key.",
|
|
21
|
+
keys: [
|
|
22
|
+
{ env: "POLYMARKET_PRIVATE_KEY", label: "Private Key", hint: "Ethereum private key (0x...)", required: true },
|
|
23
|
+
{ env: "CLOB_API_KEY", label: "CLOB API Key", hint: "From Polymarket developer portal (optional)", required: false },
|
|
24
|
+
{ env: "CLOB_SECRET", label: "CLOB Secret", hint: "CLOB API secret", required: false },
|
|
25
|
+
{ env: "CLOB_PASSPHRASE", label: "CLOB Passphrase", hint: "CLOB API passphrase", required: false },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "kalshi",
|
|
30
|
+
name: "Kalshi",
|
|
31
|
+
category: "prediction",
|
|
32
|
+
description: "Regulated US prediction market. API key from kalshi.com/settings.",
|
|
33
|
+
keys: [
|
|
34
|
+
{ env: "KALSHI_API_KEY", label: "API Key", hint: "From Kalshi account settings", required: true },
|
|
35
|
+
{ env: "KALSHI_API_SECRET", label: "API Secret", hint: "Secret paired with API key", required: false },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "limitless",
|
|
40
|
+
name: "Limitless",
|
|
41
|
+
category: "prediction",
|
|
42
|
+
description: "Emerging prediction market platform.",
|
|
43
|
+
keys: [
|
|
44
|
+
{ env: "LIMITLESS_API_KEY", label: "API Key", hint: "From Limitless developer portal", required: true },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "alpaca",
|
|
49
|
+
name: "Alpaca",
|
|
50
|
+
category: "stock",
|
|
51
|
+
description: "Commission-free stock/ETF trading. Paper + live. alpaca.markets",
|
|
52
|
+
keys: [
|
|
53
|
+
{ env: "ALPACA_API_KEY", label: "API Key", hint: "From Alpaca dashboard (paper or live)", required: true },
|
|
54
|
+
{ env: "ALPACA_SECRET_KEY", label: "Secret Key", hint: "Secret paired with API key", required: true },
|
|
55
|
+
{ env: "ALPACA_PAPER", label: "Paper Mode", hint: "Set to 'true' for paper trading (default: true)", required: false },
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "robinhood",
|
|
60
|
+
name: "Robinhood",
|
|
61
|
+
category: "stock",
|
|
62
|
+
description: "Stock/options/crypto trading. Uses OAuth token.",
|
|
63
|
+
keys: [
|
|
64
|
+
{ env: "ROBINHOOD_TOKEN", label: "Access Token", hint: "OAuth token from Robinhood API", required: true },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "polygon",
|
|
69
|
+
name: "Polygon.io",
|
|
70
|
+
category: "stock",
|
|
71
|
+
description: "Real-time & historical market data for stocks, options, crypto. polygon.io",
|
|
72
|
+
keys: [
|
|
73
|
+
{ env: "POLYGON_API_KEY", label: "API Key", hint: "From polygon.io dashboard (free tier available)", required: true },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "fmp",
|
|
78
|
+
name: "Financial Modeling Prep",
|
|
79
|
+
category: "stock",
|
|
80
|
+
description: "Fundamental data, financials, earnings. financialmodelingprep.com",
|
|
81
|
+
keys: [
|
|
82
|
+
{ env: "FMP_API_KEY", label: "API Key", hint: "From FMP dashboard", required: true },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
export type ConnectionStatus = "connected" | "partial" | "disconnected";
|
|
88
|
+
|
|
89
|
+
export interface ExchangeStatus {
|
|
90
|
+
profile: ExchangeProfile;
|
|
91
|
+
status: ConnectionStatus;
|
|
92
|
+
keysSet: string[];
|
|
93
|
+
keysMissing: string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Check which exchanges have credentials configured */
|
|
97
|
+
export function getExchangeStatuses(): ExchangeStatus[] {
|
|
98
|
+
const envNames = new Set(listEncryptedEnvNames());
|
|
99
|
+
// Also check process.env for keys set externally
|
|
100
|
+
const allKeys = new Set([...envNames, ...Object.keys(process.env).filter(k =>
|
|
101
|
+
EXCHANGE_PROFILES.some(p => p.keys.some(key => key.env === k && process.env[k]))
|
|
102
|
+
)]);
|
|
103
|
+
|
|
104
|
+
return EXCHANGE_PROFILES.map(profile => {
|
|
105
|
+
const keysSet = profile.keys.filter(k => allKeys.has(k.env)).map(k => k.env);
|
|
106
|
+
const keysMissing = profile.keys.filter(k => k.required && !allKeys.has(k.env)).map(k => k.env);
|
|
107
|
+
const requiredKeys = profile.keys.filter(k => k.required);
|
|
108
|
+
const requiredSet = requiredKeys.filter(k => allKeys.has(k.env));
|
|
109
|
+
|
|
110
|
+
let status: ConnectionStatus;
|
|
111
|
+
if (requiredSet.length === requiredKeys.length) {
|
|
112
|
+
status = "connected";
|
|
113
|
+
} else if (keysSet.length > 0) {
|
|
114
|
+
status = "partial";
|
|
115
|
+
} else {
|
|
116
|
+
status = "disconnected";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { profile, status, keysSet, keysMissing };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Get status for a specific exchange */
|
|
124
|
+
export function getExchangeStatus(exchangeId: string): ExchangeStatus | null {
|
|
125
|
+
return getExchangeStatuses().find(s => s.profile.id === exchangeId) ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Set a credential for an exchange */
|
|
129
|
+
export function setExchangeKey(envName: string, value: string): boolean {
|
|
130
|
+
try {
|
|
131
|
+
setEncryptedEnv(envName, value);
|
|
132
|
+
return true;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Remove a credential */
|
|
139
|
+
export function removeExchangeKey(envName: string): boolean {
|
|
140
|
+
try {
|
|
141
|
+
removeEncryptedEnv(envName);
|
|
142
|
+
return true;
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Get a decrypted credential (for passing to subprocesses) */
|
|
149
|
+
export function getExchangeKey(envName: string): string | null {
|
|
150
|
+
// Check encrypted env first, then process.env
|
|
151
|
+
const decrypted = getDecryptedEnv(envName);
|
|
152
|
+
if (decrypted) return decrypted;
|
|
153
|
+
return process.env[envName] ?? null;
|
|
154
|
+
}
|
|
@@ -50,7 +50,7 @@ export async function loadSessions(): Promise<void> {
|
|
|
50
50
|
mode: (dbs.mode as any) ?? "research",
|
|
51
51
|
isStreaming: false,
|
|
52
52
|
streamingMsgId: null,
|
|
53
|
-
strategyDraft: dbs.strategy_draft ?? null,
|
|
53
|
+
strategyDraft: dbs.strategy_draft ? { ...dbs.strategy_draft, validationWarnings: dbs.strategy_draft.validationWarnings ?? [] } : null,
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
// Mark all loaded messages as synced
|