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.
@@ -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
+ }