polly-gamba 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -0
- package/package.json +35 -0
- package/src/claude-trader.ts +237 -0
- package/src/index.ts +116 -0
- package/src/markets/clob.ts +127 -0
- package/src/markets/gamma.ts +146 -0
- package/src/mcp/server.ts +199 -0
- package/src/mcp-server.ts +245 -0
- package/src/position-monitor.ts +170 -0
- package/src/signals/coinbase-ws.ts +146 -0
- package/src/test/smoke.ts +93 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import Redis from 'ioredis'
|
|
2
|
+
|
|
3
|
+
export interface Position {
|
|
4
|
+
market_id: string
|
|
5
|
+
market_question: string
|
|
6
|
+
outcome: string
|
|
7
|
+
side: string
|
|
8
|
+
size_usdc: number
|
|
9
|
+
price: number // entry price
|
|
10
|
+
ts: number
|
|
11
|
+
status: 'open' | 'closed'
|
|
12
|
+
exit_price?: number
|
|
13
|
+
pnl?: number
|
|
14
|
+
closed_at?: number
|
|
15
|
+
reason?: 'take_profit' | 'stop_loss' | 'expiry' | 'manual'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MarketPrice {
|
|
19
|
+
price: number
|
|
20
|
+
endDate: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class PositionMonitor {
|
|
24
|
+
private redis: Redis
|
|
25
|
+
private prefix: string
|
|
26
|
+
|
|
27
|
+
constructor(redisUrl: string, prefix: string) {
|
|
28
|
+
this.redis = new Redis(redisUrl, {
|
|
29
|
+
retryStrategy: (times) => Math.min(times * 500, 5000),
|
|
30
|
+
maxRetriesPerRequest: null,
|
|
31
|
+
enableReadyCheck: false,
|
|
32
|
+
})
|
|
33
|
+
this.redis.on('error', (e) => console.error('[position-monitor:redis]', e.message))
|
|
34
|
+
this.prefix = prefix
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async checkPositions(): Promise<void> {
|
|
38
|
+
const raw = await this.redis.lrange(`${this.prefix}:positions`, 0, -1)
|
|
39
|
+
const all: Position[] = raw.map(r => {
|
|
40
|
+
try { return JSON.parse(r) } catch { return null }
|
|
41
|
+
}).filter(Boolean)
|
|
42
|
+
|
|
43
|
+
const open = all.filter(p => p.status === 'open')
|
|
44
|
+
|
|
45
|
+
// Check which are already tracked as closed
|
|
46
|
+
const closedIds = open.length > 0
|
|
47
|
+
? await this.redis.smembers(`${this.prefix}:closed_ids`)
|
|
48
|
+
: []
|
|
49
|
+
const closedSet = new Set(closedIds)
|
|
50
|
+
|
|
51
|
+
const toCheck = open.filter(p => !closedSet.has(`${p.market_id}_${p.outcome}_${p.ts}`))
|
|
52
|
+
|
|
53
|
+
if (toCheck.length === 0) {
|
|
54
|
+
console.log(`[position-monitor] 0 open positions to check`)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Deduplicate market fetches
|
|
59
|
+
const marketPriceCache = new Map<string, MarketPrice | null>()
|
|
60
|
+
const uniqueMarkets = [...new Set(toCheck.map(p => p.market_id))]
|
|
61
|
+
await Promise.all(uniqueMarkets.map(async (marketId) => {
|
|
62
|
+
const result = await this.fetchCurrentPrice(marketId, 'Yes').catch(() => null)
|
|
63
|
+
marketPriceCache.set(marketId, result)
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
const counts = { take_profit: 0, stop_loss: 0, expiry: 0 }
|
|
67
|
+
let closedCount = 0
|
|
68
|
+
|
|
69
|
+
for (const pos of toCheck) {
|
|
70
|
+
const marketData = marketPriceCache.get(pos.market_id)
|
|
71
|
+
if (!marketData) continue
|
|
72
|
+
|
|
73
|
+
const outcome = pos.outcome
|
|
74
|
+
const currentPrice = await this.fetchCurrentPrice(pos.market_id, outcome)
|
|
75
|
+
if (!currentPrice) continue
|
|
76
|
+
|
|
77
|
+
const entryPrice = pos.price
|
|
78
|
+
const exitPrice = currentPrice.price
|
|
79
|
+
const gain = (exitPrice - entryPrice) / entryPrice
|
|
80
|
+
|
|
81
|
+
let reason: Position['reason'] | null = null
|
|
82
|
+
|
|
83
|
+
// Check exit conditions
|
|
84
|
+
const hoursToEnd = (new Date(currentPrice.endDate).getTime() - Date.now()) / (1000 * 60 * 60)
|
|
85
|
+
if (hoursToEnd <= 48) {
|
|
86
|
+
reason = 'expiry'
|
|
87
|
+
} else if (gain >= 0.15) {
|
|
88
|
+
reason = 'take_profit'
|
|
89
|
+
} else if (gain <= -0.20) {
|
|
90
|
+
reason = 'stop_loss'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!reason) continue
|
|
94
|
+
|
|
95
|
+
// Calculate P&L
|
|
96
|
+
const shares = pos.size_usdc / entryPrice
|
|
97
|
+
const pnl = shares * exitPrice - pos.size_usdc
|
|
98
|
+
|
|
99
|
+
// Build closed position record
|
|
100
|
+
const closed: Position = {
|
|
101
|
+
...pos,
|
|
102
|
+
status: 'closed',
|
|
103
|
+
exit_price: exitPrice,
|
|
104
|
+
pnl,
|
|
105
|
+
closed_at: Date.now(),
|
|
106
|
+
reason,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Track in closed_ids set
|
|
110
|
+
const posKey = `${pos.market_id}_${pos.outcome}_${pos.ts}`
|
|
111
|
+
await this.redis.sadd(`${this.prefix}:closed_ids`, posKey)
|
|
112
|
+
|
|
113
|
+
// Push to closed_positions history
|
|
114
|
+
await this.redis.lpush(`${this.prefix}:closed_positions`, JSON.stringify(closed))
|
|
115
|
+
await this.redis.ltrim(`${this.prefix}:closed_positions`, 0, 9999)
|
|
116
|
+
|
|
117
|
+
// Log entry
|
|
118
|
+
await this.redis.lpush(`${this.prefix}:log`, JSON.stringify({
|
|
119
|
+
type: 'position_closed',
|
|
120
|
+
data: closed,
|
|
121
|
+
ts: Date.now(),
|
|
122
|
+
}))
|
|
123
|
+
await this.redis.ltrim(`${this.prefix}:log`, 0, 9999)
|
|
124
|
+
|
|
125
|
+
counts[reason]++
|
|
126
|
+
closedCount++
|
|
127
|
+
|
|
128
|
+
console.log(
|
|
129
|
+
`[position-monitor] CLOSE ${reason.toUpperCase()} "${String(pos.market_question).slice(0, 50)}" ` +
|
|
130
|
+
`${pos.outcome} entry=${entryPrice.toFixed(3)} exit=${exitPrice.toFixed(3)} pnl=${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)}`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(
|
|
135
|
+
`[position-monitor] checked=${toCheck.length} closed=${closedCount} ` +
|
|
136
|
+
`(take_profit=${counts.take_profit} stop_loss=${counts.stop_loss} expiry=${counts.expiry})`
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async fetchCurrentPrice(marketId: string, outcome: string): Promise<MarketPrice | null> {
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch(`https://gamma-api.polymarket.com/markets/${marketId}`)
|
|
143
|
+
if (!res.ok) return null
|
|
144
|
+
const data = await res.json() as any
|
|
145
|
+
|
|
146
|
+
const outcomes: string[] = JSON.parse(data.outcomes || '[]')
|
|
147
|
+
const prices: string[] = JSON.parse(data.outcomePrices || '[]')
|
|
148
|
+
const endDate: string = data.endDateIso || data.endDate || ''
|
|
149
|
+
|
|
150
|
+
const priceMap: Record<string, number> = {}
|
|
151
|
+
outcomes.forEach((o, i) => {
|
|
152
|
+
priceMap[o] = parseFloat(prices[i]) || 0
|
|
153
|
+
priceMap[o.toLowerCase()] = parseFloat(prices[i]) || 0
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// Normalize outcome lookup
|
|
157
|
+
const normalized = outcome.charAt(0).toUpperCase() + outcome.slice(1).toLowerCase()
|
|
158
|
+
const price = priceMap[outcome] ?? priceMap[normalized] ?? priceMap[outcome.toLowerCase()]
|
|
159
|
+
if (price === undefined) return null
|
|
160
|
+
|
|
161
|
+
return { price, endDate }
|
|
162
|
+
} catch {
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
stop(): void {
|
|
168
|
+
this.redis.disconnect()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
|
|
4
|
+
export interface PriceSignal {
|
|
5
|
+
asset: 'BTC' | 'ETH';
|
|
6
|
+
direction: 'up' | 'down';
|
|
7
|
+
pct_change: number;
|
|
8
|
+
price: number;
|
|
9
|
+
window_secs: number;
|
|
10
|
+
signal_ts: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PriceTick {
|
|
14
|
+
price: number;
|
|
15
|
+
ts: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const THRESHOLD = 0.005; // 0.5%
|
|
19
|
+
const WINDOW_SECS = 60;
|
|
20
|
+
const PRODUCTS = ['BTC-USD', 'ETH-USD'] as const;
|
|
21
|
+
|
|
22
|
+
export class CoinbaseSignalDetector extends EventEmitter {
|
|
23
|
+
private ws: WebSocket | null = null;
|
|
24
|
+
private history: Map<string, PriceTick[]> = new Map();
|
|
25
|
+
private currentPrice: Map<string, number> = new Map();
|
|
26
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
27
|
+
|
|
28
|
+
start(): void {
|
|
29
|
+
this.connect();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
stop(): void {
|
|
33
|
+
if (this.reconnectTimer) {
|
|
34
|
+
clearTimeout(this.reconnectTimer);
|
|
35
|
+
this.reconnectTimer = null;
|
|
36
|
+
}
|
|
37
|
+
if (this.ws) {
|
|
38
|
+
this.ws.close();
|
|
39
|
+
this.ws = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private connect(): void {
|
|
44
|
+
const url = 'wss://ws-feed.exchange.coinbase.com';
|
|
45
|
+
console.log(`[coinbase-ws] Connecting to ${url}`);
|
|
46
|
+
|
|
47
|
+
const ws = new WebSocket(url);
|
|
48
|
+
this.ws = ws;
|
|
49
|
+
|
|
50
|
+
ws.on('open', () => {
|
|
51
|
+
console.log('[coinbase-ws] Connected');
|
|
52
|
+
const sub = {
|
|
53
|
+
type: 'subscribe',
|
|
54
|
+
product_ids: PRODUCTS,
|
|
55
|
+
channels: ['ticker'],
|
|
56
|
+
};
|
|
57
|
+
ws.send(JSON.stringify(sub));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
ws.on('message', (data: Buffer) => {
|
|
61
|
+
try {
|
|
62
|
+
this.handleMessage(JSON.parse(data.toString()));
|
|
63
|
+
} catch (e) {
|
|
64
|
+
// ignore parse errors
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
ws.on('error', (err: Error) => {
|
|
69
|
+
console.error('[coinbase-ws] Error:', err.message);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
ws.on('close', () => {
|
|
73
|
+
console.log('[coinbase-ws] Disconnected — reconnecting in 5s');
|
|
74
|
+
this.scheduleReconnect();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private scheduleReconnect(): void {
|
|
79
|
+
this.reconnectTimer = setTimeout(() => {
|
|
80
|
+
this.connect();
|
|
81
|
+
}, 5000);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private handleMessage(msg: any): void {
|
|
85
|
+
// Coinbase Exchange WS ticker format
|
|
86
|
+
if (msg.type !== 'ticker') return;
|
|
87
|
+
|
|
88
|
+
const { product_id, price } = msg;
|
|
89
|
+
if (!product_id || !price) return;
|
|
90
|
+
|
|
91
|
+
const priceNum = parseFloat(price);
|
|
92
|
+
if (isNaN(priceNum)) return;
|
|
93
|
+
|
|
94
|
+
this.currentPrice.set(product_id, priceNum);
|
|
95
|
+
this.recordTick(product_id, priceNum);
|
|
96
|
+
this.checkThreshold(product_id, priceNum);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private recordTick(productId: string, price: number): void {
|
|
100
|
+
if (!this.history.has(productId)) {
|
|
101
|
+
this.history.set(productId, []);
|
|
102
|
+
}
|
|
103
|
+
const ticks = this.history.get(productId)!;
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
ticks.push({ price, ts: now });
|
|
106
|
+
|
|
107
|
+
// Trim ticks older than window
|
|
108
|
+
const cutoff = now - WINDOW_SECS * 1000;
|
|
109
|
+
while (ticks.length > 0 && ticks[0].ts < cutoff) {
|
|
110
|
+
ticks.shift();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private checkThreshold(productId: string, currentPrice: number): void {
|
|
115
|
+
const ticks = this.history.get(productId);
|
|
116
|
+
if (!ticks || ticks.length < 2) return;
|
|
117
|
+
|
|
118
|
+
const oldest = ticks[0];
|
|
119
|
+
const pctChange = (currentPrice - oldest.price) / oldest.price;
|
|
120
|
+
|
|
121
|
+
if (Math.abs(pctChange) >= THRESHOLD) {
|
|
122
|
+
const asset = productId.replace('-USD', '') as 'BTC' | 'ETH';
|
|
123
|
+
const signal: PriceSignal = {
|
|
124
|
+
asset,
|
|
125
|
+
direction: pctChange > 0 ? 'up' : 'down',
|
|
126
|
+
pct_change: pctChange,
|
|
127
|
+
price: currentPrice,
|
|
128
|
+
window_secs: Math.round((Date.now() - oldest.ts) / 1000),
|
|
129
|
+
signal_ts: Date.now(),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
console.log(
|
|
133
|
+
`[signal] ${asset} ${signal.direction} ${(pctChange * 100).toFixed(2)}% ` +
|
|
134
|
+
`@ $${currentPrice.toLocaleString()} over ${signal.window_secs}s`
|
|
135
|
+
);
|
|
136
|
+
this.emit('signal', signal);
|
|
137
|
+
|
|
138
|
+
// Reset history to avoid re-triggering on same move
|
|
139
|
+
this.history.set(productId, [{ price: currentPrice, ts: Date.now() }]);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
getCurrentPrice(asset: 'BTC' | 'ETH'): number | undefined {
|
|
144
|
+
return this.currentPrice.get(`${asset}-USD`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests — verify the pipeline components work end-to-end.
|
|
3
|
+
* Hits live APIs in read-only mode. No auth required.
|
|
4
|
+
*/
|
|
5
|
+
import { fetchActiveMarkets, fetchMarket } from '../markets/gamma';
|
|
6
|
+
import { getOrderbook, getPrice } from '../markets/clob';
|
|
7
|
+
import { placeOrder } from '../markets/clob';
|
|
8
|
+
|
|
9
|
+
let passed = 0;
|
|
10
|
+
let failed = 0;
|
|
11
|
+
|
|
12
|
+
async function test(name: string, fn: () => Promise<void>): Promise<void> {
|
|
13
|
+
process.stdout.write(` ${name} ... `);
|
|
14
|
+
try {
|
|
15
|
+
await fn();
|
|
16
|
+
console.log('PASS');
|
|
17
|
+
passed++;
|
|
18
|
+
} catch (err: any) {
|
|
19
|
+
console.log(`FAIL: ${err.message}`);
|
|
20
|
+
failed++;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function assert(condition: boolean, msg: string): void {
|
|
25
|
+
if (!condition) throw new Error(msg);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main(): Promise<void> {
|
|
29
|
+
console.log('\n[smoke] Running smoke tests...\n');
|
|
30
|
+
|
|
31
|
+
await test('fetchActiveMarkets returns array', async () => {
|
|
32
|
+
const markets = await fetchActiveMarkets(10_000, 1_000);
|
|
33
|
+
assert(Array.isArray(markets), 'expected array');
|
|
34
|
+
assert(markets.length > 0, `expected markets, got ${markets.length}`);
|
|
35
|
+
const m = markets[0];
|
|
36
|
+
assert(typeof m.id === 'string', 'market.id should be string');
|
|
37
|
+
assert(typeof m.question === 'string', 'market.question should be string');
|
|
38
|
+
assert(typeof m.volume === 'number', 'market.volume should be number');
|
|
39
|
+
console.log(`\n [info] Got ${markets.length} markets, first: "${m.question.substring(0, 60)}"`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await test('fetchActiveMarkets has BTC/ETH/crypto markets', async () => {
|
|
43
|
+
const markets = await fetchActiveMarkets(10_000, 1_000);
|
|
44
|
+
const cryptoMarkets = markets.filter(m =>
|
|
45
|
+
/bitcoin|btc|ethereum|eth|crypto/i.test(m.question + m.description)
|
|
46
|
+
);
|
|
47
|
+
console.log(`\n [info] Found ${cryptoMarkets.length} crypto-related markets`);
|
|
48
|
+
assert(cryptoMarkets.length > 0, 'expected some crypto markets');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await test('placeOrder dry-run works', async () => {
|
|
52
|
+
const result = await placeOrder({
|
|
53
|
+
token_id: 'test-token-id',
|
|
54
|
+
side: 'BUY',
|
|
55
|
+
size_usdc: 10,
|
|
56
|
+
price: 0.6,
|
|
57
|
+
dry_run: true,
|
|
58
|
+
});
|
|
59
|
+
assert(result.dry_run === true, 'expected dry_run=true');
|
|
60
|
+
assert(result.token_id === 'test-token-id', 'expected token_id');
|
|
61
|
+
assert(result.estimated_shares > 0, 'expected estimated_shares > 0');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Optional CLOB tests — may fail if token IDs change
|
|
65
|
+
await test('getOrderbook (with real token — may skip)', async () => {
|
|
66
|
+
const markets = await fetchActiveMarkets(50_000, 5_000);
|
|
67
|
+
const withTokens = markets.find(m => m.tokens.length > 0 && m.tokens[0].token_id);
|
|
68
|
+
if (!withTokens) {
|
|
69
|
+
console.log('\n [skip] No liquid markets with tokens found');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const tokenId = withTokens.tokens[0].token_id;
|
|
73
|
+
try {
|
|
74
|
+
const book = await getOrderbook(tokenId);
|
|
75
|
+
assert(Array.isArray(book.bids), 'expected bids array');
|
|
76
|
+
assert(Array.isArray(book.asks), 'expected asks array');
|
|
77
|
+
console.log(`\n [info] Orderbook: ${book.bids.length} bids, ${book.asks.length} asks, spread=${book.spread?.toFixed(4)}`);
|
|
78
|
+
} catch (e: any) {
|
|
79
|
+
console.log(`\n [skip] CLOB unavailable: ${e.message}`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
console.log(`\n[smoke] Results: ${passed} passed, ${failed} failed\n`);
|
|
84
|
+
|
|
85
|
+
if (failed > 0) {
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
main().catch((err) => {
|
|
91
|
+
console.error('[smoke] Fatal:', err.message);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"ignoreDeprecations": "6.0",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"outDir": "dist",
|
|
9
|
+
"strict": false,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"allowSyntheticDefaultImports": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"lib": ["ES2022"],
|
|
17
|
+
"types": ["node"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*"],
|
|
20
|
+
"exclude": ["node_modules", "dist"]
|
|
21
|
+
}
|