polly-gamba 1.0.18 → 1.0.20

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.
@@ -254,7 +254,7 @@ For EVERY market above: if price differs from fair probability by >8%, place a t
254
254
  await this.redis.ltrim(`${prefix}:reviews`, 0, 9999);
255
255
  const positionLines = positions.map(p => {
256
256
  const gainPct = (p.gain_pct * 100).toFixed(1);
257
- const stopLossWarning = p.gain_pct <= -0.35 ? ' ⚠️ HARD STOP ZONE (-35%)' : '';
257
+ const stopLossWarning = p.gain_pct <= -0.35 ? ' ⚠️ HARD STOP (-35%+ loss — Rule 2/4 MUST apply)' : '';
258
258
  return `### ${p.market_id} ${p.market_question}
259
259
  - Outcome: ${p.outcome} | Entry: ${p.entry_price} | Now: ${p.current_price} | Gain: ${gainPct}%${stopLossWarning}
260
260
  - Size: $${p.size_usdc} | Hours to expiry: ${p.hours_to_expiry.toFixed(1)}h
@@ -267,12 +267,14 @@ Review each open position. For each position: check whether the exit trigger con
267
267
 
268
268
  ${positionLines}
269
269
 
270
- CLOSE RULES (call close_position for any that apply):
271
- 1. Exit trigger condition has been met (e.g. specific price level hit, event occurred)
272
- 2. Position is down >35% AND the fundamental thesis has materially weakened or been disproven
273
- 3. Market has moved to extreme (>90% or <10%) suggesting resolution is near and against us
270
+ CLOSE RULES apply ALL that match, no thesis override allowed:
271
+ 1. Exit trigger condition has been met (e.g. specific price level hit, event occurred). Apply literally.
272
+ 2. Position is down >35% AND the fundamental thesis has materially weakened or been disproven (market structure changed, key assumption invalidated).
273
+ 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.
274
+ 4. Position is down >50% — HARD CLOSE regardless of thesis. Cut losses. No exceptions.
275
+ 5. You have 2+ open positions in the SAME market and both are losing — close the one with the higher percentage loss to reduce duplicate exposure.
274
276
 
275
- HOLD RULES: If thesis still valid and exit trigger NOT triggered, do nothing (no output needed).`;
277
+ HOLD RULES: If NONE of the close rules apply and exit trigger NOT triggered, do nothing (no output needed).`;
276
278
  const msg = JSON.stringify({
277
279
  type: 'user',
278
280
  message: { role: 'user', content: prompt }
package/dist/index.js CHANGED
@@ -43,6 +43,26 @@ const os = __importStar(require("os"));
43
43
  const fs_1 = require("fs");
44
44
  const CWD = process.env.POLLY_CWD || path.join(os.homedir(), 'polly-gamba');
45
45
  const EXPIRING_CWD = process.env.POLLY_EXPIRING_CWD || `${CWD}-expiring`;
46
+ function ensureMainCwdSettings(cwd) {
47
+ const settingsPath = path.join(cwd, 'settings.json');
48
+ if (!(0, fs_1.existsSync)(settingsPath)) {
49
+ const mcpServerPath = path.join(cwd, 'dist', 'mcp-server.js');
50
+ const settings = {
51
+ mcpServers: {
52
+ 'polly-paper': {
53
+ command: 'node',
54
+ args: [mcpServerPath],
55
+ env: {
56
+ REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379',
57
+ POLLY_REDIS_PREFIX: 'polly',
58
+ },
59
+ },
60
+ },
61
+ };
62
+ (0, fs_1.writeFileSync)(settingsPath, JSON.stringify(settings, null, 2));
63
+ console.log(`[main] created settings.json at ${settingsPath}`);
64
+ }
65
+ }
46
66
  function ensureExpiringCwd(expiringCwd) {
47
67
  if (!(0, fs_1.existsSync)(expiringCwd)) {
48
68
  (0, fs_1.mkdirSync)(expiringCwd, { recursive: true });
@@ -69,6 +89,7 @@ function ensureExpiringCwd(expiringCwd) {
69
89
  async function main() {
70
90
  console.log('[polly-gamba] Starting paper trading service');
71
91
  console.log(`[polly-gamba] Claude cwd: ${CWD}`);
92
+ ensureMainCwdSettings(CWD);
72
93
  ensureExpiringCwd(EXPIRING_CWD);
73
94
  console.log(`[polly-gamba] Expiring trader cwd: ${EXPIRING_CWD}`);
74
95
  const anthropicTrader = new claude_trader_1.ClaudeTrader({ cwd: CWD, redisPrefix: 'polly', label: 'anthropic' });
@@ -23,6 +23,7 @@ export declare class PositionMonitor {
23
23
  constructor(redisUrl: string, prefix: string, intervalMs?: number);
24
24
  start(): void;
25
25
  checkPositions(): Promise<void>;
26
+ private executeHardStop;
26
27
  private fetchCurrentPrice;
27
28
  stop(): void;
28
29
  }
@@ -87,15 +87,58 @@ class PositionMonitor {
87
87
  });
88
88
  }
89
89
  }
90
- console.log(`[position-monitor] checked=${toCheck.length} review_candidates=${reviewCandidates.length} (moved>5% or <72h expiry)`);
91
- // Pass to Claude for judgment-based exits with web search
92
- if (reviewCandidates.length > 0 && this.trader) {
93
- await this.trader.onPositionReview(reviewCandidates);
90
+ // Execute programmatic hard stops BEFORE sending to Claude
91
+ // These are deterministic rules no judgment needed
92
+ const hardStopClosed = [];
93
+ for (const candidate of reviewCandidates) {
94
+ const isPriceBelowFloor = candidate.current_price <= 0.10;
95
+ const isLargeEnoughLoss = candidate.gain_pct <= -0.50;
96
+ if (isPriceBelowFloor || isLargeEnoughLoss) {
97
+ await this.executeHardStop(candidate, isPriceBelowFloor ? 'price_below_floor' : 'stop_loss_50pct');
98
+ hardStopClosed.push(candidate.market_id);
99
+ }
100
+ }
101
+ const remaining = reviewCandidates.filter(c => !hardStopClosed.includes(c.market_id));
102
+ console.log(`[position-monitor] checked=${toCheck.length} review_candidates=${reviewCandidates.length} hard_stops=${hardStopClosed.length} (moved>5% or <72h expiry)`);
103
+ // Pass remaining to Claude for judgment-based exits
104
+ if (remaining.length > 0 && this.trader) {
105
+ await this.trader.onPositionReview(remaining);
94
106
  }
95
- else if (reviewCandidates.length > 0) {
107
+ else if (remaining.length > 0) {
96
108
  console.log('[position-monitor] no trader set — skipping Claude review');
97
109
  }
98
110
  }
111
+ async executeHardStop(candidate, reason) {
112
+ const prefix = this.prefix;
113
+ const raw = await this.redis.lrange(`${prefix}:positions`, 0, -1);
114
+ const positions = raw.map(r => { try {
115
+ return JSON.parse(r);
116
+ }
117
+ catch {
118
+ return null;
119
+ } }).filter(Boolean);
120
+ const match = positions.find((p) => p.market_id === candidate.market_id &&
121
+ String(p.outcome).toLowerCase() === String(candidate.outcome).toLowerCase() &&
122
+ p.ts === candidate.ts &&
123
+ p.status !== 'closed');
124
+ if (!match)
125
+ return;
126
+ const posKey = `${match.market_id}_${match.outcome}_${match.ts}`;
127
+ const closedIds = await this.redis.smembers(`${prefix}:closed_ids`);
128
+ if (closedIds.includes(posKey))
129
+ return;
130
+ const shares = match.size_usdc / match.price;
131
+ const pnl = shares * candidate.current_price - match.size_usdc;
132
+ const closed = { ...match, status: 'closed', exit_price: candidate.current_price, pnl, closed_at: Date.now(), reason: 'stop_loss' };
133
+ await this.redis.sadd(`${prefix}:closed_ids`, posKey);
134
+ await this.redis.lpush(`${prefix}:closed_positions`, JSON.stringify(closed));
135
+ await this.redis.ltrim(`${prefix}:closed_positions`, 0, 9999);
136
+ await this.redis.lpush(`${prefix}:log`, JSON.stringify({ type: 'position_closed', data: closed, ts: Date.now(), trigger: reason }));
137
+ await this.redis.ltrim(`${prefix}:log`, 0, 9999);
138
+ const pnlStr = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`;
139
+ const gainStr = (candidate.gain_pct * 100).toFixed(1);
140
+ console.log(`[position-monitor] HARD STOP [${reason}] "${String(match.market_question).slice(0, 50)}" ${match.outcome} entry=${match.price} exit=${candidate.current_price} gain=${gainStr}% pnl=${pnlStr}`);
141
+ }
99
142
  async fetchCurrentPrice(marketId, outcome) {
100
143
  try {
101
144
  const res = await fetch(`https://gamma-api.polymarket.com/markets/${marketId}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polly-gamba",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "Coinbase price signal → Claude brain → Polymarket CLOB execution",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
package/service.log CHANGED
@@ -71,3 +71,109 @@ npm warn deprecated prebuild-install@7.1.3: No longer maintained. Please contact
71
71
  /Users/feral/.npm/_npx/fe561c72834ff84d/node_modules/.bin/polly-gamba: line 1: use strict: command not found
72
72
  /Users/feral/.npm/_npx/fe561c72834ff84d/node_modules/.bin/polly-gamba: line 2: syntax error near unexpected token `('
73
73
  /Users/feral/.npm/_npx/fe561c72834ff84d/node_modules/.bin/polly-gamba: line 2: `var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {'
74
+ /Users/feral/.npm/_npx/fe561c72834ff84d/node_modules/.bin/polly-gamba: line 1: use strict: command not found
75
+ /Users/feral/.npm/_npx/fe561c72834ff84d/node_modules/.bin/polly-gamba: line 2: syntax error near unexpected token `('
76
+ /Users/feral/.npm/_npx/fe561c72834ff84d/node_modules/.bin/polly-gamba: line 2: `var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {'
77
+ npm warn deprecated prebuild-install@7.1.3: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
78
+ [polly-gamba] Starting paper trading service
79
+ [polly-gamba] Claude cwd: /Users/feral/polly-gamba
80
+ [polly-gamba] Expiring trader cwd: /Users/feral/polly-gamba-expiring
81
+ [coinbase-ws] Connecting to wss://ws-feed.exchange.coinbase.com
82
+ [polly-gamba] Listening for BTC/ETH price signals (threshold: 0.5% in 60s)...
83
+ [scan] fetching high-quality markets (vol24h>$50k, liq>$50k, price 0.10-0.90)
84
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
85
+ [expiring] fetching expiring markets (closing within 72h, vol24h>$10k, liq>$10k, price 0.05-0.95)
86
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
87
+ [gamma] Loaded 496 markets (filtered from 500)
88
+ [expiring] 0 expiring markets for review
89
+ [expiring] no expiring markets found this cycle
90
+ [coinbase-ws] Connected
91
+ [gamma] Loaded 496 markets (filtered from 500)
92
+ [scan] 16 high-quality markets for autonomous review
93
+ [claude-trader:anthropic] ready
94
+ [claude-trader:ollama] ready
95
+ [claude-trader:expiring] ready
96
+ [position-monitor] checked=25 review_candidates=8 (moved>5% or <72h expiry)
97
+ [scan] fetching high-quality markets (vol24h>$50k, liq>$50k, price 0.10-0.90)
98
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
99
+ [expiring] fetching expiring markets (closing within 72h, vol24h>$10k, liq>$10k, price 0.05-0.95)
100
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
101
+ [position-monitor] checked=25 review_candidates=8 (moved>5% or <72h expiry)
102
+ [gamma] Loaded 496 markets (filtered from 500)
103
+ [expiring] 0 expiring markets for review
104
+ [expiring] no expiring markets found this cycle
105
+ [gamma] Loaded 496 markets (filtered from 500)
106
+ [scan] 16 high-quality markets for autonomous review
107
+ [scan] fetching high-quality markets (vol24h>$50k, liq>$50k, price 0.10-0.90)
108
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
109
+ [expiring] fetching expiring markets (closing within 72h, vol24h>$10k, liq>$10k, price 0.05-0.95)
110
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
111
+ [position-monitor] checked=25 review_candidates=7 (moved>5% or <72h expiry)
112
+ [gamma] Loaded 496 markets (filtered from 500)
113
+ [expiring] 0 expiring markets for review
114
+ [expiring] no expiring markets found this cycle
115
+ [gamma] Loaded 496 markets (filtered from 500)
116
+ [scan] 16 high-quality markets for autonomous review
117
+ [scan] fetching high-quality markets (vol24h>$50k, liq>$50k, price 0.10-0.90)
118
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
119
+ [position-monitor] checked=25 review_candidates=8 (moved>5% or <72h expiry)
120
+ [gamma] Loaded 496 markets (filtered from 500)
121
+ [scan] 16 high-quality markets for autonomous review
122
+ [expiring] fetching expiring markets (closing within 72h, vol24h>$10k, liq>$10k, price 0.05-0.95)
123
+ [expiring] 0 expiring markets for review
124
+ [expiring] no expiring markets found this cycle
125
+ [polly-gamba] Starting paper trading service
126
+ [polly-gamba] Claude cwd: /Users/feral/polly-gamba
127
+ [polly-gamba] Expiring trader cwd: /Users/feral/polly-gamba-expiring
128
+ [coinbase-ws] Connecting to wss://ws-feed.exchange.coinbase.com
129
+ [polly-gamba] Listening for BTC/ETH price signals (threshold: 0.5% in 60s)...
130
+ [scan] fetching high-quality markets (vol24h>$50k, liq>$50k, price 0.10-0.90)
131
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
132
+ [expiring] fetching expiring markets (closing within 72h, vol24h>$10k, liq>$10k, price 0.05-0.95)
133
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
134
+ [coinbase-ws] Connected
135
+ [position-monitor] checked=25 review_candidates=8 (moved>5% or <72h expiry)
136
+ [gamma] Loaded 496 markets (filtered from 500)
137
+ [scan] 16 high-quality markets for autonomous review
138
+ [gamma] Loaded 496 markets (filtered from 500)
139
+ [expiring] 0 expiring markets for review
140
+ [expiring] no expiring markets found this cycle
141
+ [claude-trader:anthropic] ready
142
+ [claude-trader:ollama] ready
143
+ [claude-trader:expiring] ready
144
+ [scan] fetching high-quality markets (vol24h>$50k, liq>$50k, price 0.10-0.90)
145
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
146
+ [expiring] fetching expiring markets (closing within 72h, vol24h>$10k, liq>$10k, price 0.05-0.95)
147
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
148
+ [position-monitor] checked=25 review_candidates=8 (moved>5% or <72h expiry)
149
+ [gamma] Loaded 496 markets (filtered from 500)
150
+ [scan] 16 high-quality markets for autonomous review
151
+ [gamma] Loaded 496 markets (filtered from 500)
152
+ [expiring] 0 expiring markets for review
153
+ [expiring] no expiring markets found this cycle
154
+ [scan] fetching high-quality markets (vol24h>$50k, liq>$50k, price 0.10-0.90)
155
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
156
+ [position-monitor] checked=25 review_candidates=8 (moved>5% or <72h expiry)
157
+ [gamma] Loaded 496 markets (filtered from 500)
158
+ [scan] 16 high-quality markets for autonomous review
159
+ [expiring] fetching expiring markets (closing within 72h, vol24h>$10k, liq>$10k, price 0.05-0.95)
160
+ [expiring] 0 expiring markets for review
161
+ [expiring] no expiring markets found this cycle
162
+ [position-monitor] checked=25 review_candidates=8 (moved>5% or <72h expiry)
163
+ [scan] fetching high-quality markets (vol24h>$50k, liq>$50k, price 0.10-0.90)
164
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
165
+ [gamma] Loaded 496 markets (filtered from 500)
166
+ [scan] 16 high-quality markets for autonomous review
167
+ [expiring] fetching expiring markets (closing within 72h, vol24h>$10k, liq>$10k, price 0.05-0.95)
168
+ [expiring] 0 expiring markets for review
169
+ [expiring] no expiring markets found this cycle
170
+ [scan] fetching high-quality markets (vol24h>$50k, liq>$50k, price 0.10-0.90)
171
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
172
+ [expiring] fetching expiring markets (closing within 72h, vol24h>$10k, liq>$10k, price 0.05-0.95)
173
+ [gamma] Fetching active markets from https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=500
174
+ [position-monitor] checked=24 review_candidates=7 (moved>5% or <72h expiry)
175
+ [gamma] Loaded 496 markets (filtered from 500)
176
+ [scan] 16 high-quality markets for autonomous review
177
+ [gamma] Loaded 496 markets (filtered from 500)
178
+ [expiring] 0 expiring markets for review
179
+ [expiring] no expiring markets found this cycle
package/settings.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "mcpServers": {
3
+ "polly-paper": {
4
+ "command": "node",
5
+ "args": ["/Users/feral/polly-gamba/dist/mcp-server.js"],
6
+ "env": {
7
+ "REDIS_URL": "redis://localhost:6379",
8
+ "POLLY_REDIS_PREFIX": "polly"
9
+ }
10
+ }
11
+ }
12
+ }
@@ -304,7 +304,7 @@ For EVERY market above: if price differs from fair probability by >8%, place a t
304
304
 
305
305
  const positionLines = positions.map(p => {
306
306
  const gainPct = (p.gain_pct * 100).toFixed(1)
307
- const stopLossWarning = p.gain_pct <= -0.35 ? ' ⚠️ HARD STOP ZONE (-35%)' : ''
307
+ const stopLossWarning = p.gain_pct <= -0.35 ? ' ⚠️ HARD STOP (-35%+ loss — Rule 2/4 MUST apply)' : ''
308
308
  return `### ${p.market_id} ${p.market_question}
309
309
  - Outcome: ${p.outcome} | Entry: ${p.entry_price} | Now: ${p.current_price} | Gain: ${gainPct}%${stopLossWarning}
310
310
  - Size: $${p.size_usdc} | Hours to expiry: ${p.hours_to_expiry.toFixed(1)}h
@@ -318,12 +318,14 @@ Review each open position. For each position: check whether the exit trigger con
318
318
 
319
319
  ${positionLines}
320
320
 
321
- CLOSE RULES (call close_position for any that apply):
322
- 1. Exit trigger condition has been met (e.g. specific price level hit, event occurred)
323
- 2. Position is down >35% AND the fundamental thesis has materially weakened or been disproven
324
- 3. Market has moved to extreme (>90% or <10%) suggesting resolution is near and against us
321
+ CLOSE RULES apply ALL that match, no thesis override allowed:
322
+ 1. Exit trigger condition has been met (e.g. specific price level hit, event occurred). Apply literally.
323
+ 2. Position is down >35% AND the fundamental thesis has materially weakened or been disproven (market structure changed, key assumption invalidated).
324
+ 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.
325
+ 4. Position is down >50% — HARD CLOSE regardless of thesis. Cut losses. No exceptions.
326
+ 5. You have 2+ open positions in the SAME market and both are losing — close the one with the higher percentage loss to reduce duplicate exposure.
325
327
 
326
- HOLD RULES: If thesis still valid and exit trigger NOT triggered, do nothing (no output needed).`
328
+ HOLD RULES: If NONE of the close rules apply and exit trigger NOT triggered, do nothing (no output needed).`
327
329
 
328
330
  const msg = JSON.stringify({
329
331
  type: 'user',
package/src/index.ts CHANGED
@@ -9,6 +9,27 @@ import { mkdirSync, writeFileSync, existsSync } from 'fs'
9
9
  const CWD = process.env.POLLY_CWD || path.join(os.homedir(), 'polly-gamba')
10
10
  const EXPIRING_CWD = process.env.POLLY_EXPIRING_CWD || `${CWD}-expiring`
11
11
 
12
+ function ensureMainCwdSettings(cwd: string): void {
13
+ const settingsPath = path.join(cwd, 'settings.json')
14
+ if (!existsSync(settingsPath)) {
15
+ const mcpServerPath = path.join(cwd, 'dist', 'mcp-server.js')
16
+ const settings = {
17
+ mcpServers: {
18
+ 'polly-paper': {
19
+ command: 'node',
20
+ args: [mcpServerPath],
21
+ env: {
22
+ REDIS_URL: process.env.REDIS_URL || 'redis://localhost:6379',
23
+ POLLY_REDIS_PREFIX: 'polly',
24
+ },
25
+ },
26
+ },
27
+ }
28
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2))
29
+ console.log(`[main] created settings.json at ${settingsPath}`)
30
+ }
31
+ }
32
+
12
33
  function ensureExpiringCwd(expiringCwd: string): void {
13
34
  if (!existsSync(expiringCwd)) {
14
35
  mkdirSync(expiringCwd, { recursive: true })
@@ -37,6 +58,7 @@ async function main(): Promise<void> {
37
58
  console.log('[polly-gamba] Starting paper trading service')
38
59
  console.log(`[polly-gamba] Claude cwd: ${CWD}`)
39
60
 
61
+ ensureMainCwdSettings(CWD)
40
62
  ensureExpiringCwd(EXPIRING_CWD)
41
63
  console.log(`[polly-gamba] Expiring trader cwd: ${EXPIRING_CWD}`)
42
64
 
@@ -124,18 +124,72 @@ export class PositionMonitor {
124
124
  }
125
125
  }
126
126
 
127
+ // Execute programmatic hard stops BEFORE sending to Claude
128
+ // These are deterministic rules — no judgment needed
129
+ const hardStopClosed: string[] = []
130
+ for (const candidate of reviewCandidates) {
131
+ const isPriceBelowFloor = candidate.current_price <= 0.10
132
+ const isLargeEnoughLoss = candidate.gain_pct <= -0.50
133
+ if (isPriceBelowFloor || isLargeEnoughLoss) {
134
+ await this.executeHardStop(candidate, isPriceBelowFloor ? 'price_below_floor' : 'stop_loss_50pct')
135
+ hardStopClosed.push(candidate.market_id)
136
+ }
137
+ }
138
+
139
+ const remaining = reviewCandidates.filter(c => !hardStopClosed.includes(c.market_id))
140
+
127
141
  console.log(
128
- `[position-monitor] checked=${toCheck.length} review_candidates=${reviewCandidates.length} (moved>5% or <72h expiry)`
142
+ `[position-monitor] checked=${toCheck.length} review_candidates=${reviewCandidates.length} hard_stops=${hardStopClosed.length} (moved>5% or <72h expiry)`
129
143
  )
130
144
 
131
- // Pass to Claude for judgment-based exits with web search
132
- if (reviewCandidates.length > 0 && this.trader) {
133
- await this.trader.onPositionReview(reviewCandidates)
134
- } else if (reviewCandidates.length > 0) {
145
+ // Pass remaining to Claude for judgment-based exits
146
+ if (remaining.length > 0 && this.trader) {
147
+ await this.trader.onPositionReview(remaining)
148
+ } else if (remaining.length > 0) {
135
149
  console.log('[position-monitor] no trader set — skipping Claude review')
136
150
  }
137
151
  }
138
152
 
153
+ private async executeHardStop(candidate: {
154
+ market_id: string
155
+ market_question: string
156
+ outcome: string
157
+ entry_price: number
158
+ current_price: number
159
+ gain_pct: number
160
+ size_usdc: number
161
+ ts: number
162
+ }, reason: string): Promise<void> {
163
+ const prefix = this.prefix
164
+ const raw = await this.redis.lrange(`${prefix}:positions`, 0, -1)
165
+ const positions = raw.map(r => { try { return JSON.parse(r) } catch { return null } }).filter(Boolean)
166
+ const match = positions.find((p: any) =>
167
+ p.market_id === candidate.market_id &&
168
+ String(p.outcome).toLowerCase() === String(candidate.outcome).toLowerCase() &&
169
+ p.ts === candidate.ts &&
170
+ p.status !== 'closed'
171
+ )
172
+ if (!match) return
173
+
174
+ const posKey = `${match.market_id}_${match.outcome}_${match.ts}`
175
+ const closedIds = await this.redis.smembers(`${prefix}:closed_ids`)
176
+ if (closedIds.includes(posKey)) return
177
+
178
+ const shares = match.size_usdc / match.price
179
+ const pnl = shares * candidate.current_price - match.size_usdc
180
+ const closed = { ...match, status: 'closed', exit_price: candidate.current_price, pnl, closed_at: Date.now(), reason: 'stop_loss' }
181
+
182
+ await this.redis.sadd(`${prefix}:closed_ids`, posKey)
183
+ await this.redis.lpush(`${prefix}:closed_positions`, JSON.stringify(closed))
184
+ await this.redis.ltrim(`${prefix}:closed_positions`, 0, 9999)
185
+ await this.redis.lpush(`${prefix}:log`, JSON.stringify({ type: 'position_closed', data: closed, ts: Date.now(), trigger: reason }))
186
+ await this.redis.ltrim(`${prefix}:log`, 0, 9999)
187
+
188
+ const pnlStr = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`
189
+ const gainStr = (candidate.gain_pct * 100).toFixed(1)
190
+ console.log(`[position-monitor] HARD STOP [${reason}] "${String(match.market_question).slice(0, 50)}" ${match.outcome} entry=${match.price} exit=${candidate.current_price} gain=${gainStr}% pnl=${pnlStr}`)
191
+ }
192
+
139
193
  private async fetchCurrentPrice(marketId: string, outcome: string): Promise<MarketPrice | null> {
140
194
  try {
141
195
  const res = await fetch(`https://gamma-api.polymarket.com/markets/${marketId}`)