horizon-code 0.5.1 → 0.6.1
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 +13 -6
- package/src/ai/system-prompt.ts +20 -1
- package/src/app.ts +184 -11
- package/src/chat/renderer.ts +106 -24
- package/src/components/footer.ts +1 -1
- package/src/keys/handler.ts +8 -0
- package/src/platform/auth.ts +3 -3
- package/src/platform/profile.ts +202 -0
- package/src/platform/session-sync.ts +3 -3
- package/src/platform/supabase.ts +10 -9
- package/src/platform/sync.ts +9 -1
- package/src/research/apis.ts +13 -13
- package/src/research/scanner.ts +212 -0
- package/src/research/tools.ts +58 -0
- package/src/strategy/alerts.ts +190 -0
- package/src/strategy/export.ts +159 -0
- package/src/strategy/health.ts +127 -0
- package/src/strategy/ledger.ts +185 -0
- package/src/strategy/prompts.ts +136 -551
- package/src/strategy/replay.ts +191 -0
- package/src/strategy/tools.ts +495 -1
- package/src/strategy/versioning.ts +168 -0
package/src/platform/auth.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { saveConfig, loadConfig, getApiKey } from "./config.ts";
|
|
2
2
|
import { platform } from "./client.ts";
|
|
3
3
|
|
|
4
|
-
export type AuthStatus = "authenticated" | "no_key" | "invalid_key";
|
|
4
|
+
export type AuthStatus = "authenticated" | "no_key" | "invalid_key" | "network_error";
|
|
5
5
|
|
|
6
6
|
export async function checkAuth(): Promise<AuthStatus> {
|
|
7
7
|
const key = getApiKey();
|
|
@@ -13,8 +13,8 @@ export async function checkAuth(): Promise<AuthStatus> {
|
|
|
13
13
|
return "authenticated";
|
|
14
14
|
} catch (err: any) {
|
|
15
15
|
if (err?.status === 401 || err?.status === 403) return "invalid_key";
|
|
16
|
-
// Network error
|
|
17
|
-
return "
|
|
16
|
+
// Network error — can't verify, report as network error
|
|
17
|
+
return "network_error";
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Cumulative user profile — learns preferences over time
|
|
2
|
+
// Storage: ~/.horizon/profile.json
|
|
3
|
+
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
7
|
+
|
|
8
|
+
const PROFILE_PATH = resolve(homedir(), ".horizon", "profile.json");
|
|
9
|
+
|
|
10
|
+
export interface UserProfile {
|
|
11
|
+
// Trading preferences
|
|
12
|
+
preferred_markets: string[]; // most-used market slugs
|
|
13
|
+
preferred_exchanges: string[]; // polymarket, kalshi, etc.
|
|
14
|
+
risk_tolerance: "conservative" | "moderate" | "aggressive";
|
|
15
|
+
typical_spread: number; // average spread parameter used
|
|
16
|
+
typical_position_size: number; // average max_position used
|
|
17
|
+
always_paper_first: boolean; // always tests in paper before live
|
|
18
|
+
|
|
19
|
+
// Workflow patterns
|
|
20
|
+
strategies_created: number;
|
|
21
|
+
strategies_deployed: number;
|
|
22
|
+
backtests_run: number;
|
|
23
|
+
total_sessions: number;
|
|
24
|
+
preferred_strategy_types: string[]; // mm, momentum, arb, etc.
|
|
25
|
+
avg_pipeline_depth: number; // average number of pipeline functions
|
|
26
|
+
|
|
27
|
+
// Usage stats
|
|
28
|
+
first_seen: string;
|
|
29
|
+
last_seen: string;
|
|
30
|
+
total_interactions: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_PROFILE: UserProfile = {
|
|
34
|
+
preferred_markets: [],
|
|
35
|
+
preferred_exchanges: [],
|
|
36
|
+
risk_tolerance: "moderate",
|
|
37
|
+
typical_spread: 0.04,
|
|
38
|
+
typical_position_size: 100,
|
|
39
|
+
always_paper_first: true,
|
|
40
|
+
strategies_created: 0,
|
|
41
|
+
strategies_deployed: 0,
|
|
42
|
+
backtests_run: 0,
|
|
43
|
+
total_sessions: 0,
|
|
44
|
+
preferred_strategy_types: [],
|
|
45
|
+
avg_pipeline_depth: 2,
|
|
46
|
+
first_seen: new Date().toISOString(),
|
|
47
|
+
last_seen: new Date().toISOString(),
|
|
48
|
+
total_interactions: 0,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Load profile from disk */
|
|
52
|
+
export function loadProfile(): UserProfile {
|
|
53
|
+
if (!existsSync(PROFILE_PATH)) return { ...DEFAULT_PROFILE };
|
|
54
|
+
try {
|
|
55
|
+
const data = JSON.parse(readFileSync(PROFILE_PATH, "utf-8"));
|
|
56
|
+
return { ...DEFAULT_PROFILE, ...data };
|
|
57
|
+
} catch {
|
|
58
|
+
return { ...DEFAULT_PROFILE };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Save profile to disk */
|
|
63
|
+
export function saveProfile(profile: UserProfile): void {
|
|
64
|
+
profile.last_seen = new Date().toISOString();
|
|
65
|
+
writeFileSync(PROFILE_PATH, JSON.stringify(profile, null, 2));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Update profile based on strategy code analysis */
|
|
69
|
+
export function learnFromStrategy(code: string): void {
|
|
70
|
+
const profile = loadProfile();
|
|
71
|
+
|
|
72
|
+
// Extract market slugs
|
|
73
|
+
const marketsMatch = code.match(/markets\s*=\s*\[([\s\S]*?)\]/);
|
|
74
|
+
if (marketsMatch) {
|
|
75
|
+
const slugs = [...marketsMatch[1]!.matchAll(/["']([^"']+)["']/g)].map(m => m[1]!);
|
|
76
|
+
for (const slug of slugs) {
|
|
77
|
+
if (!profile.preferred_markets.includes(slug)) {
|
|
78
|
+
profile.preferred_markets.push(slug);
|
|
79
|
+
if (profile.preferred_markets.length > 20) profile.preferred_markets.shift();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Extract exchange
|
|
85
|
+
const exchangeMatch = code.match(/exchange\s*=\s*hz\.(\w+)/);
|
|
86
|
+
if (exchangeMatch) {
|
|
87
|
+
const exchange = exchangeMatch[1]!.toLowerCase();
|
|
88
|
+
if (!profile.preferred_exchanges.includes(exchange)) {
|
|
89
|
+
profile.preferred_exchanges.push(exchange);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Extract spread
|
|
94
|
+
const spreadMatch = code.match(/spread\s*[=:]\s*([\d.]+)/);
|
|
95
|
+
if (spreadMatch) {
|
|
96
|
+
const spread = parseFloat(spreadMatch[1]!);
|
|
97
|
+
if (spread > 0 && spread < 1) {
|
|
98
|
+
profile.typical_spread = (profile.typical_spread + spread) / 2;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Extract max_position
|
|
103
|
+
const posMatch = code.match(/max_position\s*=\s*(\d+)/);
|
|
104
|
+
if (posMatch) {
|
|
105
|
+
const pos = parseInt(posMatch[1]!);
|
|
106
|
+
profile.typical_position_size = Math.round((profile.typical_position_size + pos) / 2);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Detect risk tolerance from risk config
|
|
110
|
+
const ddMatch = code.match(/max_drawdown_pct\s*=\s*([\d.]+)/);
|
|
111
|
+
if (ddMatch) {
|
|
112
|
+
const dd = parseFloat(ddMatch[1]!);
|
|
113
|
+
if (dd <= 5) profile.risk_tolerance = "conservative";
|
|
114
|
+
else if (dd <= 15) profile.risk_tolerance = "moderate";
|
|
115
|
+
else profile.risk_tolerance = "aggressive";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Detect strategy type
|
|
119
|
+
const codeLC = code.toLowerCase();
|
|
120
|
+
const types: string[] = [];
|
|
121
|
+
if (/market.?mak|mm_|spread/.test(codeLC)) types.push("market_making");
|
|
122
|
+
if (/momentum|trend|signal/.test(codeLC)) types.push("momentum");
|
|
123
|
+
if (/arb|arbitrage/.test(codeLC)) types.push("arbitrage");
|
|
124
|
+
if (/mean.?rev|revert/.test(codeLC)) types.push("mean_reversion");
|
|
125
|
+
if (/scalp/.test(codeLC)) types.push("scalper");
|
|
126
|
+
|
|
127
|
+
for (const t of types) {
|
|
128
|
+
if (!profile.preferred_strategy_types.includes(t)) {
|
|
129
|
+
profile.preferred_strategy_types.push(t);
|
|
130
|
+
if (profile.preferred_strategy_types.length > 10) profile.preferred_strategy_types.shift();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Pipeline depth
|
|
135
|
+
const pipelineMatch = code.match(/pipeline\s*=\s*\[([\s\S]*?)\]/);
|
|
136
|
+
if (pipelineMatch) {
|
|
137
|
+
const fns = pipelineMatch[1]!.split(",").filter(s => s.trim() && !s.trim().startsWith("#"));
|
|
138
|
+
if (fns.length > 0) {
|
|
139
|
+
profile.avg_pipeline_depth = Math.round((profile.avg_pipeline_depth + fns.length) / 2);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Paper mode preference
|
|
144
|
+
if (/mode\s*=\s*["']paper["']/.test(code)) {
|
|
145
|
+
profile.always_paper_first = true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
profile.strategies_created++;
|
|
149
|
+
profile.total_interactions++;
|
|
150
|
+
|
|
151
|
+
saveProfile(profile);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Record a backtest run */
|
|
155
|
+
export function recordBacktest(): void {
|
|
156
|
+
const profile = loadProfile();
|
|
157
|
+
profile.backtests_run++;
|
|
158
|
+
profile.total_interactions++;
|
|
159
|
+
saveProfile(profile);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Record a deployment */
|
|
163
|
+
export function recordDeploy(): void {
|
|
164
|
+
const profile = loadProfile();
|
|
165
|
+
profile.strategies_deployed++;
|
|
166
|
+
profile.total_interactions++;
|
|
167
|
+
saveProfile(profile);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Record a session start */
|
|
171
|
+
export function recordSession(): void {
|
|
172
|
+
const profile = loadProfile();
|
|
173
|
+
profile.total_sessions++;
|
|
174
|
+
saveProfile(profile);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Build a context string for the LLM system prompt */
|
|
178
|
+
export function buildProfileContext(): string {
|
|
179
|
+
const p = loadProfile();
|
|
180
|
+
if (p.total_interactions === 0) return "";
|
|
181
|
+
|
|
182
|
+
const parts: string[] = [];
|
|
183
|
+
parts.push("## User Profile (learned from usage)");
|
|
184
|
+
|
|
185
|
+
if (p.preferred_markets.length > 0) {
|
|
186
|
+
parts.push(`- Frequently traded markets: ${p.preferred_markets.slice(-5).join(", ")}`);
|
|
187
|
+
}
|
|
188
|
+
if (p.preferred_exchanges.length > 0) {
|
|
189
|
+
parts.push(`- Preferred exchanges: ${p.preferred_exchanges.join(", ")}`);
|
|
190
|
+
}
|
|
191
|
+
if (p.preferred_strategy_types.length > 0) {
|
|
192
|
+
parts.push(`- Strategy style: ${p.preferred_strategy_types.join(", ")}`);
|
|
193
|
+
}
|
|
194
|
+
parts.push(`- Risk tolerance: ${p.risk_tolerance} (typical spread: ${p.typical_spread.toFixed(3)}, typical max_position: ${p.typical_position_size})`);
|
|
195
|
+
parts.push(`- Experience: ${p.strategies_created} strategies created, ${p.backtests_run} backtests, ${p.strategies_deployed} deployments`);
|
|
196
|
+
|
|
197
|
+
if (p.always_paper_first) {
|
|
198
|
+
parts.push("- Always uses paper mode first (respect this preference)");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return parts.join("\n");
|
|
202
|
+
}
|
|
@@ -84,7 +84,7 @@ export async function loadSessions(): Promise<void> {
|
|
|
84
84
|
openTabIds: openIds,
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
|
-
} catch {}
|
|
87
|
+
} catch (e: any) { console.error("[session-sync] loadSessions failed:", e?.message ?? e); }
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
@@ -132,7 +132,7 @@ export async function saveActiveSession(): Promise<void> {
|
|
|
132
132
|
|
|
133
133
|
// Update session metadata
|
|
134
134
|
await updateDbSession(dbId, { name: session.name }).catch(() => {});
|
|
135
|
-
} catch {}
|
|
135
|
+
} catch (e: any) { console.error("[session-sync] saveActiveSession failed:", e?.message ?? e); }
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/**
|
|
@@ -140,7 +140,7 @@ export async function saveActiveSession(): Promise<void> {
|
|
|
140
140
|
*/
|
|
141
141
|
export async function deleteSessionFromDb(sessionId: string): Promise<void> {
|
|
142
142
|
if (!(await isLoggedIn())) return;
|
|
143
|
-
try { await deleteDbSession(sessionId); } catch {}
|
|
143
|
+
try { await deleteDbSession(sessionId); } catch (e: any) { console.error("[session-sync] deleteSessionFromDb failed:", e?.message ?? e); }
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/**
|
package/src/platform/supabase.ts
CHANGED
|
@@ -34,7 +34,8 @@ export function getSupabase(): SupabaseClient {
|
|
|
34
34
|
refresh_token: encryptEnvVar(session.refresh_token),
|
|
35
35
|
};
|
|
36
36
|
config.session_encrypted = true;
|
|
37
|
-
} catch {
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.error("[supabase] encryption failed, using plaintext:", e);
|
|
38
39
|
// Fallback to plaintext if encryption fails
|
|
39
40
|
config.supabase_session = {
|
|
40
41
|
access_token: session.access_token,
|
|
@@ -67,7 +68,7 @@ function saveSessionTokens(config: ReturnType<typeof loadConfig>, accessToken: s
|
|
|
67
68
|
config.supabase_session = { access_token: encryptEnvVar(accessToken), refresh_token: encryptEnvVar(refreshToken) };
|
|
68
69
|
config.session_encrypted = true;
|
|
69
70
|
return;
|
|
70
|
-
} catch {}
|
|
71
|
+
} catch (e) { console.error("[supabase] saveSessionTokens encryption failed:", e); }
|
|
71
72
|
}
|
|
72
73
|
config.supabase_session = { access_token: accessToken, refresh_token: refreshToken };
|
|
73
74
|
config.session_encrypted = false;
|
|
@@ -125,7 +126,7 @@ export async function restoreSession(): Promise<boolean> {
|
|
|
125
126
|
if (config.api_key) platform.setApiKey(config.api_key);
|
|
126
127
|
return true;
|
|
127
128
|
}
|
|
128
|
-
} catch {}
|
|
129
|
+
} catch (e) { console.error("[supabase] setSession failed:", e); }
|
|
129
130
|
|
|
130
131
|
// Access token expired — try refreshing with just the refresh token
|
|
131
132
|
try {
|
|
@@ -142,7 +143,7 @@ export async function restoreSession(): Promise<boolean> {
|
|
|
142
143
|
if (config.api_key) platform.setApiKey(config.api_key);
|
|
143
144
|
return true;
|
|
144
145
|
}
|
|
145
|
-
} catch {}
|
|
146
|
+
} catch (e) { console.error("[supabase] refreshSession failed:", e); }
|
|
146
147
|
|
|
147
148
|
// Both failed — don't delete the session, it might work next time
|
|
148
149
|
// (network issue, Supabase outage, etc.). The API key still works for chat.
|
|
@@ -224,7 +225,7 @@ export async function loginWithBrowser(): Promise<{ success: boolean; error?: st
|
|
|
224
225
|
try {
|
|
225
226
|
const apiKey = await createApiKey();
|
|
226
227
|
if (apiKey) { config.api_key = apiKey; platform.setApiKey(apiKey); }
|
|
227
|
-
} catch {}
|
|
228
|
+
} catch (e) { console.error("[supabase] createApiKey failed:", e); }
|
|
228
229
|
} else {
|
|
229
230
|
platform.setApiKey(config.api_key);
|
|
230
231
|
}
|
|
@@ -235,10 +236,10 @@ export async function loginWithBrowser(): Promise<{ success: boolean; error?: st
|
|
|
235
236
|
}
|
|
236
237
|
if (res.status === 410) return { success: false, error: "Session expired. Type /login to sign in again." };
|
|
237
238
|
if (res.status === 404) return { success: false, error: "Auth session not found." };
|
|
238
|
-
} catch {}
|
|
239
|
+
} catch (e) { console.error("[supabase] poll timeout:", e); }
|
|
239
240
|
}
|
|
240
241
|
|
|
241
|
-
try { await sb.from("cli_auth_sessions").delete().eq("id", sessionId); } catch {}
|
|
242
|
+
try { await sb.from("cli_auth_sessions").delete().eq("id", sessionId); } catch (e) { console.error("[supabase] cleanup auth session failed:", e); }
|
|
242
243
|
return { success: false, error: "Login timed out (90s). Try /login again." };
|
|
243
244
|
}
|
|
244
245
|
|
|
@@ -260,7 +261,7 @@ export async function loginWithPassword(email: string, password: string): Promis
|
|
|
260
261
|
try {
|
|
261
262
|
const apiKey = await createApiKey();
|
|
262
263
|
if (apiKey) { config.api_key = apiKey; platform.setApiKey(apiKey); }
|
|
263
|
-
} catch {}
|
|
264
|
+
} catch (e) { console.error("[supabase] createApiKey failed:", e); }
|
|
264
265
|
} else {
|
|
265
266
|
platform.setApiKey(config.api_key);
|
|
266
267
|
}
|
|
@@ -295,7 +296,7 @@ async function createApiKey(): Promise<string | null> {
|
|
|
295
296
|
});
|
|
296
297
|
if (error) return null;
|
|
297
298
|
return rawKey;
|
|
298
|
-
} catch { return null; }
|
|
299
|
+
} catch (e) { console.error("[supabase] createApiKey failed:", e); return null; }
|
|
299
300
|
}
|
|
300
301
|
|
|
301
302
|
// ── Logout ──
|
package/src/platform/sync.ts
CHANGED
|
@@ -8,8 +8,11 @@ const pnlHistories: Map<string, number[]> = new Map();
|
|
|
8
8
|
|
|
9
9
|
export class PlatformSync {
|
|
10
10
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
11
|
+
private _polling = false;
|
|
11
12
|
|
|
12
13
|
async start(intervalMs = 30000): Promise<void> {
|
|
14
|
+
if (this.timer) return; // Prevent duplicate timers
|
|
15
|
+
|
|
13
16
|
const loggedIn = await isLoggedIn();
|
|
14
17
|
if (!loggedIn && !platform.authenticated) return;
|
|
15
18
|
|
|
@@ -22,6 +25,8 @@ export class PlatformSync {
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
private async poll(): Promise<void> {
|
|
28
|
+
if (this._polling) return;
|
|
29
|
+
this._polling = true;
|
|
25
30
|
try {
|
|
26
31
|
const strategies = await platform.listStrategies();
|
|
27
32
|
|
|
@@ -88,8 +93,11 @@ export class PlatformSync {
|
|
|
88
93
|
});
|
|
89
94
|
|
|
90
95
|
store.update({ deployments, connection: "connected" });
|
|
91
|
-
} catch {
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.error("[sync] poll failed:", e);
|
|
92
98
|
store.update({ connection: "disconnected" });
|
|
99
|
+
} finally {
|
|
100
|
+
this._polling = false;
|
|
93
101
|
}
|
|
94
102
|
}
|
|
95
103
|
}
|
package/src/research/apis.ts
CHANGED
|
@@ -111,7 +111,7 @@ export async function gammaEvents(opts: { query?: string; limit?: number } = {})
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
export async function gammaEventDetail(slug: string): Promise<any> {
|
|
114
|
-
const events = await get(`${GAMMA}/events?slug=${slug}`);
|
|
114
|
+
const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
|
|
115
115
|
const event = events?.[0];
|
|
116
116
|
if (!event) throw new Error(`Event not found: ${slug}`);
|
|
117
117
|
|
|
@@ -149,7 +149,7 @@ export async function gammaEventDetail(slug: string): Promise<any> {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
export async function clobPriceHistory(slug: string, interval = "1w", fidelity = 60): Promise<any> {
|
|
152
|
-
const events = await get(`${GAMMA}/events?slug=${slug}`);
|
|
152
|
+
const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
|
|
153
153
|
const market = events?.[0]?.markets?.[0];
|
|
154
154
|
if (!market) throw new Error(`Market not found: ${slug}`);
|
|
155
155
|
|
|
@@ -174,7 +174,7 @@ export async function clobPriceHistory(slug: string, interval = "1w", fidelity =
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
export async function clobOrderBook(slug: string, marketIndex = 0, outcomeIndex = 0): Promise<any> {
|
|
177
|
-
const events = await get(`${GAMMA}/events?slug=${slug}`);
|
|
177
|
+
const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
|
|
178
178
|
const market = events?.[0]?.markets?.[marketIndex];
|
|
179
179
|
if (!market) throw new Error(`Market not found: ${slug}`);
|
|
180
180
|
|
|
@@ -198,7 +198,7 @@ export async function clobOrderBook(slug: string, marketIndex = 0, outcomeIndex
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
export async function polymarketTrades(slug: string): Promise<any> {
|
|
201
|
-
const events = await get(`${GAMMA}/events?slug=${slug}`);
|
|
201
|
+
const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
|
|
202
202
|
const market = events?.[0]?.markets?.[0];
|
|
203
203
|
if (!market) throw new Error(`Market not found: ${slug}`);
|
|
204
204
|
|
|
@@ -233,27 +233,27 @@ export async function polymarketTrades(slug: string): Promise<any> {
|
|
|
233
233
|
|
|
234
234
|
/** Get recent trades for a market (up to 500) */
|
|
235
235
|
export async function getMarketTrades(conditionId: string, limit = 200): Promise<any[]> {
|
|
236
|
-
const data = await get(`${DATA_API}/trades?market=${conditionId}&limit=${Math.min(limit, 500)}`).catch(() => []);
|
|
236
|
+
const data = await get(`${DATA_API}/trades?market=${encodeURIComponent(conditionId)}&limit=${Math.min(limit, 500)}`).catch(() => []);
|
|
237
237
|
return data ?? [];
|
|
238
238
|
}
|
|
239
239
|
|
|
240
240
|
/** Get trades for a specific wallet (up to 500) */
|
|
241
241
|
export async function getWalletTrades(address: string, limit = 200, conditionId?: string): Promise<any[]> {
|
|
242
|
-
let url = `${DATA_API}/trades?maker_address=${address}&limit=${Math.min(limit, 500)}`;
|
|
243
|
-
if (conditionId) url += `&market=${conditionId}`;
|
|
242
|
+
let url = `${DATA_API}/trades?maker_address=${encodeURIComponent(address)}&limit=${Math.min(limit, 500)}`;
|
|
243
|
+
if (conditionId) url += `&market=${encodeURIComponent(conditionId)}`;
|
|
244
244
|
const data = await get(url).catch(() => []);
|
|
245
245
|
return data ?? [];
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
/** Get open positions for a wallet */
|
|
249
249
|
export async function getWalletPositions(address: string, limit = 100): Promise<any[]> {
|
|
250
|
-
const data = await get(`${DATA_API}/positions?user=${address}&limit=${Math.min(limit, 500)}&sort_by=TOKENS`).catch(() => []);
|
|
250
|
+
const data = await get(`${DATA_API}/positions?user=${encodeURIComponent(address)}&limit=${Math.min(limit, 500)}&sort_by=TOKENS`).catch(() => []);
|
|
251
251
|
return data ?? [];
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
/** Get wallet profile from Gamma API */
|
|
255
255
|
export async function getWalletProfile(address: string): Promise<any> {
|
|
256
|
-
const data = await get(`${GAMMA}/users?address=${address}`).catch(() => null);
|
|
256
|
+
const data = await get(`${GAMMA}/users?address=${encodeURIComponent(address)}`).catch(() => null);
|
|
257
257
|
const user = Array.isArray(data) ? data[0] : data;
|
|
258
258
|
return user ? {
|
|
259
259
|
address, name: user.pseudonym ?? user.name ?? "Anonymous",
|
|
@@ -264,7 +264,7 @@ export async function getWalletProfile(address: string): Promise<any> {
|
|
|
264
264
|
|
|
265
265
|
/** Resolve market slug → conditionId */
|
|
266
266
|
export async function resolveConditionId(slug: string): Promise<{ conditionId: string; title: string; market: any }> {
|
|
267
|
-
const events = await get(`${GAMMA}/events?slug=${slug}`);
|
|
267
|
+
const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
|
|
268
268
|
const market = events?.[0]?.markets?.[0];
|
|
269
269
|
if (!market) throw new Error(`Market not found: ${slug}`);
|
|
270
270
|
return { conditionId: market.conditionId, title: events[0].title, market };
|
|
@@ -443,7 +443,7 @@ function formatKalshiEvent(e: any): any {
|
|
|
443
443
|
}
|
|
444
444
|
|
|
445
445
|
export async function kalshiEventDetail(ticker: string): Promise<any> {
|
|
446
|
-
const data = await get(`${KALSHI}/events/${ticker}?with_nested_markets=true`);
|
|
446
|
+
const data = await get(`${KALSHI}/events/${encodeURIComponent(ticker)}?with_nested_markets=true`);
|
|
447
447
|
const event = data?.event ?? data;
|
|
448
448
|
if (!event) throw new Error(`Kalshi event not found: ${ticker}`);
|
|
449
449
|
|
|
@@ -457,7 +457,7 @@ export async function kalshiEventDetail(ticker: string): Promise<any> {
|
|
|
457
457
|
}
|
|
458
458
|
|
|
459
459
|
export async function kalshiOrderBook(ticker: string): Promise<any> {
|
|
460
|
-
const book = await get(`${KALSHI}/markets/${ticker}/orderbook`);
|
|
460
|
+
const book = await get(`${KALSHI}/markets/${encodeURIComponent(ticker)}/orderbook`);
|
|
461
461
|
const ob = book?.orderbook ?? {};
|
|
462
462
|
// yes array: [[price_cents, size], ...] — bids for YES outcome
|
|
463
463
|
// no array: [[price_cents, size], ...] — asks (complement pricing)
|
|
@@ -489,7 +489,7 @@ export async function kalshiPriceHistory(ticker: string, period = "1w"): Promise
|
|
|
489
489
|
const intervalMap: Record<string, number> = { "1h": 1, "6h": 1, "1d": 60, "1w": 60, "1m": 1440, "max": 1440 };
|
|
490
490
|
const periodInterval = intervalMap[period] ?? 60;
|
|
491
491
|
|
|
492
|
-
const data = await get(`${KALSHI}/markets/${ticker}/candlesticks?period_interval=${periodInterval}`).catch(() => ({ candlesticks: [] }));
|
|
492
|
+
const data = await get(`${KALSHI}/markets/${encodeURIComponent(ticker)}/candlesticks?period_interval=${periodInterval}`).catch(() => ({ candlesticks: [] }));
|
|
493
493
|
const candles = data?.candlesticks ?? [];
|
|
494
494
|
|
|
495
495
|
// Prices can be dollar strings or cent integers — normalize to decimal
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Market scanner + correlation analysis
|
|
2
|
+
// Scans for trading opportunities across Polymarket markets
|
|
3
|
+
|
|
4
|
+
import { gammaEvents } from "./apis.ts";
|
|
5
|
+
|
|
6
|
+
export interface MarketOpportunity {
|
|
7
|
+
slug: string;
|
|
8
|
+
title: string;
|
|
9
|
+
type: "wide_spread" | "high_volume" | "mispricing" | "volatile";
|
|
10
|
+
score: number; // 0-100
|
|
11
|
+
spread?: number;
|
|
12
|
+
volume?: number;
|
|
13
|
+
price?: number;
|
|
14
|
+
detail: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CorrelationResult {
|
|
18
|
+
marketA: string;
|
|
19
|
+
marketB: string;
|
|
20
|
+
correlation: number; // -1 to 1
|
|
21
|
+
direction: string; // "positive", "negative", "neutral"
|
|
22
|
+
strength: string; // "strong", "moderate", "weak"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CorrelationMatrix {
|
|
26
|
+
markets: string[];
|
|
27
|
+
matrix: number[][];
|
|
28
|
+
pairs: CorrelationResult[];
|
|
29
|
+
high_correlation_warning: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Scan top Polymarket markets for trading opportunities.
|
|
34
|
+
* Returns ranked list of opportunities for market making, edge trading, or arbitrage.
|
|
35
|
+
*/
|
|
36
|
+
export async function scanOpportunities(limit: number = 30): Promise<{
|
|
37
|
+
opportunities: MarketOpportunity[];
|
|
38
|
+
total_scanned: number;
|
|
39
|
+
summary: string;
|
|
40
|
+
}> {
|
|
41
|
+
// Fetch active events
|
|
42
|
+
const events = await gammaEvents({ query: "", limit: limit * 2 });
|
|
43
|
+
if (!events || events.length === 0) {
|
|
44
|
+
return { opportunities: [], total_scanned: 0, summary: "No markets available." };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const opportunities: MarketOpportunity[] = [];
|
|
48
|
+
|
|
49
|
+
for (const event of events) {
|
|
50
|
+
const markets = event.markets ?? [event];
|
|
51
|
+
|
|
52
|
+
for (const market of markets) {
|
|
53
|
+
const slug = market.slug ?? market.conditionId ?? "";
|
|
54
|
+
const title = market.question ?? market.title ?? slug;
|
|
55
|
+
const spread = market.spread ?? (market.bestAsk && market.bestBid ? market.bestAsk - market.bestBid : null);
|
|
56
|
+
const volume = market.volume ?? market.volumeNum ?? 0;
|
|
57
|
+
const price = market.lastTradePrice ?? market.outcomePrices?.[0] ?? 0.5;
|
|
58
|
+
const priceNum = typeof price === "string" ? parseFloat(price) : price;
|
|
59
|
+
|
|
60
|
+
// Wide spread opportunity (market making)
|
|
61
|
+
if (spread && spread > 0.04) {
|
|
62
|
+
const score = Math.min(100, Math.round(spread * 500));
|
|
63
|
+
opportunities.push({
|
|
64
|
+
slug, title, type: "wide_spread", score, spread, volume: volume,
|
|
65
|
+
detail: `Spread ${(spread * 100).toFixed(1)}c — room for market making`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// High volume but price near 50% (uncertain market = good for MM)
|
|
70
|
+
if (volume > 50000 && priceNum > 0.3 && priceNum < 0.7) {
|
|
71
|
+
const uncertainty = 1 - Math.abs(priceNum - 0.5) * 2;
|
|
72
|
+
const score = Math.min(100, Math.round(uncertainty * 60 + (volume / 100000) * 40));
|
|
73
|
+
opportunities.push({
|
|
74
|
+
slug, title, type: "high_volume", score, volume, price: priceNum,
|
|
75
|
+
detail: `$${(volume / 1000).toFixed(0)}K vol, price ${(priceNum * 100).toFixed(0)}c — high uncertainty`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Extreme prices that might indicate mispricing
|
|
80
|
+
if (priceNum > 0.02 && priceNum < 0.08) {
|
|
81
|
+
opportunities.push({
|
|
82
|
+
slug, title, type: "mispricing", score: 65, price: priceNum,
|
|
83
|
+
detail: `Low price ${(priceNum * 100).toFixed(1)}c — potential long tail value`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (priceNum > 0.92 && priceNum < 0.98) {
|
|
87
|
+
opportunities.push({
|
|
88
|
+
slug, title, type: "mispricing", score: 55, price: priceNum,
|
|
89
|
+
detail: `High price ${(priceNum * 100).toFixed(1)}c — potential short opportunity`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Sort by score descending and deduplicate by slug
|
|
96
|
+
const seen = new Set<string>();
|
|
97
|
+
const unique = opportunities
|
|
98
|
+
.sort((a, b) => b.score - a.score)
|
|
99
|
+
.filter(o => {
|
|
100
|
+
if (seen.has(o.slug)) return false;
|
|
101
|
+
seen.add(o.slug);
|
|
102
|
+
return true;
|
|
103
|
+
})
|
|
104
|
+
.slice(0, limit);
|
|
105
|
+
|
|
106
|
+
const byType = {
|
|
107
|
+
wide_spread: unique.filter(o => o.type === "wide_spread").length,
|
|
108
|
+
high_volume: unique.filter(o => o.type === "high_volume").length,
|
|
109
|
+
mispricing: unique.filter(o => o.type === "mispricing").length,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const summary = `Scanned ${events.length} markets. Found ${unique.length} opportunities: ` +
|
|
113
|
+
`${byType.wide_spread} wide spreads, ${byType.high_volume} high volume, ${byType.mispricing} mispricings.`;
|
|
114
|
+
|
|
115
|
+
return { opportunities: unique, total_scanned: events.length, summary };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Compute pairwise correlations between a set of markets.
|
|
120
|
+
* Uses simple price-based correlation from available data.
|
|
121
|
+
*/
|
|
122
|
+
export function computeCorrelations(
|
|
123
|
+
marketPrices: { slug: string; prices: number[] }[],
|
|
124
|
+
): CorrelationMatrix {
|
|
125
|
+
const n = marketPrices.length;
|
|
126
|
+
const matrix: number[][] = Array.from({ length: n }, () => Array(n).fill(0));
|
|
127
|
+
const pairs: CorrelationResult[] = [];
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < n; i++) {
|
|
130
|
+
matrix[i]![i] = 1.0;
|
|
131
|
+
for (let j = i + 1; j < n; j++) {
|
|
132
|
+
const corr = pearsonCorrelation(marketPrices[i]!.prices, marketPrices[j]!.prices);
|
|
133
|
+
matrix[i]![j] = corr;
|
|
134
|
+
matrix[j]![i] = corr;
|
|
135
|
+
|
|
136
|
+
const absCorr = Math.abs(corr);
|
|
137
|
+
pairs.push({
|
|
138
|
+
marketA: marketPrices[i]!.slug,
|
|
139
|
+
marketB: marketPrices[j]!.slug,
|
|
140
|
+
correlation: Math.round(corr * 100) / 100,
|
|
141
|
+
direction: corr > 0.1 ? "positive" : corr < -0.1 ? "negative" : "neutral",
|
|
142
|
+
strength: absCorr > 0.7 ? "strong" : absCorr > 0.4 ? "moderate" : "weak",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Find high correlations that indicate concentration risk
|
|
148
|
+
const highCorr = pairs.filter(p => Math.abs(p.correlation) > 0.7);
|
|
149
|
+
const warning = highCorr.length > 0
|
|
150
|
+
? `WARNING: ${highCorr.length} pair(s) with >0.7 correlation — portfolio is concentrated: ${highCorr.map(p => `${p.marketA}/${p.marketB} (${p.correlation})`).join(", ")}`
|
|
151
|
+
: null;
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
markets: marketPrices.map(m => m.slug),
|
|
155
|
+
matrix,
|
|
156
|
+
pairs: pairs.sort((a, b) => Math.abs(b.correlation) - Math.abs(a.correlation)),
|
|
157
|
+
high_correlation_warning: warning,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Pearson correlation between two arrays */
|
|
162
|
+
function pearsonCorrelation(x: number[], y: number[]): number {
|
|
163
|
+
const n = Math.min(x.length, y.length);
|
|
164
|
+
if (n < 3) return 0;
|
|
165
|
+
|
|
166
|
+
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
|
|
167
|
+
for (let i = 0; i < n; i++) {
|
|
168
|
+
sumX += x[i]!;
|
|
169
|
+
sumY += y[i]!;
|
|
170
|
+
sumXY += x[i]! * y[i]!;
|
|
171
|
+
sumX2 += x[i]! * x[i]!;
|
|
172
|
+
sumY2 += y[i]! * y[i]!;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const numerator = n * sumXY - sumX * sumY;
|
|
176
|
+
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
|
|
177
|
+
|
|
178
|
+
if (denominator === 0) return 0;
|
|
179
|
+
return numerator / denominator;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Build ASCII correlation matrix for terminal display */
|
|
183
|
+
export function formatCorrelationMatrix(result: CorrelationMatrix): string {
|
|
184
|
+
const { markets, matrix } = result;
|
|
185
|
+
const n = markets.length;
|
|
186
|
+
const maxSlugLen = Math.min(12, Math.max(...markets.map(m => m.length)));
|
|
187
|
+
|
|
188
|
+
const lines: string[] = [];
|
|
189
|
+
|
|
190
|
+
// Header
|
|
191
|
+
const header = " ".repeat(maxSlugLen + 2) + markets.map(m => m.slice(0, 6).padStart(7)).join("");
|
|
192
|
+
lines.push(header);
|
|
193
|
+
lines.push("-".repeat(header.length));
|
|
194
|
+
|
|
195
|
+
// Rows
|
|
196
|
+
for (let i = 0; i < n; i++) {
|
|
197
|
+
const slug = markets[i]!.slice(0, maxSlugLen).padEnd(maxSlugLen);
|
|
198
|
+
const vals = matrix[i]!.map((v, j) => {
|
|
199
|
+
if (i === j) return " 1.00";
|
|
200
|
+
const str = v.toFixed(2);
|
|
201
|
+
return (v >= 0 ? " " : "") + str;
|
|
202
|
+
}).map(s => s.padStart(7)).join("");
|
|
203
|
+
lines.push(`${slug} ${vals}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (result.high_correlation_warning) {
|
|
207
|
+
lines.push("");
|
|
208
|
+
lines.push(result.high_correlation_warning);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return lines.join("\n");
|
|
212
|
+
}
|