horizon-code 0.6.0 → 0.6.2

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.
@@ -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
  }
@@ -6,12 +6,29 @@ const KALSHI = "https://api.elections.kalshi.com/trade-api/v2";
6
6
  const DATA_API = "https://data-api.polymarket.com";
7
7
  // EXA and CALA now proxied through API server — keys held server-side
8
8
 
9
- async function get(url: string): Promise<any> {
10
- const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
9
+ async function get(url: string, timeoutMs = 10000): Promise<any> {
10
+ const res = await fetch(url, {
11
+ signal: AbortSignal.timeout(timeoutMs),
12
+ headers: { "Accept": "application/json", "User-Agent": "HorizonCode/0.6" },
13
+ });
11
14
  if (!res.ok) throw new Error(`${res.status} ${url}`);
12
15
  return res.json();
13
16
  }
14
17
 
18
+ /** GET with retry — for flaky APIs like Kalshi */
19
+ async function getWithRetry(url: string, retries = 2, timeoutMs = 15000): Promise<any> {
20
+ let lastErr: Error | null = null;
21
+ for (let i = 0; i <= retries; i++) {
22
+ try {
23
+ return await get(url, timeoutMs);
24
+ } catch (e: any) {
25
+ lastErr = e;
26
+ if (i < retries) await new Promise((r) => setTimeout(r, 500 * (i + 1)));
27
+ }
28
+ }
29
+ throw lastErr;
30
+ }
31
+
15
32
  async function post(url: string, body: any, headers: Record<string, string> = {}): Promise<any> {
16
33
  const res = await fetch(url, {
17
34
  method: "POST",
@@ -32,6 +49,28 @@ async function post(url: string, body: any, headers: Record<string, string> = {}
32
49
  let _eventsCache: { data: any[]; ts: number } = { data: [], ts: 0 };
33
50
  const CACHE_TTL = 60_000; // 60 seconds
34
51
 
52
+ /** Strip Gamma event to only the fields we use — reduces 16MB → ~200KB in memory */
53
+ function slimEvent(e: any): any {
54
+ return {
55
+ title: e.title, slug: e.slug, id: e.id,
56
+ description: (e.description ?? "").slice(0, 500),
57
+ volume: e.volume, liquidity: e.liquidity,
58
+ volume24hr: e.volume24hr, openInterest: e.openInterest,
59
+ startDate: e.startDate, endDate: e.endDate,
60
+ oneDayPriceChange: e.oneDayPriceChange,
61
+ tags: e.tags,
62
+ markets: (e.markets ?? []).map((m: any) => ({
63
+ question: m.question, conditionId: m.conditionId,
64
+ outcomePrices: m.outcomePrices,
65
+ volume: m.volume, liquidity: m.liquidity,
66
+ bestBid: m.bestBid, bestAsk: m.bestAsk,
67
+ lastTradePrice: m.lastTradePrice,
68
+ oneDayPriceChange: m.oneDayPriceChange,
69
+ clobTokenIds: m.clobTokenIds,
70
+ })),
71
+ };
72
+ }
73
+
35
74
  async function fetchAllEvents(): Promise<any[]> {
36
75
  if (_eventsCache.data.length > 0 && Date.now() - _eventsCache.ts < CACHE_TTL) {
37
76
  return _eventsCache.data;
@@ -39,11 +78,14 @@ async function fetchAllEvents(): Promise<any[]> {
39
78
  const params = new URLSearchParams({
40
79
  active: "true", closed: "false",
41
80
  order: "volume24hr", ascending: "false",
42
- limit: "200",
81
+ limit: "100",
43
82
  });
44
- const data = await get(`${GAMMA}/events?${params}`).catch(() => []);
45
- _eventsCache = { data: data ?? [], ts: Date.now() };
46
- return _eventsCache.data;
83
+ const raw = await get(`${GAMMA}/events?${params}`).catch(() => []);
84
+ const data = (raw ?? []).map(slimEvent);
85
+ if (data.length > 0) {
86
+ _eventsCache = { data, ts: Date.now() };
87
+ }
88
+ return data;
47
89
  }
48
90
 
49
91
  export async function gammaEvents(opts: { query?: string; limit?: number } = {}): Promise<any[]> {
@@ -111,7 +153,7 @@ export async function gammaEvents(opts: { query?: string; limit?: number } = {})
111
153
  }
112
154
 
113
155
  export async function gammaEventDetail(slug: string): Promise<any> {
114
- const events = await get(`${GAMMA}/events?slug=${slug}`);
156
+ const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
115
157
  const event = events?.[0];
116
158
  if (!event) throw new Error(`Event not found: ${slug}`);
117
159
 
@@ -149,7 +191,7 @@ export async function gammaEventDetail(slug: string): Promise<any> {
149
191
  }
150
192
 
151
193
  export async function clobPriceHistory(slug: string, interval = "1w", fidelity = 60): Promise<any> {
152
- const events = await get(`${GAMMA}/events?slug=${slug}`);
194
+ const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
153
195
  const market = events?.[0]?.markets?.[0];
154
196
  if (!market) throw new Error(`Market not found: ${slug}`);
155
197
 
@@ -174,7 +216,7 @@ export async function clobPriceHistory(slug: string, interval = "1w", fidelity =
174
216
  }
175
217
 
176
218
  export async function clobOrderBook(slug: string, marketIndex = 0, outcomeIndex = 0): Promise<any> {
177
- const events = await get(`${GAMMA}/events?slug=${slug}`);
219
+ const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
178
220
  const market = events?.[0]?.markets?.[marketIndex];
179
221
  if (!market) throw new Error(`Market not found: ${slug}`);
180
222
 
@@ -198,7 +240,7 @@ export async function clobOrderBook(slug: string, marketIndex = 0, outcomeIndex
198
240
  }
199
241
 
200
242
  export async function polymarketTrades(slug: string): Promise<any> {
201
- const events = await get(`${GAMMA}/events?slug=${slug}`);
243
+ const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
202
244
  const market = events?.[0]?.markets?.[0];
203
245
  if (!market) throw new Error(`Market not found: ${slug}`);
204
246
 
@@ -233,27 +275,27 @@ export async function polymarketTrades(slug: string): Promise<any> {
233
275
 
234
276
  /** Get recent trades for a market (up to 500) */
235
277
  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(() => []);
278
+ const data = await get(`${DATA_API}/trades?market=${encodeURIComponent(conditionId)}&limit=${Math.min(limit, 500)}`).catch(() => []);
237
279
  return data ?? [];
238
280
  }
239
281
 
240
282
  /** Get trades for a specific wallet (up to 500) */
241
283
  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}`;
284
+ let url = `${DATA_API}/trades?maker_address=${encodeURIComponent(address)}&limit=${Math.min(limit, 500)}`;
285
+ if (conditionId) url += `&market=${encodeURIComponent(conditionId)}`;
244
286
  const data = await get(url).catch(() => []);
245
287
  return data ?? [];
246
288
  }
247
289
 
248
290
  /** Get open positions for a wallet */
249
291
  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(() => []);
292
+ const data = await get(`${DATA_API}/positions?user=${encodeURIComponent(address)}&limit=${Math.min(limit, 500)}&sort_by=TOKENS`).catch(() => []);
251
293
  return data ?? [];
252
294
  }
253
295
 
254
296
  /** Get wallet profile from Gamma API */
255
297
  export async function getWalletProfile(address: string): Promise<any> {
256
- const data = await get(`${GAMMA}/users?address=${address}`).catch(() => null);
298
+ const data = await get(`${GAMMA}/users?address=${encodeURIComponent(address)}`).catch(() => null);
257
299
  const user = Array.isArray(data) ? data[0] : data;
258
300
  return user ? {
259
301
  address, name: user.pseudonym ?? user.name ?? "Anonymous",
@@ -264,7 +306,7 @@ export async function getWalletProfile(address: string): Promise<any> {
264
306
 
265
307
  /** Resolve market slug → conditionId */
266
308
  export async function resolveConditionId(slug: string): Promise<{ conditionId: string; title: string; market: any }> {
267
- const events = await get(`${GAMMA}/events?slug=${slug}`);
309
+ const events = await get(`${GAMMA}/events?slug=${encodeURIComponent(slug)}`);
268
310
  const market = events?.[0]?.markets?.[0];
269
311
  if (!market) throw new Error(`Market not found: ${slug}`);
270
312
  return { conditionId: market.conditionId, title: events[0].title, market };
@@ -312,8 +354,8 @@ function parseKalshiMarket(m: any): any {
312
354
  let _kalshiCache: { data: any[]; ts: number } = { data: [], ts: 0 };
313
355
  const KALSHI_CACHE_TTL = 60_000;
314
356
 
315
- /** Fetch events from Kalshi — uses cursor pagination to get more results */
316
- async function fetchKalshiEvents(limit = 200): Promise<any[]> {
357
+ /** Fetch events from Kalshi — single page to avoid slow pagination */
358
+ async function fetchKalshiEvents(limit = 100): Promise<any[]> {
317
359
  if (_kalshiCache.data.length > 0 && Date.now() - _kalshiCache.ts < KALSHI_CACHE_TTL) {
318
360
  return _kalshiCache.data;
319
361
  }
@@ -330,7 +372,7 @@ async function fetchKalshiEvents(limit = 200): Promise<any[]> {
330
372
  });
331
373
  if (cursor) params.set("cursor", cursor);
332
374
 
333
- const data = await get(`${KALSHI}/events?${params}`).catch(() => ({ events: [], cursor: "" }));
375
+ const data = await getWithRetry(`${KALSHI}/events?${params}`).catch(() => ({ events: [], cursor: "" }));
334
376
  const events = data?.events ?? [];
335
377
  if (events.length === 0) break;
336
378
 
@@ -339,7 +381,10 @@ async function fetchKalshiEvents(limit = 200): Promise<any[]> {
339
381
  if (!cursor) break;
340
382
  }
341
383
 
342
- _kalshiCache = { data: all, ts: Date.now() };
384
+ // Only cache successful non-empty results don't pollute cache with failures
385
+ if (all.length > 0) {
386
+ _kalshiCache = { data: all, ts: Date.now() };
387
+ }
343
388
  return all;
344
389
  }
345
390
 
@@ -383,7 +428,7 @@ export async function kalshiEvents(opts: { query?: string; limit?: number } = {}
383
428
  status: "open", with_nested_markets: "true",
384
429
  series_ticker: seriesTicker, limit: String(limit),
385
430
  });
386
- const data = await get(`${KALSHI}/events?${params}`);
431
+ const data = await getWithRetry(`${KALSHI}/events?${params}`);
387
432
  const events = data?.events ?? [];
388
433
  if (events.length > 0) return events.slice(0, limit).map(formatKalshiEvent);
389
434
  } catch {}
@@ -413,7 +458,7 @@ export async function kalshiEvents(opts: { query?: string; limit?: number } = {}
413
458
  status: "open", with_nested_markets: "true",
414
459
  series_ticker: query.toUpperCase(), limit: String(limit),
415
460
  });
416
- const data = await get(`${KALSHI}/events?${params}`);
461
+ const data = await getWithRetry(`${KALSHI}/events?${params}`);
417
462
  events = data?.events ?? [];
418
463
  } catch {}
419
464
  }
@@ -443,7 +488,7 @@ function formatKalshiEvent(e: any): any {
443
488
  }
444
489
 
445
490
  export async function kalshiEventDetail(ticker: string): Promise<any> {
446
- const data = await get(`${KALSHI}/events/${ticker}?with_nested_markets=true`);
491
+ const data = await getWithRetry(`${KALSHI}/events/${encodeURIComponent(ticker)}?with_nested_markets=true`);
447
492
  const event = data?.event ?? data;
448
493
  if (!event) throw new Error(`Kalshi event not found: ${ticker}`);
449
494
 
@@ -457,7 +502,7 @@ export async function kalshiEventDetail(ticker: string): Promise<any> {
457
502
  }
458
503
 
459
504
  export async function kalshiOrderBook(ticker: string): Promise<any> {
460
- const book = await get(`${KALSHI}/markets/${ticker}/orderbook`);
505
+ const book = await getWithRetry(`${KALSHI}/markets/${encodeURIComponent(ticker)}/orderbook`);
461
506
  const ob = book?.orderbook ?? {};
462
507
  // yes array: [[price_cents, size], ...] — bids for YES outcome
463
508
  // no array: [[price_cents, size], ...] — asks (complement pricing)
@@ -489,7 +534,7 @@ export async function kalshiPriceHistory(ticker: string, period = "1w"): Promise
489
534
  const intervalMap: Record<string, number> = { "1h": 1, "6h": 1, "1d": 60, "1w": 60, "1m": 1440, "max": 1440 };
490
535
  const periodInterval = intervalMap[period] ?? 60;
491
536
 
492
- const data = await get(`${KALSHI}/markets/${ticker}/candlesticks?period_interval=${periodInterval}`).catch(() => ({ candlesticks: [] }));
537
+ const data = await getWithRetry(`${KALSHI}/markets/${encodeURIComponent(ticker)}/candlesticks?period_interval=${periodInterval}`).catch(() => ({ candlesticks: [] }));
493
538
  const candles = data?.candlesticks ?? [];
494
539
 
495
540
  // Prices can be dollar strings or cent integers — normalize to decimal
@@ -601,26 +601,28 @@ function renderKalshiList(data: any, renderer: CliRenderer): BoxRenderable {
601
601
  exchangeBadge(box, renderer, "kalshi", "Markets");
602
602
  const events = Array.isArray(data) ? data : (data?.events ?? []);
603
603
 
604
+ // Helper: truncate with ellipsis
605
+ const trunc = (s: string, max: number) => s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
606
+ // Helper: format price as cents
607
+ const fmtCents = (v: any) => v != null ? `${(+v * 100).toFixed(0)}c` : "--";
608
+
604
609
  for (let idx = 0; idx < Math.min(events.length, 8); idx++) {
605
610
  const e = events[idx];
606
- const market = e.markets?.[0];
607
- const yesBid = market?.yesBid ?? market?.lastPrice ?? null;
608
- const yesAsk = market?.yesAsk ?? null;
609
- const price = yesBid != null ? `${yesBid}c` : "--";
611
+ const m = e.markets?.[0];
612
+ const title = trunc(e.title ?? "", 36);
613
+ const bid = fmtCents(m?.yesBid ?? m?.lastPrice);
614
+ const ask = fmtCents(m?.yesAsk);
615
+ const vol = m?.volume ? `${(m.volume / 1000).toFixed(0)}k` : "";
616
+ const cat = trunc(e.category ?? "", 10);
610
617
 
611
- const row = new BoxRenderable(renderer, { id: uid(), flexDirection: "row", width: "100%" });
612
- row.add(new TextRenderable(renderer, { id: uid(), content: `${(idx + 1).toString().padStart(2)} `, fg: COLORS.textMuted }));
613
- row.add(new TextRenderable(renderer, { id: uid(), content: (e.title ?? "").slice(0, 40).padEnd(43), fg: COLORS.text, attributes: 1 }));
614
- row.add(new TextRenderable(renderer, { id: uid(), content: ` Y ${price}`.padEnd(10), fg: COLORS.success }));
615
- if (yesAsk != null) row.add(new TextRenderable(renderer, { id: uid(), content: `A ${yesAsk}c`.padEnd(8), fg: COLORS.error }));
616
- if (market?.volume) row.add(new TextRenderable(renderer, { id: uid(), content: ` vol ${market.volume.toLocaleString()}`, fg: COLORS.textMuted }));
617
- row.add(new BoxRenderable(renderer, { id: uid(), flexGrow: 1 }));
618
- row.add(new TextRenderable(renderer, { id: uid(), content: e.category ?? "", fg: COLORS.textMuted }));
618
+ const row = new BoxRenderable(renderer, { id: uid(), flexDirection: "row" });
619
+ row.add(new TextRenderable(renderer, { id: uid(), content: `${(idx + 1).toString().padStart(2)} `, fg: COLORS.textMuted }));
620
+ row.add(new TextRenderable(renderer, { id: uid(), content: title.padEnd(37), fg: COLORS.text }));
621
+ row.add(new TextRenderable(renderer, { id: uid(), content: bid.padStart(5), fg: COLORS.success }));
622
+ row.add(new TextRenderable(renderer, { id: uid(), content: `/${ask.padEnd(5)}`, fg: COLORS.error }));
623
+ row.add(new TextRenderable(renderer, { id: uid(), content: vol.padStart(6), fg: COLORS.textMuted }));
624
+ row.add(new TextRenderable(renderer, { id: uid(), content: ` ${cat}`, fg: COLORS.borderDim }));
619
625
  box.add(row);
620
-
621
- if (e.ticker) {
622
- box.add(new TextRenderable(renderer, { id: uid(), content: ` ${e.ticker}`, fg: COLORS.borderDim }));
623
- }
624
626
  }
625
627
 
626
628
  return box;
@@ -773,26 +775,28 @@ function renderProbability(data: any, renderer: CliRenderer): BoxRenderable {
773
775
  function renderKalshiDetail(data: any, renderer: CliRenderer): BoxRenderable {
774
776
  const box = new BoxRenderable(renderer, { id: uid(), flexDirection: "column", marginBottom: 1 });
775
777
 
776
- exchangeBadge(box, renderer, "kalshi", data.title ?? data.ticker ?? "");
778
+ const trunc = (s: string, max: number) => s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
779
+ const fmtCents = (v: any) => v != null ? `${(+v * 100).toFixed(0)}c` : "--";
780
+
781
+ exchangeBadge(box, renderer, "kalshi", trunc(data.title ?? data.ticker ?? "", 50));
777
782
  if (data.category) {
778
783
  box.add(new TextRenderable(renderer, { id: uid(), content: ` ${data.category} ${data.ticker ?? ""}`, fg: COLORS.textMuted }));
779
784
  }
780
785
  sep(box, renderer);
781
786
 
782
787
  for (const m of (data.markets ?? []).slice(0, 8)) {
783
- const row = new BoxRenderable(renderer, { id: uid(), flexDirection: "row", width: "100%" });
784
- const title = (m.title ?? m.ticker ?? "").padEnd(40).slice(0, 40);
785
- row.add(new TextRenderable(renderer, { id: uid(), content: title, fg: COLORS.textMuted }));
786
- const bid = m.yesBid ?? m.lastPrice ?? null;
787
- const ask = m.yesAsk ?? null;
788
- row.add(new TextRenderable(renderer, { id: uid(), content: `Bid ${bid != null ? bid + "c" : "--"}`.padEnd(10), fg: COLORS.success }));
789
- row.add(new TextRenderable(renderer, { id: uid(), content: `Ask ${ask != null ? ask + "c" : "--"}`.padEnd(10), fg: COLORS.error }));
790
- if (m.spread != null) {
791
- row.add(new TextRenderable(renderer, { id: uid(), content: `spread ${m.spread}c`.padEnd(14), fg: COLORS.textMuted }));
792
- }
793
- if (m.volume) {
794
- row.add(new TextRenderable(renderer, { id: uid(), content: `vol ${m.volume.toLocaleString()}`, fg: COLORS.textMuted }));
795
- }
788
+ const row = new BoxRenderable(renderer, { id: uid(), flexDirection: "row" });
789
+ const title = trunc(m.title ?? m.ticker ?? "", 32);
790
+ const bid = fmtCents(m.yesBid ?? m.lastPrice);
791
+ const ask = fmtCents(m.yesAsk);
792
+ const spread = m.spread != null ? `${(+m.spread * 100).toFixed(0)}c` : "";
793
+ const vol = m.volume ? `${(m.volume / 1000).toFixed(0)}k` : "";
794
+
795
+ row.add(new TextRenderable(renderer, { id: uid(), content: title.padEnd(33), fg: COLORS.text }));
796
+ row.add(new TextRenderable(renderer, { id: uid(), content: bid.padStart(5), fg: COLORS.success }));
797
+ row.add(new TextRenderable(renderer, { id: uid(), content: `/${ask.padEnd(5)}`, fg: COLORS.error }));
798
+ row.add(new TextRenderable(renderer, { id: uid(), content: spread.padStart(5), fg: COLORS.textMuted }));
799
+ row.add(new TextRenderable(renderer, { id: uid(), content: vol.padStart(6), fg: COLORS.textMuted }));
796
800
  box.add(row);
797
801
  }
798
802