horizon-code 0.1.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/assets/python/highlights.scm +137 -0
- package/assets/python/tree-sitter-python.wasm +0 -0
- package/bin/horizon.js +2 -0
- package/package.json +40 -0
- package/src/ai/client.ts +369 -0
- package/src/ai/system-prompt.ts +86 -0
- package/src/app.ts +1454 -0
- package/src/chat/messages.ts +48 -0
- package/src/chat/renderer.ts +243 -0
- package/src/chat/types.ts +18 -0
- package/src/components/code-panel.ts +329 -0
- package/src/components/footer.ts +72 -0
- package/src/components/hooks-panel.ts +224 -0
- package/src/components/input-bar.ts +193 -0
- package/src/components/mode-bar.ts +245 -0
- package/src/components/session-panel.ts +294 -0
- package/src/components/settings-panel.ts +372 -0
- package/src/components/splash.ts +156 -0
- package/src/components/strategy-panel.ts +489 -0
- package/src/components/tab-bar.ts +112 -0
- package/src/components/tutorial-panel.ts +680 -0
- package/src/components/widgets/progress-bar.ts +38 -0
- package/src/components/widgets/sparkline.ts +57 -0
- package/src/hooks/executor.ts +109 -0
- package/src/index.ts +22 -0
- package/src/keys/handler.ts +198 -0
- package/src/platform/auth.ts +36 -0
- package/src/platform/client.ts +159 -0
- package/src/platform/config.ts +121 -0
- package/src/platform/session-sync.ts +158 -0
- package/src/platform/supabase.ts +376 -0
- package/src/platform/sync.ts +149 -0
- package/src/platform/tiers.ts +103 -0
- package/src/platform/tools.ts +163 -0
- package/src/platform/types.ts +86 -0
- package/src/platform/usage.ts +224 -0
- package/src/research/apis.ts +367 -0
- package/src/research/tools.ts +205 -0
- package/src/research/widgets.ts +523 -0
- package/src/state/store.ts +256 -0
- package/src/state/types.ts +109 -0
- package/src/strategy/ascii-chart.ts +74 -0
- package/src/strategy/code-stream.ts +146 -0
- package/src/strategy/dashboard.ts +140 -0
- package/src/strategy/persistence.ts +82 -0
- package/src/strategy/prompts.ts +626 -0
- package/src/strategy/sandbox.ts +137 -0
- package/src/strategy/tools.ts +764 -0
- package/src/strategy/validator.ts +216 -0
- package/src/strategy/widgets.ts +270 -0
- package/src/syntax/setup.ts +54 -0
- package/src/theme/colors.ts +107 -0
- package/src/theme/icons.ts +27 -0
- package/src/util/hyperlink.ts +21 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { AppState, Session, StrategyDraft } from "./types.ts";
|
|
2
|
+
import type { Message } from "../chat/types.ts";
|
|
3
|
+
|
|
4
|
+
export type StateListener = (state: AppState) => void;
|
|
5
|
+
|
|
6
|
+
function createSession(name: string): Session {
|
|
7
|
+
const id = `session-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
8
|
+
return {
|
|
9
|
+
id, name, messages: [],
|
|
10
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
11
|
+
mode: "research", isStreaming: false, streamingMsgId: null, strategyDraft: null,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createInitialState(): AppState {
|
|
16
|
+
// Start with NO sessions — they load from Supabase or get created on first input
|
|
17
|
+
return {
|
|
18
|
+
connection: "connected",
|
|
19
|
+
operatingMode: "supervised",
|
|
20
|
+
sessions: [],
|
|
21
|
+
activeSessionId: "",
|
|
22
|
+
openTabIds: [],
|
|
23
|
+
deployments: [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class Store {
|
|
28
|
+
private state: AppState;
|
|
29
|
+
private listeners: Set<StateListener> = new Set();
|
|
30
|
+
private notifyPending = false;
|
|
31
|
+
|
|
32
|
+
constructor() { this.state = createInitialState(); }
|
|
33
|
+
|
|
34
|
+
get(): AppState { return this.state; }
|
|
35
|
+
|
|
36
|
+
update(partial: Partial<AppState>): void {
|
|
37
|
+
this.state = { ...this.state, ...partial };
|
|
38
|
+
// Ensure openTabIds has no duplicates
|
|
39
|
+
if (partial.openTabIds) {
|
|
40
|
+
this.state.openTabIds = [...new Set(this.state.openTabIds)];
|
|
41
|
+
}
|
|
42
|
+
this.notify();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Session getters ──
|
|
46
|
+
|
|
47
|
+
getActiveSession(): Session | undefined {
|
|
48
|
+
return this.state.sessions.find((s) => s.id === this.state.activeSessionId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getSession(id: string): Session | undefined {
|
|
52
|
+
return this.state.sessions.find((s) => s.id === id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Message operations (take explicit sessionId) ──
|
|
56
|
+
|
|
57
|
+
addMessageTo(sessionId: string, message: Message): void {
|
|
58
|
+
const sessions = this.state.sessions.map((s) => {
|
|
59
|
+
if (s.id !== sessionId) return s;
|
|
60
|
+
return { ...s, messages: [...s.messages, message], updatedAt: Date.now() };
|
|
61
|
+
});
|
|
62
|
+
this.state = { ...this.state, sessions };
|
|
63
|
+
this.notify();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Convenience: add to active session
|
|
67
|
+
addMessage(message: Message): void {
|
|
68
|
+
this.addMessageTo(this.state.activeSessionId, message);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
updateMessageIn(sessionId: string, messageId: string, updates: Partial<Message>): void {
|
|
72
|
+
const sessions = this.state.sessions.map((s) => {
|
|
73
|
+
if (s.id !== sessionId) return s;
|
|
74
|
+
const messages = s.messages.map((m) => m.id !== messageId ? m : { ...m, ...updates });
|
|
75
|
+
return { ...s, messages, updatedAt: Date.now() };
|
|
76
|
+
});
|
|
77
|
+
this.state = { ...this.state, sessions };
|
|
78
|
+
this.notify();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
updateMessage(messageId: string, updates: Partial<Message>): void {
|
|
82
|
+
this.updateMessageIn(this.state.activeSessionId, messageId, updates);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Session streaming state ──
|
|
86
|
+
|
|
87
|
+
setSessionStreaming(sessionId: string, streaming: boolean, msgId?: string | null): void {
|
|
88
|
+
const sessions = this.state.sessions.map((s) => {
|
|
89
|
+
if (s.id !== sessionId) return s;
|
|
90
|
+
return { ...s, isStreaming: streaming, streamingMsgId: msgId ?? null };
|
|
91
|
+
});
|
|
92
|
+
this.state = { ...this.state, sessions };
|
|
93
|
+
this.notify();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
isAnyStreaming(): boolean {
|
|
97
|
+
return this.state.sessions.some((s) => s.isStreaming);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Session mode ──
|
|
101
|
+
|
|
102
|
+
setSessionMode(sessionId: string, mode: Session["mode"]): void {
|
|
103
|
+
const sessions = this.state.sessions.map((s) =>
|
|
104
|
+
s.id === sessionId ? { ...s, mode } : s
|
|
105
|
+
);
|
|
106
|
+
this.state = { ...this.state, sessions };
|
|
107
|
+
this.notify();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Strategy draft (per-session) ──
|
|
111
|
+
|
|
112
|
+
setStrategyDraft(draft: StrategyDraft): void {
|
|
113
|
+
const sid = this.state.activeSessionId;
|
|
114
|
+
const sessions = this.state.sessions.map((s) =>
|
|
115
|
+
s.id === sid ? { ...s, strategyDraft: draft } : s
|
|
116
|
+
);
|
|
117
|
+
this.state = { ...this.state, sessions };
|
|
118
|
+
this.notify();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
updateStrategyDraft(updates: Partial<StrategyDraft>): void {
|
|
122
|
+
const sid = this.state.activeSessionId;
|
|
123
|
+
const sessions = this.state.sessions.map((s) => {
|
|
124
|
+
if (s.id !== sid || !s.strategyDraft) return s;
|
|
125
|
+
return { ...s, strategyDraft: { ...s.strategyDraft, ...updates } };
|
|
126
|
+
});
|
|
127
|
+
this.state = { ...this.state, sessions };
|
|
128
|
+
this.notify();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
clearStrategyDraft(): void {
|
|
132
|
+
const sid = this.state.activeSessionId;
|
|
133
|
+
const sessions = this.state.sessions.map((s) =>
|
|
134
|
+
s.id === sid ? { ...s, strategyDraft: null } : s
|
|
135
|
+
);
|
|
136
|
+
this.state = { ...this.state, sessions };
|
|
137
|
+
this.notify();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Tab management ──
|
|
141
|
+
|
|
142
|
+
newSession(): string {
|
|
143
|
+
const session = createSession("New chat");
|
|
144
|
+
this.state = {
|
|
145
|
+
...this.state,
|
|
146
|
+
sessions: [...this.state.sessions, session],
|
|
147
|
+
activeSessionId: session.id,
|
|
148
|
+
openTabIds: [...this.state.openTabIds, session.id],
|
|
149
|
+
};
|
|
150
|
+
this.notify();
|
|
151
|
+
return session.id;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
openTab(sessionId: string): void {
|
|
155
|
+
if (!this.state.openTabIds.includes(sessionId)) {
|
|
156
|
+
this.state = {
|
|
157
|
+
...this.state,
|
|
158
|
+
openTabIds: [...this.state.openTabIds, sessionId],
|
|
159
|
+
activeSessionId: sessionId,
|
|
160
|
+
};
|
|
161
|
+
} else {
|
|
162
|
+
this.state = { ...this.state, activeSessionId: sessionId };
|
|
163
|
+
}
|
|
164
|
+
this.notify();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
closeTab(sessionId: string): void {
|
|
168
|
+
const tabs = this.state.openTabIds.filter((id) => id !== sessionId);
|
|
169
|
+
if (tabs.length === 0) {
|
|
170
|
+
// Always keep at least one tab
|
|
171
|
+
const newId = this.newSession();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const activeId = sessionId === this.state.activeSessionId
|
|
175
|
+
? tabs[Math.max(0, this.state.openTabIds.indexOf(sessionId) - 1)] ?? tabs[0]!
|
|
176
|
+
: this.state.activeSessionId;
|
|
177
|
+
this.state = { ...this.state, openTabIds: tabs, activeSessionId: activeId };
|
|
178
|
+
this.notify();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
nextTab(): void {
|
|
182
|
+
const { openTabIds, activeSessionId } = this.state;
|
|
183
|
+
if (openTabIds.length <= 1) return;
|
|
184
|
+
const idx = openTabIds.indexOf(activeSessionId);
|
|
185
|
+
const next = openTabIds[(idx + 1) % openTabIds.length]!;
|
|
186
|
+
this.state = { ...this.state, activeSessionId: next };
|
|
187
|
+
this.notify();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
prevTab(): void {
|
|
191
|
+
const { openTabIds, activeSessionId } = this.state;
|
|
192
|
+
if (openTabIds.length <= 1) return;
|
|
193
|
+
const idx = openTabIds.indexOf(activeSessionId);
|
|
194
|
+
const prev = openTabIds[(idx - 1 + openTabIds.length) % openTabIds.length]!;
|
|
195
|
+
this.state = { ...this.state, activeSessionId: prev };
|
|
196
|
+
this.notify();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
jumpToTab(index: number): void {
|
|
200
|
+
const tab = this.state.openTabIds[index];
|
|
201
|
+
if (tab) {
|
|
202
|
+
this.state = { ...this.state, activeSessionId: tab };
|
|
203
|
+
this.notify();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Session CRUD ──
|
|
208
|
+
|
|
209
|
+
deleteSession(sessionId: string): void {
|
|
210
|
+
// Close tab if open
|
|
211
|
+
const tabs = this.state.openTabIds.filter((id) => id !== sessionId);
|
|
212
|
+
const sessions = this.state.sessions.filter((s) => s.id !== sessionId);
|
|
213
|
+
if (sessions.length === 0) {
|
|
214
|
+
const s = createSession("New chat");
|
|
215
|
+
sessions.push(s);
|
|
216
|
+
tabs.push(s.id);
|
|
217
|
+
}
|
|
218
|
+
const activeId = sessionId === this.state.activeSessionId
|
|
219
|
+
? (tabs[0] ?? sessions[0]!.id) : this.state.activeSessionId;
|
|
220
|
+
this.state = { ...this.state, sessions, openTabIds: tabs, activeSessionId: activeId };
|
|
221
|
+
this.notify();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
renameSession(sessionId: string, name: string): void {
|
|
225
|
+
const sessions = this.state.sessions.map((s) =>
|
|
226
|
+
s.id === sessionId ? { ...s, name, updatedAt: Date.now() } : s
|
|
227
|
+
);
|
|
228
|
+
this.state = { ...this.state, sessions };
|
|
229
|
+
this.notify();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
togglePinSession(sessionId: string): void {
|
|
233
|
+
const sessions = this.state.sessions.map((s) =>
|
|
234
|
+
s.id === sessionId ? { ...s, pinned: !s.pinned } : s
|
|
235
|
+
);
|
|
236
|
+
this.state = { ...this.state, sessions };
|
|
237
|
+
this.notify();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
subscribe(listener: StateListener): () => void {
|
|
241
|
+
this.listeners.add(listener);
|
|
242
|
+
return () => this.listeners.delete(listener);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private notify(): void {
|
|
246
|
+
// Batch: collapse multiple synchronous mutations into a single notification
|
|
247
|
+
if (this.notifyPending) return;
|
|
248
|
+
this.notifyPending = true;
|
|
249
|
+
queueMicrotask(() => {
|
|
250
|
+
this.notifyPending = false;
|
|
251
|
+
for (const listener of this.listeners) listener(this.state);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export const store = new Store();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Message } from "../chat/types.ts";
|
|
2
|
+
import type { Mode } from "../components/mode-bar.ts";
|
|
3
|
+
|
|
4
|
+
export type ConnectionStatus = "connected" | "connecting" | "disconnected";
|
|
5
|
+
export type OperatingMode = "dry_run" | "supervised" | "autonomous";
|
|
6
|
+
|
|
7
|
+
export type DeploymentStatus =
|
|
8
|
+
| "pending" | "starting" | "running" | "queued" | "restarting"
|
|
9
|
+
| "scanner_idle" | "stopped" | "error" | "killed" | "stopping";
|
|
10
|
+
|
|
11
|
+
export interface Position {
|
|
12
|
+
market_id: string;
|
|
13
|
+
slug: string;
|
|
14
|
+
question: string;
|
|
15
|
+
side: "BUY" | "SELL";
|
|
16
|
+
size: number;
|
|
17
|
+
avg_entry_price: number;
|
|
18
|
+
cost_basis: number;
|
|
19
|
+
realized_pnl: number;
|
|
20
|
+
unrealized_pnl: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Order {
|
|
24
|
+
market_id: string;
|
|
25
|
+
slug: string;
|
|
26
|
+
side: "BUY" | "SELL";
|
|
27
|
+
price: number;
|
|
28
|
+
size: number;
|
|
29
|
+
filled_size: number;
|
|
30
|
+
status: "PENDING" | "LIVE" | "PARTIALLY_FILLED" | "FILLED" | "CANCELLED";
|
|
31
|
+
order_type: "GTC" | "IOC";
|
|
32
|
+
created_at: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DeploymentMetrics {
|
|
36
|
+
total_pnl: number;
|
|
37
|
+
realized_pnl: number;
|
|
38
|
+
unrealized_pnl: number;
|
|
39
|
+
total_exposure: number;
|
|
40
|
+
position_count: number;
|
|
41
|
+
open_order_count: number;
|
|
42
|
+
win_rate: number;
|
|
43
|
+
total_trades: number;
|
|
44
|
+
max_drawdown_pct: number;
|
|
45
|
+
sharpe_ratio: number;
|
|
46
|
+
profit_factor: number;
|
|
47
|
+
avg_return_per_trade: number;
|
|
48
|
+
gross_profit: number;
|
|
49
|
+
gross_loss: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Deployment {
|
|
53
|
+
id: string;
|
|
54
|
+
strategyId: string; // strategy UUID (used for API calls)
|
|
55
|
+
name: string;
|
|
56
|
+
strategy_type: string;
|
|
57
|
+
status: DeploymentStatus;
|
|
58
|
+
dry_run: boolean;
|
|
59
|
+
mode: "manual" | "scanner";
|
|
60
|
+
metrics: DeploymentMetrics;
|
|
61
|
+
positions: Position[];
|
|
62
|
+
orders: Order[];
|
|
63
|
+
pnl_history: number[];
|
|
64
|
+
started_at: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface StrategyDraft {
|
|
68
|
+
name: string;
|
|
69
|
+
code: string;
|
|
70
|
+
params: Record<string, unknown>;
|
|
71
|
+
explanation: string;
|
|
72
|
+
riskConfig: {
|
|
73
|
+
max_position: number;
|
|
74
|
+
max_notional: number;
|
|
75
|
+
max_drawdown_pct: number;
|
|
76
|
+
max_order_size?: number;
|
|
77
|
+
rate_limit?: number;
|
|
78
|
+
rate_burst?: number;
|
|
79
|
+
} | null;
|
|
80
|
+
validationStatus: "pending" | "valid" | "invalid" | "none";
|
|
81
|
+
validationErrors: { line: number | null; message: string }[];
|
|
82
|
+
phase: "generated" | "iterated" | "validated" | "saved" | "deployed";
|
|
83
|
+
strategyId?: string;
|
|
84
|
+
filePath?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Each session is a full independent workspace
|
|
88
|
+
export interface Session {
|
|
89
|
+
id: string;
|
|
90
|
+
name: string;
|
|
91
|
+
messages: Message[];
|
|
92
|
+
createdAt: number;
|
|
93
|
+
updatedAt: number;
|
|
94
|
+
pinned?: boolean;
|
|
95
|
+
// Per-tab workspace state
|
|
96
|
+
mode: Mode;
|
|
97
|
+
isStreaming: boolean;
|
|
98
|
+
streamingMsgId: string | null;
|
|
99
|
+
strategyDraft: StrategyDraft | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface AppState {
|
|
103
|
+
connection: ConnectionStatus;
|
|
104
|
+
operatingMode: OperatingMode;
|
|
105
|
+
sessions: Session[];
|
|
106
|
+
activeSessionId: string;
|
|
107
|
+
openTabIds: string[]; // tabs currently open (ordered)
|
|
108
|
+
deployments: Deployment[];
|
|
109
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// ASCII equity curve chart using box-drawing characters
|
|
2
|
+
|
|
3
|
+
const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Render a multi-line ASCII chart with box frame and Y-axis labels.
|
|
7
|
+
* Returns an array of strings (one per line).
|
|
8
|
+
*/
|
|
9
|
+
export function renderAsciiChart(
|
|
10
|
+
values: number[],
|
|
11
|
+
width = 48,
|
|
12
|
+
height = 8,
|
|
13
|
+
): string[] {
|
|
14
|
+
if (values.length === 0) return [" (no data)"];
|
|
15
|
+
|
|
16
|
+
// Resample to fit width
|
|
17
|
+
const sampled: number[] = [];
|
|
18
|
+
for (let i = 0; i < width; i++) {
|
|
19
|
+
const idx = Math.floor((i / width) * values.length);
|
|
20
|
+
sampled.push(values[idx] ?? 0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const min = Math.min(...sampled);
|
|
24
|
+
const max = Math.max(...sampled);
|
|
25
|
+
const range = max - min || 1;
|
|
26
|
+
|
|
27
|
+
// Build chart grid (height rows × width cols)
|
|
28
|
+
// Each cell maps to a sub-block level
|
|
29
|
+
const grid: string[][] = [];
|
|
30
|
+
for (let row = 0; row < height; row++) {
|
|
31
|
+
grid.push(new Array(width).fill(" "));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (let col = 0; col < width; col++) {
|
|
35
|
+
const normalized = (sampled[col]! - min) / range; // 0..1
|
|
36
|
+
const totalBlocks = normalized * height;
|
|
37
|
+
const fullRows = Math.floor(totalBlocks);
|
|
38
|
+
const partialLevel = Math.round((totalBlocks - fullRows) * (BLOCKS.length - 1));
|
|
39
|
+
|
|
40
|
+
// Fill from bottom up
|
|
41
|
+
for (let row = 0; row < fullRows && row < height; row++) {
|
|
42
|
+
grid[height - 1 - row]![col] = BLOCKS[BLOCKS.length - 1]!;
|
|
43
|
+
}
|
|
44
|
+
if (fullRows < height && partialLevel > 0) {
|
|
45
|
+
grid[height - 1 - fullRows]![col] = BLOCKS[partialLevel]!;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Y-axis labels (top, middle, bottom)
|
|
50
|
+
const fmtVal = (v: number): string => {
|
|
51
|
+
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}k`;
|
|
52
|
+
if (Math.abs(v) >= 100) return v.toFixed(0);
|
|
53
|
+
return v.toFixed(1);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const topLabel = fmtVal(max).padStart(6);
|
|
57
|
+
const midLabel = fmtVal((max + min) / 2).padStart(6);
|
|
58
|
+
const botLabel = fmtVal(min).padStart(6);
|
|
59
|
+
|
|
60
|
+
const lines: string[] = [];
|
|
61
|
+
|
|
62
|
+
// Top border
|
|
63
|
+
lines.push(`${topLabel} ┌${"─".repeat(width)}┐`);
|
|
64
|
+
|
|
65
|
+
for (let row = 0; row < height; row++) {
|
|
66
|
+
const label = row === Math.floor(height / 2) ? midLabel : " ";
|
|
67
|
+
lines.push(`${label} │${grid[row]!.join("")}│`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Bottom border
|
|
71
|
+
lines.push(`${botLabel} └${"─".repeat(width)}┘`);
|
|
72
|
+
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Code fence detection + strategy finalization
|
|
2
|
+
// Intercepts ```python fences in LLM text stream and routes code to the panel.
|
|
3
|
+
// This replaces propose_strategy/update_strategy tools — the LLM writes code
|
|
4
|
+
// directly in its response, and we catch it and handle validation/saving.
|
|
5
|
+
|
|
6
|
+
import { validateStrategyCode, autoFixStrategyCode } from "./validator.ts";
|
|
7
|
+
import { saveStrategy } from "./persistence.ts";
|
|
8
|
+
import { store } from "../state/store.ts";
|
|
9
|
+
import type { StrategyDraft } from "../state/types.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Extract strategy name from code (hz.run or hz.backtest name= arg).
|
|
13
|
+
*/
|
|
14
|
+
export function extractStrategyName(code: string): string {
|
|
15
|
+
const runMatch = code.match(/hz\.(?:run|backtest)\s*\(\s*(?:[\s\S]*?)?name\s*=\s*["']([^"']+)["']/);
|
|
16
|
+
if (runMatch) return runMatch[1]!;
|
|
17
|
+
// Fallback: look for a class name or comment
|
|
18
|
+
const commentMatch = code.match(/#\s*Strategy:\s*(\S+)/i);
|
|
19
|
+
if (commentMatch) return commentMatch[1]!;
|
|
20
|
+
return "Strategy";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if code contains hz.run() or hz.backtest() — a real strategy, not a snippet.
|
|
25
|
+
*/
|
|
26
|
+
export function isStrategyCode(code: string): boolean {
|
|
27
|
+
return /hz\.(run|backtest)\s*\(/.test(code);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract risk config from hz.Risk(...) in code.
|
|
32
|
+
*/
|
|
33
|
+
function extractRiskConfig(code: string): StrategyDraft["riskConfig"] {
|
|
34
|
+
const riskMatch = code.match(/hz\.Risk\(([\s\S]*?)\)/);
|
|
35
|
+
if (!riskMatch) return null;
|
|
36
|
+
const riskArgs = riskMatch[1]!.replace(/\n/g, " ").trim();
|
|
37
|
+
|
|
38
|
+
const getNum = (key: string) => {
|
|
39
|
+
const m = riskArgs.match(new RegExp(`${key}\\s*=\\s*([\\d.]+)`));
|
|
40
|
+
return m ? parseFloat(m[1]!) : undefined;
|
|
41
|
+
};
|
|
42
|
+
const rl = getNum("rate_limit");
|
|
43
|
+
const rb = getNum("rate_burst");
|
|
44
|
+
return {
|
|
45
|
+
max_position: getNum("max_position") ?? 100,
|
|
46
|
+
max_notional: getNum("max_notional") ?? 1000,
|
|
47
|
+
max_drawdown_pct: getNum("max_drawdown_pct") ?? 5,
|
|
48
|
+
max_order_size: getNum("max_order_size"),
|
|
49
|
+
rate_limit: rl !== undefined ? Math.round(rl) : undefined,
|
|
50
|
+
rate_burst: rb !== undefined ? Math.round(rb) : undefined,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract params dict from code.
|
|
56
|
+
*/
|
|
57
|
+
function extractParams(code: string): Record<string, unknown> {
|
|
58
|
+
const paramsMatch = code.match(/params\s*=\s*(\{[^}]+\})/);
|
|
59
|
+
if (!paramsMatch) return {};
|
|
60
|
+
try { return JSON.parse(paramsMatch[1]!.replace(/'/g, '"')); } catch { return {}; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Finalize a strategy: auto-fix → validate → extract metadata → save → set draft.
|
|
65
|
+
* Called when a code fence closes in the text stream.
|
|
66
|
+
*/
|
|
67
|
+
export async function finalizeStrategy(code: string, overrideName?: string): Promise<StrategyDraft> {
|
|
68
|
+
const fixedCode = autoFixStrategyCode(code);
|
|
69
|
+
const errors = validateStrategyCode(fixedCode);
|
|
70
|
+
const name = overrideName ?? extractStrategyName(fixedCode);
|
|
71
|
+
|
|
72
|
+
const draft: StrategyDraft = {
|
|
73
|
+
name,
|
|
74
|
+
code: fixedCode,
|
|
75
|
+
params: extractParams(fixedCode),
|
|
76
|
+
explanation: "",
|
|
77
|
+
riskConfig: extractRiskConfig(fixedCode),
|
|
78
|
+
validationStatus: errors.length === 0 ? "valid" : "invalid",
|
|
79
|
+
validationErrors: errors,
|
|
80
|
+
phase: "generated",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
store.setStrategyDraft(draft);
|
|
84
|
+
await saveStrategy(name, fixedCode).catch(() => null);
|
|
85
|
+
return draft;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Streaming code fence detector ──
|
|
89
|
+
|
|
90
|
+
export class CodeFenceDetector {
|
|
91
|
+
private _inFence = false;
|
|
92
|
+
private _fenceStart = 0;
|
|
93
|
+
private _lastSearchPos = 0;
|
|
94
|
+
|
|
95
|
+
get inFence(): boolean { return this._inFence; }
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Feed the full accumulated text on each delta.
|
|
99
|
+
* Returns:
|
|
100
|
+
* - null: no code fence event
|
|
101
|
+
* - { event: "open" }: fence just opened
|
|
102
|
+
* - { event: "delta", code: string }: partial code update
|
|
103
|
+
* - { event: "close", code: string }: fence closed, full code available
|
|
104
|
+
*/
|
|
105
|
+
update(fullText: string): { event: "open" } | { event: "delta"; code: string } | { event: "close"; code: string } | null {
|
|
106
|
+
if (!this._inFence) {
|
|
107
|
+
// Look for opening fence
|
|
108
|
+
const markers = ["```python\n", "```python ", "```py\n", "```py "];
|
|
109
|
+
for (const marker of markers) {
|
|
110
|
+
const idx = fullText.indexOf(marker, this._lastSearchPos);
|
|
111
|
+
if (idx !== -1) {
|
|
112
|
+
this._inFence = true;
|
|
113
|
+
this._fenceStart = idx + marker.length;
|
|
114
|
+
this._lastSearchPos = this._fenceStart;
|
|
115
|
+
return { event: "open" };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// In fence — look for closing ```
|
|
122
|
+
// Match \n``` NOT followed by another backtick (avoids matching `````)
|
|
123
|
+
const searchFrom = Math.max(this._fenceStart, this._lastSearchPos - 4);
|
|
124
|
+
const rest = fullText.slice(searchFrom);
|
|
125
|
+
const closeMatch = rest.match(/\n```(?!`)/);
|
|
126
|
+
|
|
127
|
+
if (closeMatch && closeMatch.index !== undefined) {
|
|
128
|
+
const closeIdx = searchFrom + closeMatch.index;
|
|
129
|
+
this._inFence = false;
|
|
130
|
+
const code = fullText.slice(this._fenceStart, closeIdx);
|
|
131
|
+
this._lastSearchPos = closeIdx + 4;
|
|
132
|
+
return { event: "close", code };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Still in fence — return partial code
|
|
136
|
+
this._lastSearchPos = fullText.length;
|
|
137
|
+
const code = fullText.slice(this._fenceStart);
|
|
138
|
+
return { event: "delta", code };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
reset(): void {
|
|
142
|
+
this._inFence = false;
|
|
143
|
+
this._fenceStart = 0;
|
|
144
|
+
this._lastSearchPos = 0;
|
|
145
|
+
}
|
|
146
|
+
}
|