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.
- package/dist/claude-trader.js +38 -7
- package/dist/lib/paper-trade.d.ts +43 -0
- package/dist/lib/paper-trade.js +71 -0
- package/dist/mcp-server.js +41 -52
- package/dist/position-monitor.js +29 -11
- package/dist/test/stress.d.ts +1 -0
- package/dist/test/stress.js +404 -0
- package/package.json +4 -2
- package/service.log +443 -0
- package/src/claude-trader.ts +38 -7
- package/src/lib/paper-trade.ts +110 -0
- package/src/mcp-server.ts +39 -57
- package/src/position-monitor.ts +31 -11
- package/src/test/stress.ts +541 -0
package/dist/claude-trader.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
package/dist/mcp-server.js
CHANGED
|
@@ -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
|
-
//
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
//
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
p.
|
|
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
|
|
258
|
-
|
|
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',
|
package/dist/position-monitor.js
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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 {};
|