polly-gamba 1.0.23 → 1.0.31

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.
@@ -30,6 +30,7 @@ function findClaudeBin() {
30
30
  }
31
31
  const CLAUDE_BIN = findClaudeBin();
32
32
  const CLAUDE_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY || '';
33
+ const PAPER_MODE = process.env.PAPER_MODE === 'true' || process.env.PAPER_MODE === '1';
33
34
  class ClaudeTrader {
34
35
  proc = null;
35
36
  redis;
@@ -100,12 +101,12 @@ class ClaudeTrader {
100
101
  type: 'user',
101
102
  message: {
102
103
  role: 'user',
103
- content: `You are a Polymarket paper trader running a high-volume moneyball strategy. Your job is to place paper trades on EVERY market where you have ANY opinion on fair value — even slight.
104
-
105
- TOOLS: place_order, skip_all, get_budget_status
104
+ content: `You are a Polymarket ${PAPER_MODE ? 'PAPER TRADING (no real money)' : 'paper'} trader running a high-volume moneyball strategy. Your job is to place paper trades on EVERY market where you have ANY opinion on fair value — even slight.
105
+ ${PAPER_MODE ? '\n⚠️ PAPER MODE: This is a test environment. Be AGGRESSIVE — trade more, skip less. Lower your threshold to 3% edge for any market resolving within 6 months.\n' : ''}
106
+ TOOLS: place_order, skip_all, get_budget_status, get_positions
106
107
  RULES:
107
108
  - Output ONLY tool calls. Zero prose.
108
- - For EVERY market in the list: if current price differs from your estimated fair probability by more than 5%, place a trade.
109
+ - For EVERY market in the list: if current price differs from your estimated fair probability by more than ${PAPER_MODE ? '3%' : '5%'}, place a trade.
109
110
  - YES is underpriced → BUY YES. NO is underpriced (YES overpriced) → BUY NO.
110
111
  - $10 USDC per trade (small size, many bets).
111
112
  - NO cap on number of trades — bet every market where you see any edge.
@@ -116,7 +117,7 @@ RULES:
116
117
  - Max $100 per market (20% of $500 budget). The MCP enforces this — don't fight it.
117
118
  - To add to an existing position with the SAME outcome: (1) you MUST cite a specific new catalyst (news published in last 24h, not price movement), AND (2) the current price must be ≥20% below your last entry price (e.g. if you bought YES at 0.15, new entry only valid at ≤0.12). The MCP enforces this — attempts at a smaller discount will be rejected. Price dipping alone is NOT a catalyst. New information is a catalyst.
118
119
  - exit_trigger is required on every trade. Be specific: "Exit when price hits 0.X" or "Exit when [specific news event]" — not "when narrative converges."
119
- - Call get_budget_status at the start of each scan to know available capital.`
120
+ - Call get_budget_status AND get_positions at the start of each scan to know available capital and your current open positions. get_positions returns {"positions": [...], "closed_markets": [...]}. You MUST review BOTH before trading to apply concentration AND closed-market discipline correctly.`
120
121
  }
121
122
  }));
122
123
  this.ready = true;
@@ -184,7 +185,7 @@ Call place_order on any market you have edge on. $10 per trade. No cap.`;
184
185
  const prefix = this.config.redisPrefix;
185
186
  await this.redis.lpush(`${prefix}:scans`, JSON.stringify({ scanId, markets_count: markets.length, ts: Date.now() }));
186
187
  await this.redis.ltrim(`${prefix}:scans`, 0, 9999);
187
- const tradeable = markets.filter(m => m.tokens?.some(t => t.price > 0.02 && t.price < 0.98));
188
+ const tradeable = markets.filter(m => m.tokens?.some(t => t.price >= 0.10 && t.price <= 0.90));
188
189
  const prompt = `## Autonomous Scan ${scanId}
189
190
 
190
191
  Time: ${new Date().toISOString()}
@@ -198,7 +199,35 @@ ${tradeable.map((m, i) => `### ${i + 1}. ${m.question}
198
199
  - Tokens: ${m.tokens?.map(t => `${t.outcome}@${t.price}`).join(', ') || 'none'}
199
200
  ${m.description ? `- Description: ${m.description.slice(0, 200)}` : ''}`).join('\n\n')}
200
201
 
201
- For EVERY market above: if price differs from your fair probability by >5%, place a trade ($10 USDC). Call skip_all only if you have zero opinion on all markets.`;
202
+ ## HORIZON DISCIPLINE (required applied before edge check):
203
+ - Markets resolving WITHIN 6 months: ${PAPER_MODE ? '>3% edge required (PAPER MODE — be aggressive)' : 'standard >5% edge required'}.
204
+ - Markets resolving 6–12 months out: require >${PAPER_MODE ? '7' : '10'}% edge. Long-horizon bets tie up capital without near-term price catalysts.
205
+ - Markets resolving MORE than 12 months out: require >${PAPER_MODE ? '12' : '15'}% edge. Only trade if the mispricing is extreme and obvious.
206
+ - If the end date is unknown or ambiguous, treat it as >12 months. Do NOT guess short horizon.
207
+ - These thresholds stack: if the market already has an open position, do not add unless it meets the horizon threshold AND the 20%-discount re-entry rule.
208
+
209
+ ## CONCENTRATION DISCIPLINE (required — applied before edge check):
210
+ - Count your existing open positions by underlying theme. A "theme" is the single real-world event that resolves the bet (e.g. "GTA VI release", "Hungary 2026 election", "NBA 2026 Finals").
211
+ - MAX 2 open positions per underlying theme. If you already have 2+ open positions in the same theme, SKIP all new markets in that theme regardless of apparent edge.
212
+ - Correlation examples: "Will Jesus return before GTA VI", "Will BTC hit $1M before GTA VI", "Will China invade Taiwan before GTA VI" — all three resolve based on GTA VI's release date. They are the SAME theme.
213
+ - Similarly: "Will Orbán be PM" and "Will Magyar be PM" are both the Hungary 2026 election — treat as same theme (you may hold both as a hedge pair, but do not add a third Hungary position).
214
+ - If you're uncertain whether a market correlates to an existing theme, assume it does and skip.
215
+
216
+ ## CLOSED MARKET DISCIPLINE (absolute rule — never override):
217
+ - get_positions returns a JSON object with two keys: "positions" (open) and "closed_markets" (previously exited).
218
+ - NEVER place a new order for any market_id+outcome combination that appears in closed_markets. This is enforced server-side and will be rejected anyway.
219
+ - The reason: re-entering a market you already exited is a sign of anchoring bias. If the thesis was wrong once, sportsbook odds movement is not sufficient justification. Accept the loss and redeploy capital elsewhere.
220
+
221
+ ## SPORTSBOOK CROSS-CHECK DISCIPLINE (apply when estimating fair value):
222
+ - Sportsbooks measure betting DEMAND, not calibrated probability. Polymarket is often better calibrated on liquid markets (>$50k volume).
223
+ - When fair value is primarily derived from sportsbook lines (e.g. "DraftKings has X at +225 implying 30%"), apply a 50% haircut to the apparent cross-market edge. Example: sportsbooks imply 30%, Polymarket shows 15% → effective edge = (30%×0.8 − 15%) = 9pp, not 15pp.
224
+ - Sportsbook-derived estimates MUST exceed the horizon threshold AFTER the 50% haircut. Do not enter based purely on sportsbook arbitrage at small gaps.
225
+ - Anchor fair value on base rates and world knowledge first; use sportsbooks to adjust by ±5pp only. If you cannot defend the estimate without the sportsbook line, the edge is too speculative.
226
+ - Exception: if both Polymarket AND sportsbooks agree with your base-rate analysis, the convergence strengthens the thesis.
227
+
228
+ STEP 1: Call get_positions to see your current open positions AND closed_markets — required before evaluating any market.
229
+ STEP 2: For each market, apply horizon discipline, concentration discipline, AND closed market discipline (using the data from step 1).
230
+ STEP 3: If price differs from your fair probability by the required edge (accounting for sportsbook haircut if applicable), place a trade ($10 USDC). Call skip_all only if you have zero opinion on all markets.`;
202
231
  const msg = JSON.stringify({
203
232
  type: 'user',
204
233
  message: { role: 'user', content: prompt }
@@ -279,6 +308,8 @@ CLOSE RULES — apply ALL that match, no thesis override allowed:
279
308
  3. Your side of the position is priced below 10% — HARD CLOSE. The market has strongly repriced against you. Do NOT override this with "thesis not disproven yet." At <10%, expected value of holding is near zero.
280
309
  4. Position is down >50% — HARD CLOSE regardless of thesis. Cut losses. No exceptions.
281
310
  5. You have 2+ open positions in the SAME market with the SAME outcome — close the NEWER entry (higher ts value) regardless of P&L. Duplicate same-outcome positions double exposure without incremental edge. Keep the original entry.
311
+ 6. Position is UP >25% from entry AND has been held >48h — CLOSE to lock in profits. The market has moved significantly in your favor; gains are now vulnerable to reversion. Use take_profit reason. Exception: if the resolution event is within 7 days AND the position is still clearly on the right side, you may hold until resolution.
312
+ 7. STALE POSITION: Position held >7 days AND price has moved <5% from entry AND resolution event is >30 days away — CLOSE to redeploy capital. A position that doesn't move over 7 days means the mispricing wasn't as large as estimated. Cut it, accept the small loss/gain, and redeploy to markets with active price movement.
282
313
 
283
314
  HOLD RULES: If NONE of the close rules apply and exit trigger NOT triggered, do nothing (no output needed).`;
284
315
  const msg = JSON.stringify({
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Core business logic for paper trading.
3
+ * Extracted so it can be unit-tested independently of the MCP server.
4
+ */
5
+ export interface Position {
6
+ market_id: string;
7
+ market_question: string;
8
+ outcome: string;
9
+ side: string;
10
+ size_usdc: number;
11
+ price: number;
12
+ ts: number;
13
+ status: 'open' | 'closed';
14
+ exit_price?: number;
15
+ pnl?: number;
16
+ closed_at?: number;
17
+ reason?: string;
18
+ exit_trigger?: string;
19
+ reasoning?: string;
20
+ new_catalyst?: string | null;
21
+ token_id?: string;
22
+ }
23
+ export interface PlaceOrderParams {
24
+ market_id: string;
25
+ outcome: string;
26
+ size_usdc: number;
27
+ price: number;
28
+ new_catalyst?: string;
29
+ }
30
+ export declare const TOTAL_BUDGET = 500;
31
+ export declare const MARKET_CAP = 100;
32
+ /**
33
+ * Validate a place_order request against the current set of open positions.
34
+ * Returns an error message string if validation fails, or null if OK.
35
+ */
36
+ export declare function validatePlaceOrder(params: PlaceOrderParams, openPositions: Position[]): string | null;
37
+ /**
38
+ * Compute P&L for closing a position at a given exit price.
39
+ * Returns null if the entry price is zero or invalid (prevents division by zero).
40
+ *
41
+ * Formula: shares = size_usdc / entry_price; pnl = shares * exit_price - size_usdc
42
+ */
43
+ export declare function computePnl(position: Position, exitPrice: number): number | null;
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ /**
3
+ * Core business logic for paper trading.
4
+ * Extracted so it can be unit-tested independently of the MCP server.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.MARKET_CAP = exports.TOTAL_BUDGET = void 0;
8
+ exports.validatePlaceOrder = validatePlaceOrder;
9
+ exports.computePnl = computePnl;
10
+ exports.TOTAL_BUDGET = 500;
11
+ exports.MARKET_CAP = 100;
12
+ /**
13
+ * Validate a place_order request against the current set of open positions.
14
+ * Returns an error message string if validation fails, or null if OK.
15
+ */
16
+ function validatePlaceOrder(params, openPositions) {
17
+ // Input validation — guard against bad LLM output
18
+ if (params.size_usdc == null ||
19
+ typeof params.size_usdc !== 'number' ||
20
+ isNaN(params.size_usdc) ||
21
+ params.size_usdc <= 0) {
22
+ return `Error: size_usdc must be a positive number. Got: ${params.size_usdc}`;
23
+ }
24
+ if (params.price == null ||
25
+ typeof params.price !== 'number' ||
26
+ isNaN(params.price) ||
27
+ params.price <= 0 ||
28
+ params.price > 1) {
29
+ return `Error: price must be in range (0, 1]. Got: ${params.price}`;
30
+ }
31
+ const sameMarketPositions = openPositions.filter(p => p.market_id === params.market_id);
32
+ // Check 1: Existing position for same market requires new_catalyst
33
+ if (sameMarketPositions.length > 0 && !params.new_catalyst) {
34
+ return `Error: Position already exists for this market. Provide new_catalyst (specific new information from last 24h) to add.`;
35
+ }
36
+ // Check 1b: Same-outcome re-entry requires ≥20% price improvement
37
+ const sameOutcomePositions = sameMarketPositions.filter(p => String(p.outcome).toLowerCase() === String(params.outcome).toLowerCase());
38
+ if (sameOutcomePositions.length > 0) {
39
+ const lastEntry = Math.max(...sameOutcomePositions.map(p => p.price || 0));
40
+ const requiredPrice = lastEntry * 0.80;
41
+ if (params.price > requiredPrice) {
42
+ return `Error: Re-entry blocked. Last ${params.outcome} entry at ${lastEntry.toFixed(4)}. Require price ≤ ${requiredPrice.toFixed(4)} (20% better) to add same-direction position. Current price ${params.price.toFixed(4)} is too close to last entry. Wait for a larger dislocation before averaging.`;
43
+ }
44
+ }
45
+ // Check 2: $500 total budget cap
46
+ const totalDeployed = openPositions.reduce((s, p) => s + (p.size_usdc || 0), 0);
47
+ if (totalDeployed + params.size_usdc > exports.TOTAL_BUDGET) {
48
+ const available = Math.max(0, exports.TOTAL_BUDGET - totalDeployed);
49
+ return `Error: Budget cap reached. $${exports.TOTAL_BUDGET} paper budget. Currently deployed: $${totalDeployed.toFixed(2)}. Available: $${available.toFixed(2)}.`;
50
+ }
51
+ // Check 3: $100 per-market concentration cap
52
+ const marketDeployed = sameMarketPositions.reduce((s, p) => s + (p.size_usdc || 0), 0);
53
+ if (marketDeployed + params.size_usdc > exports.MARKET_CAP) {
54
+ return `Error: Single-market cap: max $${exports.MARKET_CAP} per market (20% of $${exports.TOTAL_BUDGET} budget). Already deployed $${marketDeployed.toFixed(2)} in this market.`;
55
+ }
56
+ return null; // valid
57
+ }
58
+ /**
59
+ * Compute P&L for closing a position at a given exit price.
60
+ * Returns null if the entry price is zero or invalid (prevents division by zero).
61
+ *
62
+ * Formula: shares = size_usdc / entry_price; pnl = shares * exit_price - size_usdc
63
+ */
64
+ function computePnl(position, exitPrice) {
65
+ if (!position.price || position.price <= 0 || isNaN(position.price)) {
66
+ return null;
67
+ }
68
+ const shares = position.size_usdc / position.price;
69
+ return shares * exitPrice - position.size_usdc;
70
+ }
71
+ //# sourceMappingURL=paper-trade.js.map
@@ -8,6 +8,7 @@ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
8
8
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
9
9
  const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
10
10
  const ioredis_1 = __importDefault(require("ioredis"));
11
+ const paper_trade_1 = require("./lib/paper-trade");
11
12
  const REDIS_PREFIX = process.env.POLLY_REDIS_PREFIX || 'polly';
12
13
  const redis = new ioredis_1.default(process.env.REDIS_URL || 'redis://localhost:6379', {
13
14
  retryStrategy: (times) => Math.min(times * 500, 5000),
@@ -110,8 +111,6 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
110
111
  const ts = Date.now();
111
112
  if (name === 'place_order') {
112
113
  const a = args;
113
- const TOTAL_BUDGET = 500;
114
- const MARKET_CAP = 100;
115
114
  // Load all open positions for enforcement checks
116
115
  const rawPositions = await redis.lrange(`${REDIS_PREFIX}:positions`, 0, -1);
117
116
  const closedIds = new Set(await redis.smembers(`${REDIS_PREFIX}:closed_ids`));
@@ -127,50 +126,16 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
127
126
  return false;
128
127
  return !closedIds.has(`${p.market_id}_${p.outcome}_${p.ts}`);
129
128
  });
130
- // Check 1: Existing position for same market_id requires new_catalyst
131
- const sameMarketPositions = openPositions.filter((p) => p.market_id === a.market_id);
132
- if (sameMarketPositions.length > 0 && !a.new_catalyst) {
133
- return {
134
- content: [{
135
- type: 'text',
136
- text: `Error: Position already exists for this market. Provide new_catalyst (specific new information from last 24h) to add.`
137
- }]
138
- };
139
- }
140
- // Check 1b: Same-outcome re-entry requires ≥20% price improvement (prevents pyramiding into losers)
141
- const sameOutcomePositions = sameMarketPositions.filter((p) => String(p.outcome).toLowerCase() === String(a.outcome).toLowerCase());
142
- if (sameOutcomePositions.length > 0) {
143
- const lastEntry = Math.max(...sameOutcomePositions.map((p) => p.price || 0));
144
- const requiredPrice = lastEntry * 0.80;
145
- if (a.price > requiredPrice) {
146
- return {
147
- content: [{
148
- type: 'text',
149
- text: `Error: Re-entry blocked. Last ${a.outcome} entry at ${lastEntry.toFixed(4)}. Require price ≤ ${requiredPrice.toFixed(4)} (20% better) to add same-direction position. Current price ${a.price.toFixed(4)} is too close to last entry. Wait for a larger dislocation before averaging.`
150
- }]
151
- };
152
- }
153
- }
154
- // Check 2: $500 total budget cap
155
- const totalDeployed = openPositions.reduce((s, p) => s + (p.size_usdc || 0), 0);
156
- if (totalDeployed + a.size_usdc > TOTAL_BUDGET) {
157
- const available = Math.max(0, TOTAL_BUDGET - totalDeployed);
158
- return {
159
- content: [{
160
- type: 'text',
161
- text: `Error: Budget cap reached. $500 paper budget. Currently deployed: $${totalDeployed.toFixed(2)}. Available: $${available.toFixed(2)}.`
162
- }]
163
- };
129
+ // Block re-entry into previously closed market+outcome combos
130
+ const marketOutcomePrefix = `${a.market_id}_${a.outcome}_`;
131
+ const wasClosedBefore = Array.from(closedIds).some(id => id.startsWith(marketOutcomePrefix));
132
+ if (wasClosedBefore) {
133
+ return { content: [{ type: 'text', text: `Error: Re-entry blocked. This market+outcome (${a.market_id} ${a.outcome}) was previously closed. Polly-gamba does not re-enter previously exited positions to prevent repeated losses on the same bet.` }] };
164
134
  }
165
- // Check 3: $100 per-market concentration cap
166
- const marketDeployed = sameMarketPositions.reduce((s, p) => s + (p.size_usdc || 0), 0);
167
- if (marketDeployed + a.size_usdc > MARKET_CAP) {
168
- return {
169
- content: [{
170
- type: 'text',
171
- text: `Error: Single-market cap: max $100 per market (20% of $500 budget). Already deployed $${marketDeployed.toFixed(2)} in this market.`
172
- }]
173
- };
135
+ // Validate using extracted business logic (includes input validation + budget checks)
136
+ const validationError = (0, paper_trade_1.validatePlaceOrder)({ market_id: a.market_id, outcome: a.outcome, size_usdc: a.size_usdc, price: a.price, new_catalyst: a.new_catalyst }, openPositions);
137
+ if (validationError) {
138
+ return { content: [{ type: 'text', text: validationError }] };
174
139
  }
175
140
  const position = {
176
141
  market_id: a.market_id,
@@ -232,13 +197,28 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
232
197
  const key = `${p.market_id}_${p.outcome}_${p.ts}`;
233
198
  return !closedIds.has(key);
234
199
  });
200
+ // Expose previously-closed market+outcome combos so Claude can avoid re-entry
201
+ const closedMarkets = Array.from(closedIds).map(key => {
202
+ const parts = key.split('_');
203
+ return { market_id: parts[0], outcome: parts[1] };
204
+ });
205
+ // Deduplicate
206
+ const seen = new Set();
207
+ const uniqueClosedMarkets = closedMarkets.filter(m => {
208
+ const k = `${m.market_id}_${m.outcome}`;
209
+ if (seen.has(k))
210
+ return false;
211
+ seen.add(k);
212
+ return true;
213
+ });
235
214
  return {
236
- content: [{ type: 'text', text: JSON.stringify(positions, null, 2) }]
215
+ content: [{ type: 'text', text: JSON.stringify({ positions, closed_markets: uniqueClosedMarkets }, null, 2) }]
237
216
  };
238
217
  }
239
218
  if (name === 'close_position') {
240
219
  const a = args;
241
220
  const raw = await redis.lrange(`${REDIS_PREFIX}:positions`, 0, -1);
221
+ const closedIdsForClose = new Set(await redis.smembers(`${REDIS_PREFIX}:closed_ids`));
242
222
  const positions = raw.map(r => {
243
223
  try {
244
224
  return JSON.parse(r);
@@ -247,15 +227,24 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
247
227
  return null;
248
228
  }
249
229
  }).filter(Boolean);
250
- // Find matching open position
251
- const match = positions.find((p) => p.market_id === a.market_id &&
252
- p.outcome?.toLowerCase() === String(a.outcome).toLowerCase() &&
253
- p.status !== 'closed');
230
+ // Find matching open position — must check BOTH status field AND closedIds set
231
+ // (hard stops add to closedIds but cannot update the list entry's status field)
232
+ const match = positions.find((p) => {
233
+ if (p.market_id !== a.market_id)
234
+ return false;
235
+ if (p.outcome?.toLowerCase() !== String(a.outcome).toLowerCase())
236
+ return false;
237
+ if (p.status === 'closed')
238
+ return false;
239
+ return !closedIdsForClose.has(`${p.market_id}_${p.outcome}_${p.ts}`);
240
+ });
254
241
  if (!match) {
255
242
  return { content: [{ type: 'text', text: `No open position found for market ${a.market_id} outcome ${a.outcome}` }] };
256
243
  }
257
- const shares = match.size_usdc / match.price;
258
- const pnl = shares * a.exit_price - match.size_usdc;
244
+ const pnl = (0, paper_trade_1.computePnl)(match, a.exit_price);
245
+ if (pnl === null) {
246
+ return { content: [{ type: 'text', text: `Error: Cannot compute P&L — entry price is zero or invalid for position ${a.market_id} ${a.outcome}` }] };
247
+ }
259
248
  const closed = {
260
249
  ...match,
261
250
  status: 'closed',
@@ -52,26 +52,38 @@ class PositionMonitor {
52
52
  return;
53
53
  }
54
54
  // Deduplicate market fetches keyed by marketId_outcome
55
+ // Throttle to 5 concurrent fetches to avoid Gamma API rate limiting
55
56
  const marketPriceCache = new Map();
56
57
  const uniqueKeys = [...new Set(toCheck.map(p => `${p.market_id}|${p.outcome}`))];
57
- await Promise.all(uniqueKeys.map(async (key) => {
58
- const sep = key.indexOf('|');
59
- const marketId = key.slice(0, sep);
60
- const outcome = key.slice(sep + 1);
61
- const result = await this.fetchCurrentPrice(marketId, outcome).catch(() => null);
62
- marketPriceCache.set(key, result);
63
- }));
58
+ const BATCH_SIZE = 5;
59
+ for (let i = 0; i < uniqueKeys.length; i += BATCH_SIZE) {
60
+ const batch = uniqueKeys.slice(i, i + BATCH_SIZE);
61
+ await Promise.all(batch.map(async (key) => {
62
+ const sep = key.indexOf('|');
63
+ const marketId = key.slice(0, sep);
64
+ const outcome = key.slice(sep + 1);
65
+ const result = await this.fetchCurrentPrice(marketId, outcome).catch(() => null);
66
+ marketPriceCache.set(key, result);
67
+ }));
68
+ if (i + BATCH_SIZE < uniqueKeys.length) {
69
+ await new Promise(resolve => setTimeout(resolve, 200));
70
+ }
71
+ }
64
72
  // Build review candidates with current price data for Claude
65
73
  const reviewCandidates = [];
66
74
  for (const pos of toCheck) {
67
75
  const currentPrice = marketPriceCache.get(`${pos.market_id}|${pos.outcome}`) ?? null;
68
- if (!currentPrice)
76
+ if (!currentPrice) {
77
+ console.warn(`[position-monitor] WARNING: no price data for ${pos.market_id} ${pos.outcome} — skipping review (market may be resolved or delisted)`);
69
78
  continue;
79
+ }
70
80
  const entryPrice = pos.price;
71
81
  const gain = (currentPrice.price - entryPrice) / entryPrice;
72
82
  const hoursToEnd = (new Date(currentPrice.endDate).getTime() - Date.now()) / (1000 * 60 * 60);
73
- // Only send to Claude if something notable: moved >5% either way, or <72h to expiry
74
- if (Math.abs(gain) >= 0.05 || hoursToEnd <= 72) {
83
+ const hoursHeld = (Date.now() - pos.ts) / (1000 * 60 * 60);
84
+ const isStale = hoursHeld >= 168 && Math.abs(gain) < 0.05; // held >7d with <5% movement
85
+ // Send to Claude if: moved >5% either way, <72h to expiry, or stale (>7d no movement)
86
+ if (Math.abs(gain) >= 0.05 || hoursToEnd <= 72 || isStale) {
75
87
  reviewCandidates.push({
76
88
  market_id: pos.market_id,
77
89
  market_question: pos.market_question,
@@ -127,6 +139,10 @@ class PositionMonitor {
127
139
  const closedIds = await this.redis.smembers(`${prefix}:closed_ids`);
128
140
  if (closedIds.includes(posKey))
129
141
  return;
142
+ if (!match.price || match.price <= 0) {
143
+ console.error(`[position-monitor] HARD STOP skipped for "${String(match.market_question).slice(0, 50)}" — entry price is zero/invalid`);
144
+ return;
145
+ }
130
146
  const shares = match.size_usdc / match.price;
131
147
  const pnl = shares * candidate.current_price - match.size_usdc;
132
148
  const closed = { ...match, status: 'closed', exit_price: candidate.current_price, pnl, closed_at: Date.now(), reason: 'stop_loss' };
@@ -141,7 +157,9 @@ class PositionMonitor {
141
157
  }
142
158
  async fetchCurrentPrice(marketId, outcome) {
143
159
  try {
144
- const res = await fetch(`https://gamma-api.polymarket.com/markets/${marketId}`);
160
+ const res = await fetch(`https://gamma-api.polymarket.com/markets/${marketId}`, {
161
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; polly-gamba/1.0)' }
162
+ });
145
163
  if (!res.ok)
146
164
  return null;
147
165
  const data = await res.json();
@@ -0,0 +1 @@
1
+ export {};