hunch-cup 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # hunch-cup
2
2
 
3
3
  One-line CLI + typed SDK to play the **Hunch Cup** — a risk-free, **paper-trading**
4
- prediction-market tournament. Claim **1,000 $pHUNCH** (paper money, zero real-funds
4
+ prediction-market tournament. Claim **10,000 $pHUNCH** (paper money, zero real-funds
5
5
  risk), trade the live Hunch catalog, run a 24/7 agent, and climb a public leaderboard
6
6
  scored on realized PnL. The top 50 wallets split a real **$5,000**.
7
7
 
@@ -11,7 +11,7 @@ scored on realized PnL. The top 50 wallets split a real **$5,000**.
11
11
  ## CLI
12
12
 
13
13
  ```bash
14
- # 1. Generate a wallet and claim 1,000 $pHUNCH (prints a private key — save it!)
14
+ # 1. Generate a wallet and claim 10,000 $pHUNCH (prints a private key — save it!)
15
15
  npx hunch-cup wallet new
16
16
 
17
17
  export HUNCH_CUP_PRIVATE_KEY=0x... # the key it printed
@@ -30,13 +30,32 @@ npx hunch-cup trade <slug> yes 25
30
30
 
31
31
  # 4. Or let an agent do it — momentum | contrarian | news-reactive
32
32
  npx hunch-cup run momentum 25 # dry-run (simulated)
33
- npx hunch-cup run contrarian 25 --live # actually place trades
33
+ npx hunch-cup run contrarian 25 --live # one round, actually place trades
34
34
 
35
+ # 24×7, built in — no shell loop needed. It reads your balance first, skips
36
+ # markets you already hold, caps each round to a fraction of your bankroll, and
37
+ # stops before it goes broke (the old `while true` loop went insolvent in ~20m).
38
+ npx hunch-cup run momentum 25 --live --loop --interval 300
39
+
40
+ npx hunch-cup positions # balance + open/realized positions (watch for insolvency)
41
+ npx hunch-cup balance # just the $pHUNCH number
35
42
  npx hunch-cup board
36
43
  ```
37
44
 
45
+ ### Run a fleet (one person, many agents)
46
+
47
+ ```bash
48
+ npx hunch-cup fleet new 10 # mint 10 wallets, claim each, save to ~/.hunch-cup/fleet.json
49
+ npx hunch-cup fleet run momentum # run all 10 concurrently, 24×7 (each with a strategy)
50
+ npx hunch-cup fleet board # every wallet's live rank
51
+ ```
52
+
53
+ `fleet new` assigns a rotating strategy (momentum / contrarian / news-reactive) per
54
+ wallet so a demo fleet is visibly diverse. Keys live only in the local file.
55
+
38
56
  Env: `HUNCH_CUP_PRIVATE_KEY`, `HUNCH_CUP_BASE_URL` (default `https://www.playhunch.xyz`),
39
- `HUNCH_CUP_SIZE` (default 25), `HUNCH_CUP_SENTIMENT` (for `news-reactive`).
57
+ `HUNCH_CUP_SIZE` (default 25), `HUNCH_CUP_SENTIMENT` (for `news-reactive`),
58
+ `HUNCH_CUP_FLEET_FILE` (default `~/.hunch-cup/fleet.json`).
40
59
 
41
60
  ## SDK
42
61
 
@@ -45,15 +64,24 @@ import { CupClient, fromPrivateKey } from "hunch-cup";
45
64
 
46
65
  const client = new CupClient({ signer: fromPrivateKey(process.env.HUNCH_CUP_PRIVATE_KEY!) });
47
66
 
48
- await client.createWallet(); // claim 1,000 $pHUNCH (once)
49
- const markets = await client.listMarkets();
67
+ await client.createWallet(); // claim 10,000 $pHUNCH (once)
68
+ const markets = await client.listMarkets(25, 0); // paginated: limit, offset
50
69
  await client.simulateTrade(markets[0].slug, "yes", 25); // dry run, no signature
51
70
  await client.trade({ slug: markets[0].slug, side: "yes", sizePhunch: 25 }); // signed
52
- await client.getPositions();
71
+ await client.getWallet(); // balance + positions (typed)
53
72
  await client.getLeaderboard({ wallet: client.address });
54
73
 
55
- // Built-in strategy templates (pure deciders, you control execution):
74
+ // Built-in strategy templates (pure deciders, you control execution). N-way
75
+ // markets (ladders, THE FLIP) pick a real outcome key, never phantom yes/no.
56
76
  await client.runStrategy({ strategy: "momentum", sizePhunch: 25, live: true });
77
+
78
+ // Bankroll-managed 24×7 loop (survives insolvency + transient errors):
79
+ await client.runLoop({ strategy: "momentum", sizePhunch: 25, intervalMs: 300_000, live: true });
80
+
81
+ // A whole fleet from one process:
82
+ import { fleetNew, fleetRun } from "hunch-cup";
83
+ await fleetNew(10);
84
+ await fleetRun({ defaultStrategy: "momentum", sizePhunch: 25, intervalMs: 300_000 });
57
85
  ```
58
86
 
59
87
  ## How auth works
package/dist/cli.js CHANGED
@@ -2,18 +2,25 @@
2
2
  /**
3
3
  * `npx hunch-cup` — one-line Hunch Cup paper-trading agent (S8, scope §8.2.3).
4
4
  *
5
- * hunch-cup wallet new generate a wallet + claim 1,000 $pHUNCH
6
- * hunch-cup markets list tradable paper markets
7
- * hunch-cup quote <slug> <side> <n> price a paper trade
8
- * hunch-cup trade <slug> <side> <n> place a REAL signed paper trade
9
- * hunch-cup run <strategy> [n] run momentum|contrarian|news-reactive
10
- * hunch-cup board show the leaderboard
5
+ * hunch-cup wallet new generate a FRESH wallet + claim 10,000 $pHUNCH
6
+ * hunch-cup markets [n] [offset] list tradable paper markets
7
+ * hunch-cup quote <slug> <side> <n> price a paper trade
8
+ * hunch-cup trade <slug> <side> <n> place a REAL signed paper trade
9
+ * hunch-cup run <strategy> [n] [--live] [--loop] [--interval <sec>]
10
+ * hunch-cup positions your balance + open/realized positions
11
+ * hunch-cup balance just your $pHUNCH balance
12
+ * hunch-cup board [n] show the leaderboard
13
+ * hunch-cup fleet new <n> mint N wallets + claim each (→ ~/.hunch-cup/fleet.json)
14
+ * hunch-cup fleet run <strategy> [--interval <sec>] run all fleet wallets 24×7
15
+ * hunch-cup fleet board every fleet wallet's rank
11
16
  *
12
17
  * Config via env: HUNCH_CUP_PRIVATE_KEY (0x…), HUNCH_CUP_BASE_URL,
13
18
  * HUNCH_CUP_SIZE (default 25). `run` simulates by default; pass --live to trade.
14
19
  */
15
20
  import { CupClient } from "./client.js";
16
21
  import { fromPrivateKey, newWallet } from "./signer.js";
22
+ import { fleetNew, fleetRun, fleetBoard } from "./fleet.js";
23
+ const STRATEGIES = ["momentum", "contrarian", "news-reactive"];
17
24
  function baseUrl() {
18
25
  return process.env.HUNCH_CUP_BASE_URL;
19
26
  }
@@ -25,6 +32,22 @@ function size(arg) {
25
32
  const n = Number(arg ?? process.env.HUNCH_CUP_SIZE ?? 25);
26
33
  return Number.isFinite(n) && n > 0 ? n : 25;
27
34
  }
35
+ /** Read `--flag value` (or `--flag=value`) from an args array. */
36
+ function flagValue(args, flag) {
37
+ const eq = args.find((a) => a.startsWith(`${flag}=`));
38
+ if (eq)
39
+ return eq.slice(flag.length + 1);
40
+ const i = args.indexOf(flag);
41
+ return i >= 0 ? args[i + 1] : undefined;
42
+ }
43
+ function intervalMs(args) {
44
+ const sec = Number(flagValue(args, "--interval") ?? 300);
45
+ return (Number.isFinite(sec) && sec > 0 ? sec : 300) * 1000;
46
+ }
47
+ /** The first positional (non-flag) arg after index 0. */
48
+ function firstPositional(args) {
49
+ return args.find((a, i) => i > 0 && !a.startsWith("--"));
50
+ }
28
51
  function out(value) {
29
52
  console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
30
53
  }
@@ -34,34 +57,36 @@ async function main(argv) {
34
57
  out([
35
58
  "hunch-cup — paper-trade the Hunch Cup from your terminal.",
36
59
  "",
37
- " wallet new generate a wallet + claim 1,000 $pHUNCH",
38
- " markets list tradable paper markets",
39
- " quote <slug> <side> <n> price a paper trade",
40
- " trade <slug> <side> <n> place a REAL signed paper trade",
41
- " run <strategy> [n] [--live] momentum | contrarian | news-reactive",
42
- " board show the leaderboard",
60
+ " wallet new generate a FRESH wallet + claim 10,000 $pHUNCH",
61
+ " markets [n] [offset] list tradable paper markets",
62
+ " quote <slug> <side> <n> price a paper trade",
63
+ " trade <slug> <side> <n> place a REAL signed paper trade",
64
+ " run <strategy> [n] [--live] [--loop] [--interval <sec>]",
65
+ " positions your balance + open/realized positions",
66
+ " balance just your $pHUNCH balance",
67
+ " board [n] show the leaderboard",
68
+ " fleet new <n> mint N wallets + claim each",
69
+ " fleet run <strategy> [--interval <sec>] run all fleet wallets 24×7",
70
+ " fleet board every fleet wallet's rank",
43
71
  "",
72
+ "Strategies: momentum | contrarian | news-reactive",
44
73
  "Env: HUNCH_CUP_PRIVATE_KEY, HUNCH_CUP_BASE_URL, HUNCH_CUP_SIZE",
45
74
  ].join("\n"));
46
75
  return 0;
47
76
  }
48
77
  if (cmd === "wallet" && args[0] === "new") {
49
- let signer = signerFromEnv();
50
- let privateKey = process.env.HUNCH_CUP_PRIVATE_KEY;
51
- if (!signer) {
52
- const w = newWallet();
53
- signer = w.signer;
54
- privateKey = w.privateKey;
55
- out(`Generated a new wallet. SAVE THIS PRIVATE KEY:\n ${privateKey}`);
56
- out(`export HUNCH_CUP_PRIVATE_KEY=${privateKey}`);
57
- }
78
+ // Always mint a FRESH wallet so `wallet new` xN builds a roster (the old
79
+ // behavior silently re-claimed the env key). Use `fleet new` for many.
80
+ const { privateKey, signer } = newWallet();
81
+ out(`Generated a new wallet. SAVE THIS PRIVATE KEY:\n ${privateKey}`);
82
+ out(`export HUNCH_CUP_PRIVATE_KEY=${privateKey}`);
58
83
  const client = new CupClient({ baseUrl: baseUrl(), signer });
59
84
  out(await client.createWallet());
60
85
  return 0;
61
86
  }
62
87
  if (cmd === "markets") {
63
88
  const client = new CupClient({ baseUrl: baseUrl() });
64
- out(await client.listMarkets(Number(args[0] ?? 25)));
89
+ out(await client.listMarkets(Number(args[0] ?? 25), Number(args[1] ?? 0)));
65
90
  return 0;
66
91
  }
67
92
  if (cmd === "quote") {
@@ -83,23 +108,43 @@ async function main(argv) {
83
108
  out(await client.trade({ slug, side, sizePhunch: size(n) }));
84
109
  return 0;
85
110
  }
111
+ if (cmd === "positions" || cmd === "balance") {
112
+ const signer = signerFromEnv();
113
+ const addr = args[0] ?? signer?.address;
114
+ if (!addr)
115
+ return usage(`${cmd} [address] (or set HUNCH_CUP_PRIVATE_KEY)`);
116
+ const client = new CupClient({ baseUrl: baseUrl() });
117
+ const wallet = await client.getWallet(addr);
118
+ out(cmd === "balance" ? { balancePhunch: wallet.balancePhunch } : wallet);
119
+ return 0;
120
+ }
86
121
  if (cmd === "run") {
87
122
  const strategy = args[0];
88
- if (!strategy || !["momentum", "contrarian", "news-reactive"].includes(strategy)) {
89
- return usage("run <momentum|contrarian|news-reactive> [size] [--live]");
123
+ if (!strategy || !STRATEGIES.includes(strategy)) {
124
+ return usage("run <momentum|contrarian|news-reactive> [size] [--live] [--loop] [--interval <sec>]");
90
125
  }
91
126
  const live = args.includes("--live");
92
- const n = args.find((a, i) => i > 0 && !a.startsWith("--"));
127
+ const loop = args.includes("--loop");
128
+ const n = firstPositional(args);
93
129
  const signer = signerFromEnv();
94
130
  if (live && !signer)
95
131
  return usage("set HUNCH_CUP_PRIVATE_KEY to run --live");
96
132
  const client = new CupClient({ baseUrl: baseUrl(), signer });
97
- out(await client.runStrategy({
98
- strategy,
99
- sizePhunch: size(n),
100
- sentiment: Number(process.env.HUNCH_CUP_SENTIMENT ?? 0),
101
- live,
102
- }));
133
+ const sentiment = Number(process.env.HUNCH_CUP_SENTIMENT ?? 0);
134
+ if (loop) {
135
+ out(`Looping ${strategy} every ${intervalMs(args) / 1000}s (Ctrl-C to stop)…`);
136
+ await client.runLoop({
137
+ strategy,
138
+ sizePhunch: size(n),
139
+ intervalMs: intervalMs(args),
140
+ sentiment,
141
+ live,
142
+ onRound: (round, receipts) => out(`round ${round}: ${receipts.length} trade(s)`),
143
+ onError: (round, err) => console.error(`round ${round} error: ${err instanceof Error ? err.message : err}`),
144
+ });
145
+ return 0;
146
+ }
147
+ out(await client.runStrategy({ strategy, sizePhunch: size(n), sentiment, live }));
103
148
  return 0;
104
149
  }
105
150
  if (cmd === "board") {
@@ -107,6 +152,37 @@ async function main(argv) {
107
152
  out(await client.getLeaderboard({ limit: Number(args[0] ?? 50) }));
108
153
  return 0;
109
154
  }
155
+ if (cmd === "fleet") {
156
+ const sub = args[0];
157
+ if (sub === "new") {
158
+ const count = Number(args[1] ?? 0);
159
+ if (!Number.isFinite(count) || count < 1)
160
+ return usage("fleet new <count>");
161
+ const created = await fleetNew(count, baseUrl());
162
+ out(`Minted ${created.length} wallet(s) + claimed each. Saved to ~/.hunch-cup/fleet.json`);
163
+ out(created.map((w) => ({ name: w.name, address: w.address, strategy: w.strategy })));
164
+ return 0;
165
+ }
166
+ if (sub === "run") {
167
+ const strategy = args[1] ?? "momentum";
168
+ if (!STRATEGIES.includes(strategy))
169
+ return usage("fleet run <strategy> [--interval <sec>]");
170
+ out(`Running the fleet on ${strategy} every ${intervalMs(args) / 1000}s (Ctrl-C to stop)…`);
171
+ await fleetRun({
172
+ defaultStrategy: strategy,
173
+ sizePhunch: size(process.env.HUNCH_CUP_SIZE),
174
+ intervalMs: intervalMs(args),
175
+ baseUrl: baseUrl(),
176
+ onEvent: (line) => out(line),
177
+ });
178
+ return 0;
179
+ }
180
+ if (sub === "board") {
181
+ out(await fleetBoard(baseUrl()));
182
+ return 0;
183
+ }
184
+ return usage("fleet <new|run|board>");
185
+ }
110
186
  return usage(`unknown command: ${cmd}`);
111
187
  }
112
188
  function usage(message) {
package/dist/client.d.ts CHANGED
@@ -5,6 +5,13 @@ export interface CupSigner {
5
5
  address: string;
6
6
  signMessage: (message: string) => Promise<string>;
7
7
  }
8
+ /** A tradeable outcome key + its live paper pool-implied odds. */
9
+ export interface CupOutcome {
10
+ key: string;
11
+ label: string;
12
+ shortLabel: string;
13
+ impliedPct: number;
14
+ }
8
15
  export interface CupMarket {
9
16
  slug: string;
10
17
  marketId: string;
@@ -12,11 +19,31 @@ export interface CupMarket {
12
19
  shortTitle: string;
13
20
  tokenSymbol: string;
14
21
  direction: boolean;
22
+ /** True only for a real binary YES/NO market (drives yesCents/noCents). */
23
+ binary: boolean;
15
24
  deadlineAt: string;
16
- yesCents: number;
17
- noCents: number;
25
+ /** Binary cents; `null` for N-way markets (use `outcomes` instead). */
26
+ yesCents: number | null;
27
+ noCents: number | null;
18
28
  poolPhunch: number;
19
29
  tradeCount: number;
30
+ /** Every tradeable side (keys + odds) — the source of truth for N-way `side`. */
31
+ outcomes: CupOutcome[];
32
+ }
33
+ export interface CupPosition {
34
+ marketId: string;
35
+ side: string;
36
+ costPhunch: number;
37
+ status: "open" | "resolved";
38
+ realizedPnlPhunch: number;
39
+ projectedPayoutPhunch: number;
40
+ }
41
+ export interface CupWalletView {
42
+ walletAddress: string;
43
+ balancePhunch: number;
44
+ realizedPnlPhunch: number;
45
+ openCount: number;
46
+ positions: CupPosition[];
20
47
  }
21
48
  export interface CupTradeReceipt {
22
49
  simulated: boolean;
@@ -42,13 +69,13 @@ export declare class CupClient {
42
69
  get address(): string | undefined;
43
70
  private json;
44
71
  private requireSigner;
45
- /** Claim 1,000 $pHUNCH once for the signer's wallet. */
72
+ /** Claim 10,000 $pHUNCH once for the signer's wallet. */
46
73
  createWallet(): Promise<{
47
74
  walletAddress: string;
48
75
  balancePhunch: number;
49
76
  minted: boolean;
50
77
  }>;
51
- listMarkets(limit?: number): Promise<CupMarket[]>;
78
+ listMarkets(limit?: number, offset?: number): Promise<CupMarket[]>;
52
79
  getQuote(slug: string, side: string, sizePhunch: number): Promise<{
53
80
  shares: number;
54
81
  projectedPayoutPhunch: number;
@@ -63,16 +90,27 @@ export declare class CupClient {
63
90
  sizePhunch: number;
64
91
  tradeId?: string;
65
92
  }): Promise<CupTradeReceipt>;
66
- getPositions(address?: string): Promise<unknown>;
93
+ /** Typed wallet summary: balance + realized PnL + positions, all in $pHUNCH. */
94
+ getWallet(address?: string): Promise<CupWalletView>;
95
+ /** @deprecated use {@link getWallet}. Kept for back-compat. */
96
+ getPositions(address?: string): Promise<CupWalletView>;
67
97
  getLeaderboard(opts?: {
68
98
  limit?: number;
69
99
  week?: number;
70
100
  wallet?: string;
71
101
  }): Promise<unknown>;
72
102
  /**
73
- * One round of a built-in strategy: pick a side per market and place a trade
74
- * (simulated unless `live`). Returns the receipts. The decision is pure
75
- * ({@link pickFor}); placement is deterministic code.
103
+ * One round of a built-in strategy WITH bankroll survival the un-managed
104
+ * loop in the hosting docs went insolvent in ~20 minutes (250 $pHUNCH/round from
105
+ * a finite no-top-up grant). This:
106
+ * • reads the live balance + open positions first (a `live` run);
107
+ * • skips markets the wallet is ALREADY positioned in (no re-piling);
108
+ * • caps the round to `min(size, maxRoundFraction × balance)` per market and
109
+ * stops when the balance can't cover another ticket;
110
+ * • uses a STABLE tradeId (`cup:<roundBucket>:<slug>:<side>`) so an accidental
111
+ * double-fire in the same 5-min bucket is idempotent, not a double-spend;
112
+ * • picks over the market's REAL outcome keys (N-way safe).
113
+ * The decision is pure ({@link pickFor}); placement is deterministic code.
76
114
  */
77
115
  runStrategy(opts: {
78
116
  strategy: CupStrategy;
@@ -80,5 +118,24 @@ export declare class CupClient {
80
118
  limit?: number;
81
119
  sentiment?: number;
82
120
  live?: boolean;
121
+ /** Max fraction of the current balance to deploy per round (default 25%). */
122
+ maxRoundFraction?: number;
83
123
  }): Promise<CupTradeReceipt[]>;
124
+ /**
125
+ * Run a strategy on a fixed interval, forever (or `rounds` times), catching
126
+ * per-round errors so a transient failure never kills the agent — the built-in
127
+ * 24×7 loop the CLI/hosting docs previously punted to a `while true` shell hack.
128
+ */
129
+ runLoop(opts: {
130
+ strategy: CupStrategy;
131
+ sizePhunch: number;
132
+ intervalMs: number;
133
+ limit?: number;
134
+ sentiment?: number;
135
+ live?: boolean;
136
+ maxRoundFraction?: number;
137
+ rounds?: number;
138
+ onRound?: (round: number, receipts: CupTradeReceipt[]) => void;
139
+ onError?: (round: number, err: unknown) => void;
140
+ }): Promise<void>;
84
141
  }
package/dist/client.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Hunch Cup — thin typed SDK client over `/api/cup/v1/*` (S8).
3
3
  *
4
4
  * Wallet-native + keyless: pass a viem `LocalAccount` (or any `CupSigner`) and
5
- * the client claims your 1,000 $pHUNCH, lists/quotes paper markets, and — for a
5
+ * the client claims your 10,000 $pHUNCH, lists/quotes paper markets, and — for a
6
6
  * REAL trade — signs the canonical message for you (the only thing that proves
7
7
  * wallet ownership on the paper path). Reads need no signer. Uses global `fetch`
8
8
  * (Node ≥ 20). `viem` is an optional peer used only by {@link fromPrivateKey}.
@@ -36,7 +36,7 @@ export class CupClient {
36
36
  throw new Error("A signer is required for this action (claim/trade).");
37
37
  return this.signer;
38
38
  }
39
- /** Claim 1,000 $pHUNCH once for the signer's wallet. */
39
+ /** Claim 10,000 $pHUNCH once for the signer's wallet. */
40
40
  async createWallet() {
41
41
  const signer = this.requireSigner();
42
42
  return this.json("/api/cup/v1/wallet", {
@@ -45,8 +45,11 @@ export class CupClient {
45
45
  body: JSON.stringify({ walletAddress: signer.address }),
46
46
  });
47
47
  }
48
- async listMarkets(limit = 25) {
49
- const out = await this.json(`/api/cup/v1/markets?limit=${limit}`);
48
+ async listMarkets(limit = 25, offset = 0) {
49
+ const qs = new URLSearchParams({ limit: String(limit) });
50
+ if (offset > 0)
51
+ qs.set("offset", String(offset));
52
+ const out = await this.json(`/api/cup/v1/markets?${qs}`);
50
53
  return out.markets;
51
54
  }
52
55
  async getQuote(slug, side, sizePhunch) {
@@ -97,12 +100,17 @@ export class CupClient {
97
100
  });
98
101
  return out.receipt;
99
102
  }
100
- async getPositions(address) {
103
+ /** Typed wallet summary: balance + realized PnL + positions, all in $pHUNCH. */
104
+ async getWallet(address) {
101
105
  const addr = address ?? this.signer?.address;
102
106
  if (!addr)
103
107
  throw new Error("No wallet address (pass one or construct with a signer).");
104
108
  return this.json(`/api/cup/v1/wallet/${addr}`);
105
109
  }
110
+ /** @deprecated use {@link getWallet}. Kept for back-compat. */
111
+ async getPositions(address) {
112
+ return this.getWallet(address);
113
+ }
106
114
  async getLeaderboard(opts = {}) {
107
115
  const qs = new URLSearchParams();
108
116
  if (opts.limit)
@@ -115,27 +123,101 @@ export class CupClient {
115
123
  return this.json(`/api/cup/v1/leaderboard${suffix}`);
116
124
  }
117
125
  /**
118
- * One round of a built-in strategy: pick a side per market and place a trade
119
- * (simulated unless `live`). Returns the receipts. The decision is pure
120
- * ({@link pickFor}); placement is deterministic code.
126
+ * One round of a built-in strategy WITH bankroll survival the un-managed
127
+ * loop in the hosting docs went insolvent in ~20 minutes (250 $pHUNCH/round from
128
+ * a finite no-top-up grant). This:
129
+ * • reads the live balance + open positions first (a `live` run);
130
+ * • skips markets the wallet is ALREADY positioned in (no re-piling);
131
+ * • caps the round to `min(size, maxRoundFraction × balance)` per market and
132
+ * stops when the balance can't cover another ticket;
133
+ * • uses a STABLE tradeId (`cup:<roundBucket>:<slug>:<side>`) so an accidental
134
+ * double-fire in the same 5-min bucket is idempotent, not a double-spend;
135
+ * • picks over the market's REAL outcome keys (N-way safe).
136
+ * The decision is pure ({@link pickFor}); placement is deterministic code.
121
137
  */
122
138
  async runStrategy(opts) {
123
139
  const markets = await this.listMarkets(opts.limit ?? 10);
124
140
  const receipts = [];
141
+ // Bankroll snapshot (live only — a simulate run writes nothing, so it can
142
+ // fan out over every market without spending).
143
+ let balance = Infinity;
144
+ const held = new Set();
145
+ if (opts.live && this.signer) {
146
+ try {
147
+ const wallet = await this.getWallet();
148
+ balance = wallet.balancePhunch;
149
+ for (const p of wallet.positions)
150
+ if (p.status === "open")
151
+ held.add(p.marketId);
152
+ }
153
+ catch {
154
+ /* fail-soft: no snapshot → behave like the unmanaged run but still capped below */
155
+ }
156
+ }
157
+ const fraction = opts.maxRoundFraction ?? 0.25;
158
+ let roundBudget = opts.live ? Math.max(0, balance * fraction) : Infinity;
159
+ const bucket = roundBucket();
125
160
  for (const m of markets) {
161
+ if (opts.live && held.has(m.marketId))
162
+ continue; // already positioned
163
+ const size = opts.sizePhunch;
164
+ if (opts.live && (balance < size || roundBudget < size))
165
+ break; // insolvent / budget spent
126
166
  const card = {
127
167
  slug: m.slug,
128
- yesCents: m.yesCents,
129
- noCents: m.noCents,
168
+ yesCents: m.yesCents ?? 50,
169
+ noCents: m.noCents ?? 50,
130
170
  direction: m.direction,
171
+ outcomes: m.outcomes?.map((o) => ({ key: o.key, impliedPct: o.impliedPct })),
131
172
  };
132
173
  const pick = pickFor(opts.strategy, card, opts.sentiment ?? 0);
133
- receipts.push(opts.live
134
- ? await this.trade({ slug: pick.slug, side: pick.side, sizePhunch: opts.sizePhunch })
135
- : await this.simulateTrade(pick.slug, pick.side, opts.sizePhunch));
174
+ if (opts.live) {
175
+ const receipt = await this.trade({
176
+ slug: pick.slug,
177
+ side: pick.side,
178
+ sizePhunch: size,
179
+ tradeId: `cup:${bucket}:${pick.slug}:${pick.side}`,
180
+ });
181
+ receipts.push(receipt);
182
+ if (typeof receipt.balancePhunch === "number")
183
+ balance = receipt.balancePhunch;
184
+ else
185
+ balance -= size;
186
+ roundBudget -= size;
187
+ held.add(m.marketId);
188
+ }
189
+ else {
190
+ receipts.push(await this.simulateTrade(pick.slug, pick.side, size));
191
+ }
136
192
  }
137
193
  return receipts;
138
194
  }
195
+ /**
196
+ * Run a strategy on a fixed interval, forever (or `rounds` times), catching
197
+ * per-round errors so a transient failure never kills the agent — the built-in
198
+ * 24×7 loop the CLI/hosting docs previously punted to a `while true` shell hack.
199
+ */
200
+ async runLoop(opts) {
201
+ for (let round = 1; !opts.rounds || round <= opts.rounds; round += 1) {
202
+ try {
203
+ const receipts = await this.runStrategy(opts);
204
+ opts.onRound?.(round, receipts);
205
+ }
206
+ catch (err) {
207
+ opts.onError?.(round, err);
208
+ }
209
+ if (opts.rounds && round >= opts.rounds)
210
+ break;
211
+ await sleep(opts.intervalMs);
212
+ }
213
+ }
214
+ }
215
+ /** A 5-minute time bucket, so a same-bucket re-run reuses the same tradeIds. */
216
+ function roundBucket() {
217
+ return Math.floor(Date.now() / (5 * 60 * 1000)).toString(36);
218
+ }
219
+ function sleep(ms) {
220
+ return new Promise((resolve) => setTimeout(resolve, ms));
139
221
  }
140
222
  function randomId() {
141
223
  // Best-effort unique id; the server idempotency key is the full tradeId.
@@ -0,0 +1,43 @@
1
+ import type { CupStrategy } from "./strategy.js";
2
+ export interface FleetWallet {
3
+ name: string;
4
+ privateKey: string;
5
+ address: string;
6
+ /** Optional per-wallet strategy override (else the fleet default is used). */
7
+ strategy?: CupStrategy;
8
+ }
9
+ export interface Fleet {
10
+ wallets: FleetWallet[];
11
+ }
12
+ export declare function fleetFilePath(): string;
13
+ export declare function loadFleet(): Fleet;
14
+ export declare function saveFleet(fleet: Fleet): string;
15
+ /**
16
+ * Mint `count` fresh wallets, claim each one's 10,000 $pHUNCH, and append them to
17
+ * the fleet file with a rotating strategy. Idempotent claim (the server grants
18
+ * once), so re-running only tops up the roster.
19
+ */
20
+ export declare function fleetNew(count: number, baseUrl?: string): Promise<FleetWallet[]>;
21
+ /**
22
+ * Run a strategy loop across every fleet wallet concurrently. Each wallet uses
23
+ * its own per-wallet `strategy` if set, else `defaultStrategy`. Rounds are
24
+ * staggered so N wallets don't all fire on the exact same instant. Never throws —
25
+ * per-round errors are reported via `onEvent`.
26
+ */
27
+ export declare function fleetRun(opts: {
28
+ defaultStrategy: CupStrategy;
29
+ sizePhunch: number;
30
+ intervalMs: number;
31
+ baseUrl?: string;
32
+ limit?: number;
33
+ rounds?: number;
34
+ onEvent?: (line: string) => void;
35
+ }): Promise<void>;
36
+ /** Each fleet wallet's live rank + balance + realized PnL, best rank first. */
37
+ export declare function fleetBoard(baseUrl?: string): Promise<Array<{
38
+ name: string;
39
+ address: string;
40
+ rank: number | null;
41
+ balancePhunch: number;
42
+ realizedPnlPhunch: number;
43
+ }>>;
package/dist/fleet.js ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Hunch Cup — run a FLEET of agents from one machine (founder goal 2: "one
3
+ * person spins up 5–10 agents easily").
4
+ *
5
+ * A fleet is a set of throwaway wallets persisted to `~/.hunch-cup/fleet.json`
6
+ * (override with `HUNCH_CUP_FLEET_FILE`). `fleetNew` mints N fresh wallets and
7
+ * claims each one's 10,000 $pHUNCH; `fleetRun` runs a (possibly per-wallet mixed)
8
+ * strategy loop across all of them concurrently; `fleetBoard` shows each wallet's
9
+ * live rank. Keys never leave the local file.
10
+ *
11
+ * SECURITY: the file holds real (paper-only) private keys in plaintext — same
12
+ * trust model as `HUNCH_CUP_PRIVATE_KEY`. These wallets only ever touch paper
13
+ * $pHUNCH, never real funds.
14
+ */
15
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join, dirname } from "node:path";
18
+ import { CupClient } from "./client.js";
19
+ import { fromPrivateKey, newWallet } from "./signer.js";
20
+ export function fleetFilePath() {
21
+ return process.env.HUNCH_CUP_FLEET_FILE ?? join(homedir(), ".hunch-cup", "fleet.json");
22
+ }
23
+ export function loadFleet() {
24
+ const path = fleetFilePath();
25
+ if (!existsSync(path))
26
+ return { wallets: [] };
27
+ try {
28
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
29
+ return { wallets: Array.isArray(parsed.wallets) ? parsed.wallets : [] };
30
+ }
31
+ catch {
32
+ return { wallets: [] };
33
+ }
34
+ }
35
+ export function saveFleet(fleet) {
36
+ const path = fleetFilePath();
37
+ mkdirSync(dirname(path), { recursive: true });
38
+ writeFileSync(path, JSON.stringify(fleet, null, 2), { mode: 0o600 });
39
+ return path;
40
+ }
41
+ /** A rotation of the built-in strategies, so a demo fleet is visibly diverse. */
42
+ const STRATEGY_CYCLE = ["momentum", "contrarian", "news-reactive"];
43
+ /**
44
+ * Mint `count` fresh wallets, claim each one's 10,000 $pHUNCH, and append them to
45
+ * the fleet file with a rotating strategy. Idempotent claim (the server grants
46
+ * once), so re-running only tops up the roster.
47
+ */
48
+ export async function fleetNew(count, baseUrl) {
49
+ const fleet = loadFleet();
50
+ const created = [];
51
+ for (let i = 0; i < count; i += 1) {
52
+ const { privateKey, signer } = newWallet();
53
+ const idx = fleet.wallets.length + created.length;
54
+ const wallet = {
55
+ name: `bot-${idx + 1}`,
56
+ privateKey,
57
+ address: signer.address,
58
+ strategy: STRATEGY_CYCLE[idx % STRATEGY_CYCLE.length],
59
+ };
60
+ try {
61
+ await new CupClient({ baseUrl, signer }).createWallet();
62
+ }
63
+ catch {
64
+ /* claim is dormant/closed or offline — the wallet is saved regardless */
65
+ }
66
+ created.push(wallet);
67
+ }
68
+ saveFleet({ wallets: [...fleet.wallets, ...created] });
69
+ return created;
70
+ }
71
+ /**
72
+ * Run a strategy loop across every fleet wallet concurrently. Each wallet uses
73
+ * its own per-wallet `strategy` if set, else `defaultStrategy`. Rounds are
74
+ * staggered so N wallets don't all fire on the exact same instant. Never throws —
75
+ * per-round errors are reported via `onEvent`.
76
+ */
77
+ export async function fleetRun(opts) {
78
+ const fleet = loadFleet();
79
+ if (fleet.wallets.length === 0) {
80
+ opts.onEvent?.("No fleet wallets. Run `hunch-cup fleet new <n>` first.");
81
+ return;
82
+ }
83
+ const stagger = Math.floor(opts.intervalMs / Math.max(1, fleet.wallets.length));
84
+ await Promise.all(fleet.wallets.map(async (w, i) => {
85
+ await sleep(stagger * i); // spread the first fire across the interval
86
+ const client = new CupClient({ baseUrl: opts.baseUrl, signer: fromPrivateKey(w.privateKey) });
87
+ const strategy = w.strategy ?? opts.defaultStrategy;
88
+ await client.runLoop({
89
+ strategy,
90
+ sizePhunch: opts.sizePhunch,
91
+ intervalMs: opts.intervalMs,
92
+ limit: opts.limit,
93
+ live: true,
94
+ rounds: opts.rounds,
95
+ onRound: (round, receipts) => opts.onEvent?.(`[${w.name}·${strategy}] round ${round}: ${receipts.length} trade(s)`),
96
+ onError: (round, err) => opts.onEvent?.(`[${w.name}] round ${round} error: ${err instanceof Error ? err.message : String(err)}`),
97
+ });
98
+ }));
99
+ }
100
+ /** Each fleet wallet's live rank + balance + realized PnL, best rank first. */
101
+ export async function fleetBoard(baseUrl) {
102
+ const fleet = loadFleet();
103
+ const rows = await Promise.all(fleet.wallets.map(async (w) => {
104
+ const client = new CupClient({ baseUrl });
105
+ let balancePhunch = 0;
106
+ let realizedPnlPhunch = 0;
107
+ let rank = null;
108
+ try {
109
+ const wallet = await client.getWallet(w.address);
110
+ balancePhunch = wallet.balancePhunch;
111
+ realizedPnlPhunch = wallet.realizedPnlPhunch;
112
+ }
113
+ catch {
114
+ /* dormant/offline */
115
+ }
116
+ try {
117
+ const board = (await client.getLeaderboard({ wallet: w.address, limit: 1 }));
118
+ rank = board.you?.rank ?? null;
119
+ }
120
+ catch {
121
+ /* no rank yet */
122
+ }
123
+ return { name: w.name, address: w.address, rank, balancePhunch, realizedPnlPhunch };
124
+ }));
125
+ return rows.sort((a, b) => (a.rank ?? 1e9) - (b.rank ?? 1e9));
126
+ }
127
+ function sleep(ms) {
128
+ return new Promise((resolve) => setTimeout(resolve, ms));
129
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export { CupClient, DEFAULT_BASE_URL } from "./client.js";
2
- export type { CupClientOptions, CupSigner, CupMarket, CupTradeReceipt, } from "./client.js";
2
+ export type { CupClientOptions, CupSigner, CupMarket, CupOutcome, CupPosition, CupWalletView, CupTradeReceipt, } from "./client.js";
3
3
  export { fromViemAccount, fromPrivateKey, newWallet } from "./signer.js";
4
4
  export { buildCupTradeMessage } from "./message.js";
5
5
  export type { CupTradeMessageParams } from "./message.js";
6
6
  export { pickFor, momentumPick, contrarianPick, newsReactivePick, } from "./strategy.js";
7
- export type { CupStrategy, StrategyCard, StrategyPick } from "./strategy.js";
7
+ export type { CupStrategy, StrategyCard, StrategyOutcome, StrategyPick, } from "./strategy.js";
8
+ export { fleetNew, fleetRun, fleetBoard, loadFleet, saveFleet, fleetFilePath, } from "./fleet.js";
9
+ export type { Fleet, FleetWallet } from "./fleet.js";
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ export { CupClient, DEFAULT_BASE_URL } from "./client.js";
2
2
  export { fromViemAccount, fromPrivateKey, newWallet } from "./signer.js";
3
3
  export { buildCupTradeMessage } from "./message.js";
4
4
  export { pickFor, momentumPick, contrarianPick, newsReactivePick, } from "./strategy.js";
5
+ export { fleetNew, fleetRun, fleetBoard, loadFleet, saveFleet, fleetFilePath, } from "./fleet.js";
@@ -3,8 +3,16 @@
3
3
  *
4
4
  * Pure, deterministic deciders. The LLM (if any) NEVER executes — a strategy
5
5
  * maps a market card to a concrete `{ side }` and order placement is plain code.
6
- * Direction markets resolve to UP/DOWN; binary markets to YES/NO.
6
+ * Binary markets resolve to YES/NO, direction markets to UP/DOWN, and N-way
7
+ * markets (ladders, outperform books, THE FLIP: heads/tails/tie) pick over their
8
+ * REAL outcome keys — never yes/no, which on an N-way market records a bet that
9
+ * can never win (the server now 422s it; the strategy must not emit it).
7
10
  */
11
+ /** One tradeable outcome with its live pool-implied probability (0–100). */
12
+ export interface StrategyOutcome {
13
+ key: string;
14
+ impliedPct: number;
15
+ }
8
16
  export interface StrategyCard {
9
17
  slug: string;
10
18
  /** Implied paper-pool probability of the positive outcome, in cents (0–100). */
@@ -13,20 +21,28 @@ export interface StrategyCard {
13
21
  noCents: number;
14
22
  /** True → the positive/negative outcomes are UP/DOWN, not YES/NO. */
15
23
  direction: boolean;
24
+ /**
25
+ * The market's real outcome keys + odds. Present for N-way markets (and
26
+ * optionally binary); when set, strategies pick over THESE keys so an
27
+ * N-way bet never lands on a phantom yes/no side.
28
+ */
29
+ outcomes?: StrategyOutcome[];
16
30
  }
17
31
  export interface StrategyPick {
18
32
  slug: string;
19
33
  side: string;
20
34
  }
21
35
  export type CupStrategy = "momentum" | "contrarian" | "news-reactive";
22
- /** Momentum: trade WITH the crowd — back the pool's favorite. */
36
+ /** Momentum: trade WITH the crowd — back the pool's favorite (max odds). */
23
37
  export declare function momentumPick(card: StrategyCard): StrategyPick;
24
- /** Contrarian: fade the crowd — back the underdog. */
38
+ /** Contrarian: fade the crowd — back the longest-odds outcome. */
25
39
  export declare function contrarianPick(card: StrategyCard): StrategyPick;
26
40
  /**
27
41
  * News-reactive: trade off an external sentiment signal in [-1, 1] (e.g. a
28
- * headline classifier). Positive back the positive outcome; negative → the
29
- * negative; a neutral 0 falls back to the pool favorite.
42
+ * headline classifier). On a binary/direction market positive → the positive
43
+ * outcome (YES/UP), negative the negative (NO/DOWN); neutral 0 momentum.
44
+ * N-way markets have no natural positive pole, so a signal maps onto the
45
+ * crowd favorite (positive) or underdog (negative) — always a real key.
30
46
  */
31
47
  export declare function newsReactivePick(card: StrategyCard, sentiment: number): StrategyPick;
32
48
  /** Dispatch a named strategy. `news-reactive` uses `sentiment` (default 0). */
package/dist/strategy.js CHANGED
@@ -3,9 +3,31 @@
3
3
  *
4
4
  * Pure, deterministic deciders. The LLM (if any) NEVER executes — a strategy
5
5
  * maps a market card to a concrete `{ side }` and order placement is plain code.
6
- * Direction markets resolve to UP/DOWN; binary markets to YES/NO.
6
+ * Binary markets resolve to YES/NO, direction markets to UP/DOWN, and N-way
7
+ * markets (ladders, outperform books, THE FLIP: heads/tails/tie) pick over their
8
+ * REAL outcome keys — never yes/no, which on an N-way market records a bet that
9
+ * can never win (the server now 422s it; the strategy must not emit it).
7
10
  */
8
- /** The outcome label for a pole on this card (UP/DOWN vs YES/NO). */
11
+ /** Is this an N-way market we should pick over its own outcome keys? */
12
+ function isNway(card) {
13
+ return (Array.isArray(card.outcomes) &&
14
+ card.outcomes.length > 0 &&
15
+ // A true binary yes/no pair is handled by the yesCents/noCents path below.
16
+ !(card.outcomes.length === 2 &&
17
+ card.outcomes.some((o) => o.key === "yes") &&
18
+ card.outcomes.some((o) => o.key === "no")));
19
+ }
20
+ /** The outcome key with the highest / lowest implied odds (ties → first listed). */
21
+ function extreme(outcomes, want) {
22
+ return outcomes.reduce((best, o) => want === "max"
23
+ ? o.impliedPct > best.impliedPct
24
+ ? o
25
+ : best
26
+ : o.impliedPct < best.impliedPct
27
+ ? o
28
+ : best).key;
29
+ }
30
+ /** The outcome label for a binary/direction pole (UP/DOWN vs YES/NO). */
9
31
  function sideFor(card, pole) {
10
32
  if (card.direction)
11
33
  return pole === "pos" ? "up" : "down";
@@ -15,21 +37,32 @@ function sideFor(card, pole) {
15
37
  function favorite(card) {
16
38
  return card.yesCents >= card.noCents ? "pos" : "neg";
17
39
  }
18
- /** Momentum: trade WITH the crowd — back the pool's favorite. */
40
+ /** Momentum: trade WITH the crowd — back the pool's favorite (max odds). */
19
41
  export function momentumPick(card) {
42
+ if (isNway(card))
43
+ return { slug: card.slug, side: extreme(card.outcomes, "max") };
20
44
  return { slug: card.slug, side: sideFor(card, favorite(card)) };
21
45
  }
22
- /** Contrarian: fade the crowd — back the underdog. */
46
+ /** Contrarian: fade the crowd — back the longest-odds outcome. */
23
47
  export function contrarianPick(card) {
48
+ if (isNway(card))
49
+ return { slug: card.slug, side: extreme(card.outcomes, "min") };
24
50
  const pole = favorite(card) === "pos" ? "neg" : "pos";
25
51
  return { slug: card.slug, side: sideFor(card, pole) };
26
52
  }
27
53
  /**
28
54
  * News-reactive: trade off an external sentiment signal in [-1, 1] (e.g. a
29
- * headline classifier). Positive back the positive outcome; negative → the
30
- * negative; a neutral 0 falls back to the pool favorite.
55
+ * headline classifier). On a binary/direction market positive → the positive
56
+ * outcome (YES/UP), negative the negative (NO/DOWN); neutral 0 momentum.
57
+ * N-way markets have no natural positive pole, so a signal maps onto the
58
+ * crowd favorite (positive) or underdog (negative) — always a real key.
31
59
  */
32
60
  export function newsReactivePick(card, sentiment) {
61
+ if (isNway(card)) {
62
+ if (sentiment < 0)
63
+ return contrarianPick(card);
64
+ return momentumPick(card);
65
+ }
33
66
  if (sentiment > 0)
34
67
  return { slug: card.slug, side: sideFor(card, "pos") };
35
68
  if (sentiment < 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hunch-cup",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "One-line CLI + typed SDK to paper-trade the Hunch Cup tournament: claim 1,000 $pHUNCH, trade live markets with zero real-funds risk, run momentum/contrarian/news-reactive agents, and climb the public leaderboard.",
5
5
  "keywords": [
6
6
  "hunch",