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,764 @@
|
|
|
1
|
+
// Strategy mode AI tools — full coding partner
|
|
2
|
+
// The LLM can: write strategy code, run it, backtest it, read logs, build dashboards, deploy it
|
|
3
|
+
|
|
4
|
+
import { tool } from "ai";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { validateStrategyCode, autoFixStrategyCode } from "./validator.ts";
|
|
7
|
+
import { gammaEvents } from "../research/apis.ts";
|
|
8
|
+
import { platform } from "../platform/client.ts";
|
|
9
|
+
import { store } from "../state/store.ts";
|
|
10
|
+
import { dashboard } from "./dashboard.ts";
|
|
11
|
+
import { runInSandbox, spawnInSandbox } from "./sandbox.ts";
|
|
12
|
+
import { saveStrategy, loadStrategy, listSavedStrategies } from "./persistence.ts";
|
|
13
|
+
import { hyperlink } from "../util/hyperlink.ts";
|
|
14
|
+
import type { StrategyDraft } from "../state/types.ts";
|
|
15
|
+
|
|
16
|
+
const t = tool as any;
|
|
17
|
+
|
|
18
|
+
const docsCache = new Map<string, string>();
|
|
19
|
+
|
|
20
|
+
// ── Process management ──
|
|
21
|
+
|
|
22
|
+
interface ManagedProcess {
|
|
23
|
+
proc: ReturnType<typeof Bun.spawn>;
|
|
24
|
+
stdout: string[];
|
|
25
|
+
stderr: string[];
|
|
26
|
+
startedAt: number;
|
|
27
|
+
cleanup?: () => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const runningProcesses = new Map<number, ManagedProcess>();
|
|
31
|
+
const customServers: ReturnType<typeof Bun.serve>[] = [];
|
|
32
|
+
|
|
33
|
+
function startCapturing(pid: number, managed: ManagedProcess): void {
|
|
34
|
+
// Stream stdout/stderr into rolling buffers
|
|
35
|
+
const readStream = async (stream: ReadableStream<Uint8Array>, buffer: string[]) => {
|
|
36
|
+
try {
|
|
37
|
+
const reader = stream.getReader();
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
while (true) {
|
|
40
|
+
const { done, value } = await reader.read();
|
|
41
|
+
if (done) break;
|
|
42
|
+
const text = decoder.decode(value);
|
|
43
|
+
for (const line of text.split("\n")) {
|
|
44
|
+
if (line) buffer.push(line);
|
|
45
|
+
}
|
|
46
|
+
// Rolling buffer — keep last 200 lines
|
|
47
|
+
while (buffer.length > 200) buffer.shift();
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
};
|
|
51
|
+
readStream(managed.proc.stdout as ReadableStream<Uint8Array>, managed.stdout);
|
|
52
|
+
readStream(managed.proc.stderr as ReadableStream<Uint8Array>, managed.stderr);
|
|
53
|
+
|
|
54
|
+
// Clean up when process exits
|
|
55
|
+
managed.proc.exited.then(() => {
|
|
56
|
+
// Keep in map for log reading — cleaned up on next run or quit
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Clean up all running processes and servers */
|
|
61
|
+
export function cleanupStrategyProcesses(): void {
|
|
62
|
+
for (const [pid, managed] of runningProcesses) {
|
|
63
|
+
managed.cleanup?.();
|
|
64
|
+
runningProcesses.delete(pid);
|
|
65
|
+
}
|
|
66
|
+
for (const server of customServers) {
|
|
67
|
+
try { server.stop(); } catch {}
|
|
68
|
+
}
|
|
69
|
+
customServers.length = 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Helper: extract hz.run() kwargs from strategy code ──
|
|
73
|
+
|
|
74
|
+
function extractRunKwargs(code: string) {
|
|
75
|
+
// Use multiline-aware extraction
|
|
76
|
+
const pipelineMatch = code.match(/pipeline\s*=\s*\[([\s\S]*?)\]/);
|
|
77
|
+
const pipelineFns = pipelineMatch
|
|
78
|
+
? pipelineMatch[1]!.split(",").map((s) => s.trim()).filter((s) => s && !s.startsWith("#"))
|
|
79
|
+
: [];
|
|
80
|
+
|
|
81
|
+
const riskMatch = code.match(/hz\.Risk\(([\s\S]*?)\)/);
|
|
82
|
+
const riskArgs = riskMatch ? riskMatch[1]!.replace(/\n/g, " ").trim() : "max_position=100, max_drawdown_pct=10";
|
|
83
|
+
|
|
84
|
+
// Match params={...} — handle nested braces by counting depth
|
|
85
|
+
let paramsStr = "{}";
|
|
86
|
+
const paramsStart = code.indexOf("params={");
|
|
87
|
+
if (paramsStart !== -1) {
|
|
88
|
+
let depth = 0;
|
|
89
|
+
let end = paramsStart + 7;
|
|
90
|
+
for (let i = paramsStart + 7; i < code.length; i++) {
|
|
91
|
+
if (code[i] === "{") depth++;
|
|
92
|
+
if (code[i] === "}") {
|
|
93
|
+
if (depth === 0) { end = i + 1; break; }
|
|
94
|
+
depth--;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
paramsStr = code.slice(paramsStart + 7, end);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Match all market slugs
|
|
101
|
+
const marketsMatch = code.match(/markets\s*=\s*\[([\s\S]*?)\]/);
|
|
102
|
+
let marketNames: string[] = ["test-market"];
|
|
103
|
+
if (marketsMatch) {
|
|
104
|
+
const raw = marketsMatch[1]!;
|
|
105
|
+
const slugs = [...raw.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]!);
|
|
106
|
+
if (slugs.length > 0) marketNames = slugs;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { pipelineFns, riskArgs, paramsStr, marketNames };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Safe environment for run_command (no secrets) ──
|
|
113
|
+
|
|
114
|
+
function commandEnv(): Record<string, string> {
|
|
115
|
+
const env: Record<string, string> = {};
|
|
116
|
+
const allow = ["PATH", "HOME", "LANG", "LC_ALL", "TERM", "PYTHONPATH", "VIRTUAL_ENV", "CONDA_PREFIX", "SHELL", "USER", "LOGNAME"];
|
|
117
|
+
for (const key of allow) {
|
|
118
|
+
if (process.env[key]) env[key] = process.env[key]!;
|
|
119
|
+
}
|
|
120
|
+
return env;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Tools ──
|
|
124
|
+
// Code generation uses text streaming (```python fences) detected by app.ts.
|
|
125
|
+
// These tools handle editing, validation, execution, and deployment.
|
|
126
|
+
|
|
127
|
+
export const strategyTools: Record<string, any> = {
|
|
128
|
+
|
|
129
|
+
edit_strategy: t({
|
|
130
|
+
description: "Make a targeted edit to the current strategy code using find-and-replace. Use for tweaking parameters, fixing bugs, adding a few lines. The find string must match exactly.",
|
|
131
|
+
parameters: z.object({
|
|
132
|
+
find: z.string().describe("Exact string to find in the current code"),
|
|
133
|
+
replace: z.string().describe("String to replace it with"),
|
|
134
|
+
change_summary: z.string().describe("What changed (1 sentence)"),
|
|
135
|
+
}),
|
|
136
|
+
execute: async (args: any) => {
|
|
137
|
+
const draft = store.getActiveSession()?.strategyDraft;
|
|
138
|
+
if (!draft) return { error: "No strategy loaded. Write a strategy in a ```python code fence first." };
|
|
139
|
+
|
|
140
|
+
if (!draft.code.includes(args.find)) {
|
|
141
|
+
return { error: `Could not find the text to replace. Make sure 'find' matches exactly.`, find: args.find };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const newCode = draft.code.replace(args.find, args.replace);
|
|
145
|
+
const fixedCode = autoFixStrategyCode(newCode);
|
|
146
|
+
const errors = validateStrategyCode(fixedCode);
|
|
147
|
+
|
|
148
|
+
store.updateStrategyDraft({
|
|
149
|
+
code: fixedCode,
|
|
150
|
+
validationStatus: errors.length === 0 ? "valid" : "invalid",
|
|
151
|
+
validationErrors: errors,
|
|
152
|
+
phase: "iterated",
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Auto-save
|
|
156
|
+
await saveStrategy(draft.name, fixedCode).catch(() => {});
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
strategy_name: draft.name,
|
|
160
|
+
code: fixedCode,
|
|
161
|
+
change_summary: args.change_summary,
|
|
162
|
+
validation: { valid: errors.length === 0, errors },
|
|
163
|
+
lines_changed: args.replace.split("\n").length,
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
|
|
168
|
+
validate_strategy: t({
|
|
169
|
+
description: "Re-validate the current strategy code. Validation runs automatically — only call if the user explicitly asks to validate.",
|
|
170
|
+
parameters: z.object({
|
|
171
|
+
code: z.string().optional().describe("Code to validate. Omit to validate current draft."),
|
|
172
|
+
}),
|
|
173
|
+
execute: async (args: any) => {
|
|
174
|
+
const code = args.code ?? store.getActiveSession()?.strategyDraft?.code;
|
|
175
|
+
if (!code) return { valid: false, errors: [{ line: null, message: "No strategy code to validate" }] };
|
|
176
|
+
|
|
177
|
+
const errors = validateStrategyCode(code);
|
|
178
|
+
|
|
179
|
+
if (!args.code && store.getActiveSession()?.strategyDraft) {
|
|
180
|
+
store.updateStrategyDraft({
|
|
181
|
+
validationStatus: errors.length === 0 ? "valid" : "invalid",
|
|
182
|
+
validationErrors: errors,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return { valid: errors.length === 0, errors };
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
|
|
189
|
+
lookup_sdk_docs: t({
|
|
190
|
+
description: "Look up advanced Horizon SDK docs (arbitrage, copy-trading, wallet-intelligence, stealth, sentinel, oracle). Do NOT call for basic SDK usage — types, feeds, pipeline, risk, backtesting are in the system prompt.",
|
|
191
|
+
parameters: z.object({
|
|
192
|
+
topic: z.string().describe("SDK topic: types, feeds, context, pipeline, hz.run, risk, backtesting, plotting, ascii-plotting, exchanges, arbitrage, signals, etc."),
|
|
193
|
+
}),
|
|
194
|
+
execute: async (args: any) => {
|
|
195
|
+
const topic = args.topic.toLowerCase().replace(/\s+/g, "-");
|
|
196
|
+
const cached = docsCache.get(topic);
|
|
197
|
+
if (cached) return { topic: args.topic, content: cached };
|
|
198
|
+
|
|
199
|
+
// Fetch the full markdown docs index (llms-full.txt) and extract the relevant section
|
|
200
|
+
try {
|
|
201
|
+
if (!docsCache.has("__full__")) {
|
|
202
|
+
const res = await fetch("https://mathematicalcompany.mintlify.app/llms-full.txt", {
|
|
203
|
+
signal: AbortSignal.timeout(8000),
|
|
204
|
+
});
|
|
205
|
+
if (res.ok) {
|
|
206
|
+
docsCache.set("__full__", await res.text());
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const fullDocs = docsCache.get("__full__");
|
|
211
|
+
if (fullDocs) {
|
|
212
|
+
// Find the section matching the topic — look for markdown headers
|
|
213
|
+
const topicVariants = [topic, topic.replace(/-/g, " "), args.topic];
|
|
214
|
+
let bestMatch = "";
|
|
215
|
+
for (const variant of topicVariants) {
|
|
216
|
+
// Find a heading containing the topic
|
|
217
|
+
const headingPattern = new RegExp(`^#+\\s+.*${variant.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`, "im");
|
|
218
|
+
const headingMatch = fullDocs.match(headingPattern);
|
|
219
|
+
if (headingMatch?.index !== undefined) {
|
|
220
|
+
// Extract from this heading to the next same-level heading (or 4000 chars)
|
|
221
|
+
const level = headingMatch[0].match(/^#+/)![0].length;
|
|
222
|
+
const rest = fullDocs.slice(headingMatch.index);
|
|
223
|
+
const nextHeading = rest.slice(1).search(new RegExp(`^#{1,${level}}\\s`, "m"));
|
|
224
|
+
const section = nextHeading !== -1 ? rest.slice(0, nextHeading + 1) : rest;
|
|
225
|
+
bestMatch = section.slice(0, 4000);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (bestMatch) {
|
|
231
|
+
docsCache.set(topic, bestMatch);
|
|
232
|
+
return { topic: args.topic, content: bestMatch };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Fallback: keyword search in the full doc
|
|
236
|
+
const lines = fullDocs.split("\n");
|
|
237
|
+
const relevant: string[] = [];
|
|
238
|
+
const keywords = topic.split("-").filter((w: string) => w.length > 2);
|
|
239
|
+
for (let i = 0; i < lines.length && relevant.length < 80; i++) {
|
|
240
|
+
const line = lines[i]!.toLowerCase();
|
|
241
|
+
if (keywords.some((kw: string) => line.includes(kw))) {
|
|
242
|
+
// Include surrounding context
|
|
243
|
+
const start = Math.max(0, i - 2);
|
|
244
|
+
const end = Math.min(lines.length, i + 5);
|
|
245
|
+
for (let j = start; j < end; j++) {
|
|
246
|
+
if (!relevant.includes(lines[j]!)) relevant.push(lines[j]!);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (relevant.length > 0) {
|
|
251
|
+
const content = relevant.join("\n").slice(0, 4000);
|
|
252
|
+
docsCache.set(topic, content);
|
|
253
|
+
return { topic: args.topic, content };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} catch {}
|
|
257
|
+
|
|
258
|
+
return { topic: args.topic, content: `SDK docs for "${args.topic}" not found. Use the embedded reference in your system prompt.` };
|
|
259
|
+
},
|
|
260
|
+
}),
|
|
261
|
+
|
|
262
|
+
polymarket_data: t({
|
|
263
|
+
description: "Search Polymarket for real market slugs, spreads, volume. Call only when you need a real market slug or the user asks about specific markets.",
|
|
264
|
+
parameters: z.object({
|
|
265
|
+
query: z.string().describe("Search query (e.g. 'bitcoin', 'election')"),
|
|
266
|
+
limit: z.number().optional().describe("Max results (default 5)"),
|
|
267
|
+
}),
|
|
268
|
+
execute: async (args: any) => {
|
|
269
|
+
const events = await gammaEvents({ query: args.query, limit: args.limit ?? 5 });
|
|
270
|
+
return {
|
|
271
|
+
query: args.query,
|
|
272
|
+
markets: events.map((e: any) => ({
|
|
273
|
+
title: e.title, slug: e.slug,
|
|
274
|
+
yesPrice: e.markets?.[0]?.yesPrice,
|
|
275
|
+
noPrice: e.markets?.[0]?.noPrice,
|
|
276
|
+
volume24hr: e.volume24hr, liquidity: e.liquidity,
|
|
277
|
+
spread: e.markets?.[0]?.bestAsk && e.markets?.[0]?.bestBid
|
|
278
|
+
? +(e.markets[0].bestAsk - e.markets[0].bestBid).toFixed(4) : null,
|
|
279
|
+
})),
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
}),
|
|
283
|
+
|
|
284
|
+
// ── Backtest (real hz.backtest()) ──
|
|
285
|
+
|
|
286
|
+
backtest_strategy: t({
|
|
287
|
+
description: "Run hz.backtest() on the current strategy using the Horizon SDK locally. Returns real metrics, equity curve, and ASCII dashboard.",
|
|
288
|
+
parameters: z.object({
|
|
289
|
+
data_points: z.number().optional().describe("Synthetic price ticks (default 500)"),
|
|
290
|
+
initial_capital: z.number().optional().describe("Starting capital USD (default 1000)"),
|
|
291
|
+
base_price: z.number().optional().describe("Base price (default 0.50)"),
|
|
292
|
+
fill_model: z.enum(["deterministic", "probabilistic", "glft"]).optional().describe("Fill model (default deterministic)"),
|
|
293
|
+
}),
|
|
294
|
+
execute: async (args: any) => {
|
|
295
|
+
const draft = store.getActiveSession()?.strategyDraft;
|
|
296
|
+
if (!draft) return { error: "No strategy loaded yet." };
|
|
297
|
+
if (draft.validationStatus === "invalid") {
|
|
298
|
+
return { error: "Strategy has validation errors. Fix them first.", errors: draft.validationErrors };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const dataPoints = args.data_points ?? 500;
|
|
302
|
+
const capital = args.initial_capital ?? 1000;
|
|
303
|
+
const basePrice = args.base_price ?? 0.50;
|
|
304
|
+
const fillModel = args.fill_model ?? "deterministic";
|
|
305
|
+
|
|
306
|
+
const code = draft.code;
|
|
307
|
+
const hzRunIdx = code.search(/hz\.run\s*\(/);
|
|
308
|
+
const pipelineCode = hzRunIdx !== -1 ? code.slice(0, hzRunIdx).trimEnd() : code;
|
|
309
|
+
const { pipelineFns, riskArgs, paramsStr, marketNames } = extractRunKwargs(code);
|
|
310
|
+
|
|
311
|
+
if (pipelineFns.length === 0) {
|
|
312
|
+
return { error: "Could not extract pipeline functions. Ensure pipeline=[...] is in hz.run()." };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Write backtest config to a JSON file (avoids template injection)
|
|
316
|
+
const backtestConfig = {
|
|
317
|
+
name: draft.name.replace(/[^a-zA-Z0-9_-]/g, "_"),
|
|
318
|
+
markets: marketNames,
|
|
319
|
+
data_points: dataPoints,
|
|
320
|
+
base_price: basePrice,
|
|
321
|
+
initial_capital: capital,
|
|
322
|
+
fill_model: fillModel,
|
|
323
|
+
risk_args: riskArgs,
|
|
324
|
+
params_str: paramsStr,
|
|
325
|
+
pipeline_fns: pipelineFns,
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const script = `
|
|
329
|
+
import horizon as hz
|
|
330
|
+
from horizon.context import FeedData
|
|
331
|
+
import json, sys
|
|
332
|
+
|
|
333
|
+
${pipelineCode}
|
|
334
|
+
|
|
335
|
+
# Load config from JSON file (no string interpolation)
|
|
336
|
+
with open("backtest_config.json") as f:
|
|
337
|
+
_cfg = json.load(f)
|
|
338
|
+
|
|
339
|
+
data = []
|
|
340
|
+
price = _cfg["base_price"]
|
|
341
|
+
for i in range(_cfg["data_points"]):
|
|
342
|
+
noise = (((i * 7 + 13) % 100) / 100 - 0.5) * 0.05
|
|
343
|
+
revert = (_cfg["base_price"] - price) * 0.05
|
|
344
|
+
price = max(0.01, min(0.99, price + noise + revert))
|
|
345
|
+
data.append({"timestamp": float(i), "price": round(price, 4)})
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
result = hz.backtest(
|
|
349
|
+
name=_cfg["name"],
|
|
350
|
+
markets=_cfg["markets"],
|
|
351
|
+
data=data,
|
|
352
|
+
pipeline=[${pipelineFns.join(", ")}],
|
|
353
|
+
risk=hz.Risk(${riskArgs}),
|
|
354
|
+
params=${paramsStr},
|
|
355
|
+
initial_capital=_cfg["initial_capital"],
|
|
356
|
+
fill_model=_cfg["fill_model"],
|
|
357
|
+
)
|
|
358
|
+
m = result.metrics
|
|
359
|
+
out = {
|
|
360
|
+
"strategy_name": _cfg["name"],
|
|
361
|
+
"equity_curve": [round(e, 2) for _, e in result.equity_curve],
|
|
362
|
+
"trade_count": len(result.trades),
|
|
363
|
+
"metrics": {
|
|
364
|
+
"total_return": round(getattr(m, 'total_return', 0), 4),
|
|
365
|
+
"max_drawdown": round(getattr(m, 'max_drawdown', 0), 4),
|
|
366
|
+
"sharpe_ratio": round(getattr(m, 'sharpe_ratio', 0), 2),
|
|
367
|
+
"sortino_ratio": round(getattr(m, 'sortino_ratio', 0), 2),
|
|
368
|
+
"win_rate": round(getattr(m, 'win_rate', 0), 4),
|
|
369
|
+
"profit_factor": round(getattr(m, 'profit_factor', 0), 2),
|
|
370
|
+
"total_trades": getattr(m, 'trade_count', 0),
|
|
371
|
+
"expectancy": round(getattr(m, 'expectancy', 0), 4),
|
|
372
|
+
"total_fees": round(getattr(m, 'total_fees', 0), 4),
|
|
373
|
+
},
|
|
374
|
+
"pnl_by_market": result.pnl_by_market(),
|
|
375
|
+
}
|
|
376
|
+
try:
|
|
377
|
+
out["summary"] = result.summary()
|
|
378
|
+
except:
|
|
379
|
+
pass
|
|
380
|
+
print("---BACKTEST_JSON---")
|
|
381
|
+
print(json.dumps(out))
|
|
382
|
+
print("---END_BACKTEST_JSON---")
|
|
383
|
+
try:
|
|
384
|
+
bundle = hz.from_backtest(result)
|
|
385
|
+
print("---ASCII_DASHBOARD---")
|
|
386
|
+
print(hz.dashboard(bundle))
|
|
387
|
+
print("---END_ASCII_DASHBOARD---")
|
|
388
|
+
except Exception as de:
|
|
389
|
+
print(f"---ASCII_DASHBOARD---\\nDashboard error: {de}\\n---END_ASCII_DASHBOARD---")
|
|
390
|
+
except Exception as e:
|
|
391
|
+
import traceback
|
|
392
|
+
print("---BACKTEST_ERROR---")
|
|
393
|
+
print(str(e))
|
|
394
|
+
traceback.print_exc()
|
|
395
|
+
print("---END_BACKTEST_ERROR---")
|
|
396
|
+
sys.exit(1)
|
|
397
|
+
`;
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
// Write config to a temp file that the Python script reads (avoids template injection)
|
|
401
|
+
const { mkdtemp } = await import("fs/promises");
|
|
402
|
+
const { join } = await import("path");
|
|
403
|
+
const { tmpdir } = await import("os");
|
|
404
|
+
const sandboxDir = await mkdtemp(join(tmpdir(), "horizon-backtest-"));
|
|
405
|
+
await Bun.write(join(sandboxDir, "backtest_config.json"), JSON.stringify(backtestConfig));
|
|
406
|
+
|
|
407
|
+
const { stdout, stderr, exitCode, timedOut } = await runInSandbox({ code: script, timeout: 60000, cwd: sandboxDir });
|
|
408
|
+
|
|
409
|
+
if (timedOut) return { error: "Backtest timed out (60s limit)" };
|
|
410
|
+
|
|
411
|
+
// Check for early crash (import failure, syntax error, etc.)
|
|
412
|
+
if (exitCode !== 0 && !stdout.includes("---BACKTEST_ERROR---") && !stdout.includes("---BACKTEST_JSON---")) {
|
|
413
|
+
const errLines = (stderr || stdout).trim().split("\n").slice(-10).join("\n");
|
|
414
|
+
return {
|
|
415
|
+
error: `Python exited with code ${exitCode}`,
|
|
416
|
+
detail: errLines || "No output",
|
|
417
|
+
hint: stderr.includes("ModuleNotFoundError") ? "Horizon SDK not found. Run: pip install horizon" : undefined,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (stdout.includes("---BACKTEST_ERROR---")) {
|
|
422
|
+
const errMsg = stdout.split("---BACKTEST_ERROR---")[1]?.split("---END_BACKTEST_ERROR---")[0]?.trim();
|
|
423
|
+
return { error: `Backtest failed: ${errMsg}` };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const jsonMatch = stdout.match(/---BACKTEST_JSON---([\s\S]*?)---END_BACKTEST_JSON---/);
|
|
427
|
+
const dashMatch = stdout.match(/---ASCII_DASHBOARD---([\s\S]*?)---END_ASCII_DASHBOARD---/);
|
|
428
|
+
|
|
429
|
+
if (!jsonMatch) {
|
|
430
|
+
return {
|
|
431
|
+
error: "Could not parse backtest output",
|
|
432
|
+
detail: stderr ? stderr.slice(0, 800) : stdout.slice(0, 800),
|
|
433
|
+
hint: "The Python process ran but produced no structured output",
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const result = JSON.parse(jsonMatch[1]!.trim());
|
|
438
|
+
return { ...result, ascii_dashboard: dashMatch ? dashMatch[1]!.trim() : null, duration: `${dataPoints} ticks` };
|
|
439
|
+
} catch (err) {
|
|
440
|
+
return { error: `Backtest execution failed: ${err instanceof Error ? err.message : String(err)}`, hint: "Ensure Horizon SDK is installed: pip install horizon" };
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
}),
|
|
444
|
+
|
|
445
|
+
// ── Local Execution & Process Management ──
|
|
446
|
+
|
|
447
|
+
run_strategy: t({
|
|
448
|
+
description: "Run the current strategy code locally as a background Python process. Returns PID. Use read_logs(pid) to monitor. Must use mode='paper'. Process shows in the status bar as 'N running'.",
|
|
449
|
+
parameters: z.object({
|
|
450
|
+
timeout_secs: z.number().optional().describe("Max runtime seconds (default 3600 = 1 hour)"),
|
|
451
|
+
}),
|
|
452
|
+
execute: async (args: any) => {
|
|
453
|
+
const draft = store.getActiveSession()?.strategyDraft;
|
|
454
|
+
if (!draft) return { error: "No strategy loaded yet." };
|
|
455
|
+
if (draft.validationStatus !== "valid") {
|
|
456
|
+
return { error: "Strategy must pass validation first." };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Safety: require paper mode
|
|
460
|
+
if (!/mode\s*=\s*["']paper["']/.test(draft.code)) {
|
|
461
|
+
return { error: 'Safety: strategy must use mode="paper" for local execution.' };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const timeout = args.timeout_secs ?? 3600;
|
|
465
|
+
try {
|
|
466
|
+
const { proc, cleanup } = spawnInSandbox(draft.code);
|
|
467
|
+
|
|
468
|
+
const pid = proc.pid;
|
|
469
|
+
const managed: ManagedProcess = { proc, stdout: [], stderr: [], startedAt: Date.now(), cleanup };
|
|
470
|
+
runningProcesses.set(pid, managed);
|
|
471
|
+
startCapturing(pid, managed);
|
|
472
|
+
|
|
473
|
+
// Auto-kill after timeout
|
|
474
|
+
setTimeout(() => {
|
|
475
|
+
const m = runningProcesses.get(pid);
|
|
476
|
+
if (m) { m.cleanup?.(); runningProcesses.delete(pid); }
|
|
477
|
+
}, timeout * 1000);
|
|
478
|
+
|
|
479
|
+
// Wait a beat to check for immediate crash
|
|
480
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
481
|
+
const exitCode = proc.exitCode;
|
|
482
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
483
|
+
const err = managed.stderr.join("\n");
|
|
484
|
+
runningProcesses.delete(pid);
|
|
485
|
+
return { error: `Process crashed immediately (exit ${exitCode})`, stderr: err.slice(0, 1000) };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
success: true, pid, status: "running", timeout_secs: timeout,
|
|
490
|
+
initial_output: managed.stdout.slice(0, 5).join("\n"),
|
|
491
|
+
message: `Strategy running (PID ${pid}). Use read_logs(${pid}) to see output. Auto-stops after ${Math.round(timeout / 60)}min.`,
|
|
492
|
+
};
|
|
493
|
+
} catch (err) {
|
|
494
|
+
return { error: `Failed to start: ${err instanceof Error ? err.message : String(err)}` };
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
}),
|
|
498
|
+
|
|
499
|
+
read_logs: t({
|
|
500
|
+
description: "Read stdout/stderr from a running or recently-exited background process. Use after run_strategy to monitor what the strategy is doing.",
|
|
501
|
+
parameters: z.object({
|
|
502
|
+
pid: z.number().describe("Process ID"),
|
|
503
|
+
lines: z.number().optional().describe("Number of recent lines (default 50)"),
|
|
504
|
+
stream: z.enum(["stdout", "stderr", "both"]).optional().describe("Which stream (default both)"),
|
|
505
|
+
}),
|
|
506
|
+
execute: async (args: any) => {
|
|
507
|
+
const managed = runningProcesses.get(args.pid);
|
|
508
|
+
if (!managed) return { error: `No process with PID ${args.pid}` };
|
|
509
|
+
|
|
510
|
+
const n = args.lines ?? 50;
|
|
511
|
+
const stream = args.stream ?? "both";
|
|
512
|
+
const alive = managed.proc.exitCode === null;
|
|
513
|
+
|
|
514
|
+
const result: any = {
|
|
515
|
+
pid: args.pid,
|
|
516
|
+
alive,
|
|
517
|
+
uptime_secs: Math.round((Date.now() - managed.startedAt) / 1000),
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
if (stream === "stdout" || stream === "both") {
|
|
521
|
+
result.stdout = managed.stdout.slice(-n).join("\n");
|
|
522
|
+
}
|
|
523
|
+
if (stream === "stderr" || stream === "both") {
|
|
524
|
+
result.stderr = managed.stderr.slice(-n).join("\n");
|
|
525
|
+
}
|
|
526
|
+
if (!alive) {
|
|
527
|
+
result.exit_code = managed.proc.exitCode;
|
|
528
|
+
}
|
|
529
|
+
return result;
|
|
530
|
+
},
|
|
531
|
+
}),
|
|
532
|
+
|
|
533
|
+
stop_process: t({
|
|
534
|
+
description: "Stop a running background process by PID.",
|
|
535
|
+
parameters: z.object({
|
|
536
|
+
pid: z.number().describe("Process ID to stop"),
|
|
537
|
+
}),
|
|
538
|
+
execute: async (args: any) => {
|
|
539
|
+
const managed = runningProcesses.get(args.pid);
|
|
540
|
+
if (!managed) return { error: `No process with PID ${args.pid}` };
|
|
541
|
+
const lastLogs = managed.stdout.slice(-10).join("\n");
|
|
542
|
+
managed.cleanup?.();
|
|
543
|
+
runningProcesses.delete(args.pid);
|
|
544
|
+
return { success: true, pid: args.pid, last_output: lastLogs };
|
|
545
|
+
},
|
|
546
|
+
}),
|
|
547
|
+
|
|
548
|
+
run_command: t({
|
|
549
|
+
description: "Execute a whitelisted shell command. For: pip install, file inspection, python scripts, git, opening URLs. Commands not in the whitelist are blocked.",
|
|
550
|
+
parameters: z.object({
|
|
551
|
+
command: z.string().describe("Shell command"),
|
|
552
|
+
timeout_secs: z.number().optional().describe("Max seconds (default 30)"),
|
|
553
|
+
}),
|
|
554
|
+
execute: async (args: any) => {
|
|
555
|
+
const cmd = args.command.trim().split(/\s+/)[0] ?? "";
|
|
556
|
+
const ALLOWED = new Set([
|
|
557
|
+
"pip", "pip3", "python", "python3", "node", "bun", "npm", "npx",
|
|
558
|
+
"cat", "head", "tail", "less", "wc", "ls", "find", "grep", "rg",
|
|
559
|
+
"pwd", "which", "git", "curl", "open", "xdg-open",
|
|
560
|
+
"mkdir", "touch", "cp", "mv",
|
|
561
|
+
]);
|
|
562
|
+
// Strict whitelist — no bypass. "env", "echo", "chmod", "rm" removed to prevent secret leaking/destruction.
|
|
563
|
+
if (!ALLOWED.has(cmd)) {
|
|
564
|
+
return { error: `Command "${cmd}" is not allowed.` };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Block shell tricks that could leak env or escape
|
|
568
|
+
const dangerous = /\$\(|`|\benv\b|\bexport\b|\bsource\b|\beval\b|\bexec\b|>\s*\/|\/etc\/|\/proc\/|~\/\.horizon/i;
|
|
569
|
+
if (dangerous.test(args.command)) {
|
|
570
|
+
return { error: "Command contains blocked patterns." };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const timeout = (args.timeout_secs ?? 30) * 1000;
|
|
574
|
+
try {
|
|
575
|
+
const proc = Bun.spawn(["bash", "-c", args.command], {
|
|
576
|
+
stdout: "pipe", stderr: "pipe",
|
|
577
|
+
env: commandEnv(), // restricted env — no secrets
|
|
578
|
+
});
|
|
579
|
+
const result = await Promise.race([
|
|
580
|
+
(async () => {
|
|
581
|
+
const stdout = await new Response(proc.stdout).text();
|
|
582
|
+
const stderr = await new Response(proc.stderr).text();
|
|
583
|
+
await proc.exited;
|
|
584
|
+
return { stdout, stderr, exitCode: proc.exitCode };
|
|
585
|
+
})(),
|
|
586
|
+
new Promise<never>((_, reject) =>
|
|
587
|
+
setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, timeout)
|
|
588
|
+
),
|
|
589
|
+
]);
|
|
590
|
+
return { stdout: result.stdout.slice(0, 3000), stderr: result.stderr.slice(0, 1000), exit_code: result.exitCode };
|
|
591
|
+
} catch (err) {
|
|
592
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
}),
|
|
596
|
+
|
|
597
|
+
// ── Save & Deploy ──
|
|
598
|
+
|
|
599
|
+
save_strategy: t({
|
|
600
|
+
description: "Save current strategy draft to the Horizon platform. Returns strategy_id needed for deploy_strategy. Code must pass validation first.",
|
|
601
|
+
parameters: z.object({ name: z.string().optional().describe("Override strategy name") }),
|
|
602
|
+
execute: async (args: any) => {
|
|
603
|
+
const draft = store.getActiveSession()?.strategyDraft;
|
|
604
|
+
if (!draft) return { error: "No strategy loaded." };
|
|
605
|
+
if (!draft.code) return { error: "Strategy has no code." };
|
|
606
|
+
if (draft.validationStatus === "invalid") {
|
|
607
|
+
return { error: "Strategy has validation errors. Fix them first.", errors: draft.validationErrors };
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
const data = await platform.createStrategy({
|
|
611
|
+
name: args.name ?? draft.name, code: draft.code,
|
|
612
|
+
params: draft.params, risk_config: draft.riskConfig ?? undefined,
|
|
613
|
+
});
|
|
614
|
+
store.updateStrategyDraft({ strategyId: data.id, phase: "saved" });
|
|
615
|
+
return {
|
|
616
|
+
success: true,
|
|
617
|
+
strategy_id: data.id,
|
|
618
|
+
name: draft.name,
|
|
619
|
+
hint: "Now call list_credentials() to get a credential_id, then deploy_strategy().",
|
|
620
|
+
};
|
|
621
|
+
} catch (err) {
|
|
622
|
+
return { error: `Failed to save: ${err instanceof Error ? err.message : String(err)}` };
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
}),
|
|
626
|
+
|
|
627
|
+
// ── Local file persistence ──
|
|
628
|
+
|
|
629
|
+
load_saved_strategy: t({
|
|
630
|
+
description: "Load a previously saved strategy from disk. Only call when the user explicitly asks to load or resume a saved strategy.",
|
|
631
|
+
parameters: z.object({
|
|
632
|
+
name: z.string().describe("Strategy name (filename without .py)"),
|
|
633
|
+
}),
|
|
634
|
+
execute: async (args: any) => {
|
|
635
|
+
const result = await loadStrategy(args.name);
|
|
636
|
+
if (!result) return { error: `Strategy "${args.name}" not found in ~/.horizon/strategies/` };
|
|
637
|
+
|
|
638
|
+
const fixedCode = autoFixStrategyCode(result.code);
|
|
639
|
+
const errors = validateStrategyCode(fixedCode);
|
|
640
|
+
|
|
641
|
+
const draft: StrategyDraft = {
|
|
642
|
+
name: args.name,
|
|
643
|
+
code: fixedCode,
|
|
644
|
+
params: {},
|
|
645
|
+
explanation: "",
|
|
646
|
+
riskConfig: null,
|
|
647
|
+
validationStatus: errors.length === 0 ? "valid" : "invalid",
|
|
648
|
+
validationErrors: errors,
|
|
649
|
+
phase: "generated",
|
|
650
|
+
};
|
|
651
|
+
store.setStrategyDraft(draft);
|
|
652
|
+
|
|
653
|
+
return {
|
|
654
|
+
strategy_name: args.name,
|
|
655
|
+
code: fixedCode,
|
|
656
|
+
path: result.path,
|
|
657
|
+
validation: { valid: errors.length === 0, errors },
|
|
658
|
+
};
|
|
659
|
+
},
|
|
660
|
+
}),
|
|
661
|
+
|
|
662
|
+
list_saved_strategies: t({
|
|
663
|
+
description: "List saved strategies on disk. Only call when the user asks to see their saved strategies. Do NOT call this before generating a new strategy.",
|
|
664
|
+
parameters: z.object({}),
|
|
665
|
+
execute: async () => {
|
|
666
|
+
const strategies = await listSavedStrategies();
|
|
667
|
+
return {
|
|
668
|
+
count: strategies.length,
|
|
669
|
+
strategies: strategies.map((s) => ({
|
|
670
|
+
name: s.name,
|
|
671
|
+
path: s.path,
|
|
672
|
+
modified: s.modified ? new Date(s.modified).toISOString() : null,
|
|
673
|
+
})),
|
|
674
|
+
};
|
|
675
|
+
},
|
|
676
|
+
}),
|
|
677
|
+
|
|
678
|
+
// ── Dashboard ──
|
|
679
|
+
|
|
680
|
+
spawn_dashboard: t({
|
|
681
|
+
description: `Serve a local web dashboard. Two modes:
|
|
682
|
+
|
|
683
|
+
1. **Built-in monitor** — pass strategy_id. Auto-connects to live platform metrics.
|
|
684
|
+
2. **Custom HTML** — pass custom_html you write from scratch.
|
|
685
|
+
|
|
686
|
+
Custom dashboards get a FREE live API:
|
|
687
|
+
- GET /api/metrics → platform metrics (if strategy_id provided)
|
|
688
|
+
- GET /api/logs → platform deployment logs
|
|
689
|
+
- GET /api/strategy → current strategy draft (code, name, params, risk)
|
|
690
|
+
- GET /api/local-logs → stdout/stderr from local run_strategy processes
|
|
691
|
+
|
|
692
|
+
Your HTML can fetch("/api/metrics").then(r => r.json()) to get live data. Auto-refresh with setInterval.`,
|
|
693
|
+
parameters: z.object({
|
|
694
|
+
strategy_id: z.string().optional().describe("Strategy UUID — enables /api/metrics and /api/logs from the platform"),
|
|
695
|
+
custom_html: z.string().optional().describe("Complete HTML page you write from scratch (dark theme, Chart.js, etc.)"),
|
|
696
|
+
port: z.number().optional().describe("Port (default: random)"),
|
|
697
|
+
}),
|
|
698
|
+
execute: async (args: any) => {
|
|
699
|
+
try {
|
|
700
|
+
if (args.strategy_id && !args.custom_html) {
|
|
701
|
+
const url = dashboard.start(args.strategy_id, args.port);
|
|
702
|
+
return { success: true, url, message: `Monitor at ${hyperlink(url)}` };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Custom HTML with live API backend
|
|
706
|
+
const html = args.custom_html ?? "";
|
|
707
|
+
const strategyId = args.strategy_id;
|
|
708
|
+
|
|
709
|
+
const server = Bun.serve({
|
|
710
|
+
port: args.port || 0,
|
|
711
|
+
hostname: "127.0.0.1", // localhost only — not accessible from network
|
|
712
|
+
fetch: async (req) => {
|
|
713
|
+
const url = new URL(req.url);
|
|
714
|
+
|
|
715
|
+
// Live API: platform metrics
|
|
716
|
+
if (url.pathname === "/api/metrics" && strategyId) {
|
|
717
|
+
try { return Response.json(await platform.getMetrics(strategyId, 20)); }
|
|
718
|
+
catch (e: any) { return Response.json({ error: e.message }, { status: 500 }); }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Live API: platform logs
|
|
722
|
+
if (url.pathname === "/api/logs" && strategyId) {
|
|
723
|
+
try { return Response.json(await platform.getLogs(strategyId, 50)); }
|
|
724
|
+
catch (e: any) { return Response.json({ error: e.message }, { status: 500 }); }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Live API: current strategy draft
|
|
728
|
+
if (url.pathname === "/api/strategy") {
|
|
729
|
+
const draft = store.getActiveSession()?.strategyDraft;
|
|
730
|
+
return Response.json(draft ? { name: draft.name, code: draft.code, params: draft.params, riskConfig: draft.riskConfig, validationStatus: draft.validationStatus } : { error: "No strategy loaded" });
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Live API: local process logs
|
|
734
|
+
if (url.pathname === "/api/local-logs") {
|
|
735
|
+
const allLogs: Record<string, { stdout: string[]; stderr: string[]; alive: boolean }> = {};
|
|
736
|
+
for (const [pid, managed] of runningProcesses) {
|
|
737
|
+
allLogs[pid] = { stdout: managed.stdout.slice(-50), stderr: managed.stderr.slice(-20), alive: managed.proc.exitCode === null };
|
|
738
|
+
}
|
|
739
|
+
return Response.json(allLogs);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// CORS preflight
|
|
743
|
+
if (req.method === "OPTIONS") {
|
|
744
|
+
return new Response(null, { headers: { "Access-Control-Allow-Origin": "http://127.0.0.1", "Access-Control-Allow-Methods": "GET", "Access-Control-Allow-Headers": "Content-Type" } });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Serve the HTML
|
|
748
|
+
return new Response(html, { headers: { "Content-Type": "text/html", "Access-Control-Allow-Origin": "http://127.0.0.1" } });
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
const dashUrl = `http://localhost:${server.port}`;
|
|
753
|
+
customServers.push(server);
|
|
754
|
+
|
|
755
|
+
const apis = ["/api/strategy", "/api/local-logs"];
|
|
756
|
+
if (strategyId) apis.push("/api/metrics", "/api/logs");
|
|
757
|
+
|
|
758
|
+
return { success: true, url: dashUrl, available_apis: apis, message: `Dashboard at ${hyperlink(dashUrl)}` };
|
|
759
|
+
} catch (err) {
|
|
760
|
+
return { error: `Dashboard failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
}),
|
|
764
|
+
};
|