polly-gamba 1.0.32 → 1.0.37
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 +5 -2
- package/dist/lib/paper-trade.js +1 -1
- package/dist/mcp-server.js +7 -5
- package/dist/position-monitor.js +41 -1
- package/dist/test/stress.js +24 -0
- package/package.json +1 -1
- package/service.log +1776 -0
- package/src/claude-trader.ts +5 -2
- package/src/lib/paper-trade.ts +1 -1
- package/src/mcp-server.ts +8 -5
- package/src/position-monitor.ts +41 -1
- package/src/test/stress.ts +33 -0
package/dist/claude-trader.js
CHANGED
|
@@ -115,7 +115,7 @@ RULES:
|
|
|
115
115
|
|
|
116
116
|
## POSITION DISCIPLINE:
|
|
117
117
|
- Max $100 per market (20% of $500 budget). The MCP enforces this — don't fight it.
|
|
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
|
+
- 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. CRITICAL: Sportsbook odds movement is NOT a valid new catalyst — if your original thesis cited sportsbook lines and the proposed add-on also cites updated sportsbook odds, that is the same bet twice. Only factual real-world events qualify (injury, official announcement, standings change, political development). Sportsbook-only re-entry is prohibited even with a large price discount.
|
|
119
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."
|
|
120
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.`
|
|
121
121
|
}
|
|
@@ -311,8 +311,11 @@ CLOSE RULES — apply ALL that match, no thesis override allowed:
|
|
|
311
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
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.
|
|
313
313
|
8. CONCENTRATION VIOLATION: Count open positions by underlying theme (the single real-world event that resolves all of them). Examples: "Will Jesus return before GTA VI", "Will BTC hit $1M before GTA VI", "Will China invade Taiwan before GTA VI" all share the theme "GTA VI release date". Similarly "Will Spurs win NBA Finals", "Will OKC win NBA Finals", "Will Celtics win NBA Finals" all share the theme "2026 NBA Finals". If you have 3+ positions in the same theme, close the one with the smallest current edge (current price closest to 50/50 or furthest from your target) to reduce to 2 positions. Apply this BEFORE checking other rules.
|
|
314
|
+
9. SPORTSBOOK THESIS DECAY: If the original reasoning cited a sportsbook cross-reference (e.g. "sportsbooks imply X%, Polymarket shows Y%") AND the position is currently down >20%: estimate current sportsbook consensus from your world knowledge. If the original sportsbook-Polymarket gap has narrowed by >60% (e.g. original 15pp gap is now ≤6pp), the arbitrage thesis is exhausted — CLOSE with reason "thesis_decayed". Do not continue holding through hard stops when the original edge is structurally gone.
|
|
314
315
|
|
|
315
|
-
HOLD RULES: If NONE of the close rules apply and exit trigger NOT triggered, do nothing (no output needed)
|
|
316
|
+
HOLD RULES: If NONE of the close rules apply and exit trigger NOT triggered, do nothing (no output needed).
|
|
317
|
+
|
|
318
|
+
NOTE: Positions with no price data are automatically freed by the system after 24h (likely resolved or delisted). You will only receive positions with valid current prices.`;
|
|
316
319
|
const msg = JSON.stringify({
|
|
317
320
|
type: 'user',
|
|
318
321
|
message: { role: 'user', content: prompt }
|
package/dist/lib/paper-trade.js
CHANGED
|
@@ -28,7 +28,7 @@ function validatePlaceOrder(params, openPositions) {
|
|
|
28
28
|
params.price > 1) {
|
|
29
29
|
return `Error: price must be in range (0, 1]. Got: ${params.price}`;
|
|
30
30
|
}
|
|
31
|
-
const sameMarketPositions = openPositions.filter(p => p.market_id === params.market_id);
|
|
31
|
+
const sameMarketPositions = openPositions.filter(p => String(p.market_id) === String(params.market_id));
|
|
32
32
|
// Check 1: Existing position for same market requires new_catalyst
|
|
33
33
|
if (sameMarketPositions.length > 0 && !params.new_catalyst) {
|
|
34
34
|
return `Error: Position already exists for this market. Provide new_catalyst (specific new information from last 24h) to add.`;
|
package/dist/mcp-server.js
CHANGED
|
@@ -126,19 +126,21 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
|
|
|
126
126
|
return false;
|
|
127
127
|
return !closedIds.has(`${p.market_id}_${p.outcome}_${p.ts}`);
|
|
128
128
|
});
|
|
129
|
+
// Normalize market_id to string to prevent type-mismatch bypasses
|
|
130
|
+
const marketId = String(a.market_id);
|
|
129
131
|
// Block re-entry into previously closed market+outcome combos
|
|
130
|
-
const marketOutcomePrefix = `${
|
|
132
|
+
const marketOutcomePrefix = `${marketId}_${a.outcome}_`;
|
|
131
133
|
const wasClosedBefore = Array.from(closedIds).some(id => id.startsWith(marketOutcomePrefix));
|
|
132
134
|
if (wasClosedBefore) {
|
|
133
|
-
return { content: [{ type: 'text', text: `Error: Re-entry blocked. This market+outcome (${
|
|
135
|
+
return { content: [{ type: 'text', text: `Error: Re-entry blocked. This market+outcome (${marketId} ${a.outcome}) was previously closed. Polly-gamba does not re-enter previously exited positions to prevent repeated losses on the same bet.` }] };
|
|
134
136
|
}
|
|
135
137
|
// Validate using extracted business logic (includes input validation + budget checks)
|
|
136
|
-
const validationError = (0, paper_trade_1.validatePlaceOrder)({ market_id:
|
|
138
|
+
const validationError = (0, paper_trade_1.validatePlaceOrder)({ market_id: marketId, outcome: a.outcome, size_usdc: a.size_usdc, price: a.price, new_catalyst: a.new_catalyst }, openPositions);
|
|
137
139
|
if (validationError) {
|
|
138
140
|
return { content: [{ type: 'text', text: validationError }] };
|
|
139
141
|
}
|
|
140
142
|
const position = {
|
|
141
|
-
market_id:
|
|
143
|
+
market_id: marketId,
|
|
142
144
|
market_question: a.market_question,
|
|
143
145
|
token_id: a.token_id,
|
|
144
146
|
outcome: a.outcome,
|
|
@@ -230,7 +232,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
|
|
|
230
232
|
// Find matching open position — must check BOTH status field AND closedIds set
|
|
231
233
|
// (hard stops add to closedIds but cannot update the list entry's status field)
|
|
232
234
|
const match = positions.find((p) => {
|
|
233
|
-
if (p.market_id !== a.market_id)
|
|
235
|
+
if (String(p.market_id) !== String(a.market_id))
|
|
234
236
|
return false;
|
|
235
237
|
if (p.outcome?.toLowerCase() !== String(a.outcome).toLowerCase())
|
|
236
238
|
return false;
|
package/dist/position-monitor.js
CHANGED
|
@@ -71,12 +71,34 @@ class PositionMonitor {
|
|
|
71
71
|
}
|
|
72
72
|
// Build review candidates with current price data for Claude
|
|
73
73
|
const reviewCandidates = [];
|
|
74
|
+
// Track positions with no price data that have been CONTINUOUSLY unavailable for >24h — likely resolved/delisted
|
|
75
|
+
// We use a Redis hash to record when price data first went missing per position key.
|
|
76
|
+
// A transient API outage should NOT trigger closure — only sustained unavailability counts.
|
|
77
|
+
const noPriceDataStale = [];
|
|
78
|
+
const noPriceSinceKey = `${this.prefix}:no_price_since`;
|
|
74
79
|
for (const pos of toCheck) {
|
|
75
80
|
const currentPrice = marketPriceCache.get(`${pos.market_id}|${pos.outcome}`) ?? null;
|
|
81
|
+
const posKey = `${pos.market_id}_${pos.outcome}_${pos.ts}`;
|
|
76
82
|
if (!currentPrice) {
|
|
77
|
-
|
|
83
|
+
// Record when we first noticed missing price data for this position
|
|
84
|
+
const existing = await this.redis.hget(noPriceSinceKey, posKey);
|
|
85
|
+
if (!existing) {
|
|
86
|
+
await this.redis.hset(noPriceSinceKey, posKey, String(Date.now()));
|
|
87
|
+
console.warn(`[position-monitor] WARNING: no price data for ${pos.market_id} ${pos.outcome} — first miss recorded, watching (market may be resolved or delisted)`);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
const hoursMissing = (Date.now() - parseInt(existing, 10)) / (1000 * 60 * 60);
|
|
91
|
+
if (hoursMissing >= 24) {
|
|
92
|
+
noPriceDataStale.push(pos);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
console.warn(`[position-monitor] WARNING: no price data for ${pos.market_id} ${pos.outcome} — missing ${hoursMissing.toFixed(1)}h (closes after 24h continuous)`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
78
98
|
continue;
|
|
79
99
|
}
|
|
100
|
+
// Price data available — clear any no-price-since tracker
|
|
101
|
+
await this.redis.hdel(noPriceSinceKey, posKey);
|
|
80
102
|
const entryPrice = pos.price;
|
|
81
103
|
const gain = (currentPrice.price - entryPrice) / entryPrice;
|
|
82
104
|
const hoursToEnd = (new Date(currentPrice.endDate).getTime() - Date.now()) / (1000 * 60 * 60);
|
|
@@ -99,6 +121,24 @@ class PositionMonitor {
|
|
|
99
121
|
});
|
|
100
122
|
}
|
|
101
123
|
}
|
|
124
|
+
// Close positions with no price data held >24h — market is likely resolved or delisted
|
|
125
|
+
for (const pos of noPriceDataStale) {
|
|
126
|
+
const posKey = `${pos.market_id}_${pos.outcome}_${pos.ts}`;
|
|
127
|
+
const alreadyClosed = await this.redis.sismember(`${this.prefix}:closed_ids`, posKey);
|
|
128
|
+
if (alreadyClosed)
|
|
129
|
+
continue;
|
|
130
|
+
const closed = { ...pos, status: 'closed', exit_price: pos.price, pnl: 0, closed_at: Date.now(), reason: 'market_unavailable' };
|
|
131
|
+
await this.redis.sadd(`${this.prefix}:closed_ids`, posKey);
|
|
132
|
+
await this.redis.hdel(noPriceSinceKey, posKey); // clean up tracker
|
|
133
|
+
await this.redis.lpush(`${this.prefix}:closed_positions`, JSON.stringify(closed));
|
|
134
|
+
await this.redis.ltrim(`${this.prefix}:closed_positions`, 0, 9999);
|
|
135
|
+
await this.redis.lpush(`${this.prefix}:log`, JSON.stringify({ type: 'position_closed', data: closed, ts: Date.now(), trigger: 'market_unavailable' }));
|
|
136
|
+
await this.redis.ltrim(`${this.prefix}:log`, 0, 9999);
|
|
137
|
+
console.log(`[position-monitor] CLOSED [market_unavailable] "${String(pos.market_question).slice(0, 50)}" ${pos.outcome} — no price data for >24h continuously, freeing capital`);
|
|
138
|
+
}
|
|
139
|
+
if (noPriceDataStale.length > 0) {
|
|
140
|
+
console.log(`[position-monitor] freed ${noPriceDataStale.length} stale no-price-data positions`);
|
|
141
|
+
}
|
|
102
142
|
// Execute programmatic hard stops BEFORE sending to Claude
|
|
103
143
|
// These are deterministic rules — no judgment needed
|
|
104
144
|
const hardStopClosed = [];
|
package/dist/test/stress.js
CHANGED
|
@@ -376,6 +376,29 @@ async function runConcentrationTests() {
|
|
|
376
376
|
assert(err.includes('Budget cap'), `expected Budget cap error (not market cap), got: ${err}`);
|
|
377
377
|
});
|
|
378
378
|
}
|
|
379
|
+
async function runTypeCoercionTests() {
|
|
380
|
+
console.log('\n## I) Type Coercion — market_id string vs number');
|
|
381
|
+
await test('I1: numeric market_id in params matches string market_id in position', async () => {
|
|
382
|
+
// Simulates Claude passing market_id as a number (e.g., 564166) while
|
|
383
|
+
// Redis stores it as a string ("564166"). Without String() coercion,
|
|
384
|
+
// sameMarketPositions would be empty → 20% discount check bypassed.
|
|
385
|
+
const openPositions = [
|
|
386
|
+
makePosition({ market_id: '564166', outcome: 'YES', price: 0.30, ts: 1 })
|
|
387
|
+
];
|
|
388
|
+
const err = (0, paper_trade_1.validatePlaceOrder)({ market_id: 564166, outcome: 'YES', size_usdc: 10, price: 0.29, new_catalyst: 'New result' }, openPositions);
|
|
389
|
+
// 0.29 > 0.30 * 0.80 = 0.24 → should be blocked (not a 20% discount)
|
|
390
|
+
assert(err !== null, 'expected re-entry blocked: numeric market_id must match string in position');
|
|
391
|
+
assert(err.includes('Re-entry blocked'), `expected Re-entry blocked error, got: ${err}`);
|
|
392
|
+
});
|
|
393
|
+
await test('I2: numeric market_id with sufficient discount is allowed', async () => {
|
|
394
|
+
const openPositions = [
|
|
395
|
+
makePosition({ market_id: '564166', outcome: 'YES', price: 0.30, ts: 1 })
|
|
396
|
+
];
|
|
397
|
+
const err = (0, paper_trade_1.validatePlaceOrder)({ market_id: 564166, outcome: 'YES', size_usdc: 10, price: 0.23, new_catalyst: 'Sportsbook update' }, openPositions);
|
|
398
|
+
// 0.23 <= 0.30 * 0.80 = 0.24 → allowed (>20% discount)
|
|
399
|
+
assert(err === null, `expected success with 20%+ discount even with numeric market_id, got: ${err}`);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
379
402
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
380
403
|
async function main() {
|
|
381
404
|
console.log('\n[stress] polly-gamba paper trading system — stress tests');
|
|
@@ -389,6 +412,7 @@ async function main() {
|
|
|
389
412
|
await runHardStopTests();
|
|
390
413
|
await runDoubleCloseBugTests();
|
|
391
414
|
await runConcentrationTests();
|
|
415
|
+
await runTypeCoercionTests();
|
|
392
416
|
console.log(`\n[stress] ─── Results ─── ${passed} passed, ${failed} failed\n`);
|
|
393
417
|
if (bugReport.length > 0) {
|
|
394
418
|
console.log('[stress] Bug findings:');
|