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,146 @@
|
|
|
1
|
+
const GAMMA_BASE = 'https://gamma-api.polymarket.com';
|
|
2
|
+
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
3
|
+
|
|
4
|
+
export interface MarketToken {
|
|
5
|
+
token_id: string;
|
|
6
|
+
outcome: string;
|
|
7
|
+
price: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Market {
|
|
11
|
+
id: string;
|
|
12
|
+
conditionId: string;
|
|
13
|
+
question: string;
|
|
14
|
+
description: string;
|
|
15
|
+
volume: number;
|
|
16
|
+
volume24hr: number;
|
|
17
|
+
liquidity: number;
|
|
18
|
+
tokens: MarketToken[];
|
|
19
|
+
active: boolean;
|
|
20
|
+
closed: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface CacheEntry {
|
|
24
|
+
markets: Market[];
|
|
25
|
+
ts: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let cache: CacheEntry | null = null;
|
|
29
|
+
|
|
30
|
+
function parseNumber(val: any): number {
|
|
31
|
+
if (typeof val === 'number') return val;
|
|
32
|
+
if (typeof val === 'string') return parseFloat(val) || 0;
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseTokens(raw: any): MarketToken[] {
|
|
37
|
+
if (!Array.isArray(raw)) return [];
|
|
38
|
+
return raw.map((t: any) => ({
|
|
39
|
+
token_id: t.token_id ?? t.tokenId ?? '',
|
|
40
|
+
outcome: t.outcome ?? '',
|
|
41
|
+
price: parseNumber(t.price),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseMarket(raw: any): Market {
|
|
46
|
+
// Gamma API returns outcomes/prices as JSON strings, not a tokens array
|
|
47
|
+
let tokens = parseTokens(raw.tokens)
|
|
48
|
+
if (tokens.length === 0 && raw.outcomes) {
|
|
49
|
+
try {
|
|
50
|
+
const outcomes: string[] = JSON.parse(raw.outcomes)
|
|
51
|
+
const prices: string[] = JSON.parse(raw.outcomePrices || '[]')
|
|
52
|
+
tokens = outcomes.map((outcome, i) => ({
|
|
53
|
+
token_id: '',
|
|
54
|
+
outcome,
|
|
55
|
+
price: parseFloat(prices[i]) || 0,
|
|
56
|
+
}))
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
id: String(raw.id ?? ''),
|
|
62
|
+
conditionId: String(raw.conditionId ?? raw.condition_id ?? ''),
|
|
63
|
+
question: String(raw.question ?? ''),
|
|
64
|
+
description: String(raw.description ?? ''),
|
|
65
|
+
volume: parseNumber(raw.volume),
|
|
66
|
+
volume24hr: parseNumber(raw.volume24hr),
|
|
67
|
+
liquidity: parseNumber(raw.liquidity),
|
|
68
|
+
tokens,
|
|
69
|
+
active: Boolean(raw.active),
|
|
70
|
+
closed: Boolean(raw.closed),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function fetchActiveMarkets(minVolume = 10_000, minLiquidity = 1_000): Promise<Market[]> {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
if (cache && now - cache.ts < CACHE_TTL_MS) {
|
|
77
|
+
return cache.markets;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const url = `${GAMMA_BASE}/markets?active=true&closed=false&limit=500`;
|
|
81
|
+
console.log(`[gamma] Fetching active markets from ${url}`);
|
|
82
|
+
|
|
83
|
+
const res = await fetch(url);
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
throw new Error(`Gamma API error: ${res.status} ${res.statusText}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const raw = await res.json();
|
|
89
|
+
const data: any[] = Array.isArray(raw) ? raw : [];
|
|
90
|
+
const markets: Market[] = data
|
|
91
|
+
.map(parseMarket)
|
|
92
|
+
.filter(m => m.active && !m.closed && m.volume >= minVolume && m.liquidity >= minLiquidity);
|
|
93
|
+
|
|
94
|
+
console.log(`[gamma] Loaded ${markets.length} markets (filtered from ${data.length})`);
|
|
95
|
+
|
|
96
|
+
cache = { markets, ts: now };
|
|
97
|
+
return markets;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function fetchHighQualityMarkets(
|
|
101
|
+
minVolume24h = 50_000,
|
|
102
|
+
minLiquidity = 50_000,
|
|
103
|
+
minPrice = 0.10,
|
|
104
|
+
maxPrice = 0.90
|
|
105
|
+
): Promise<Market[]> {
|
|
106
|
+
const all = await fetchActiveMarkets(1_000, 1_000)
|
|
107
|
+
|
|
108
|
+
return all
|
|
109
|
+
.filter(m => {
|
|
110
|
+
const vol24 = m.volume24hr ?? 0
|
|
111
|
+
const liq = m.liquidity ?? 0
|
|
112
|
+
const hasUncertainty = m.tokens?.some(t => t.price >= minPrice && t.price <= maxPrice)
|
|
113
|
+
return vol24 >= minVolume24h && liq >= minLiquidity && hasUncertainty
|
|
114
|
+
})
|
|
115
|
+
.sort((a, b) => (b.volume24hr ?? 0) - (a.volume24hr ?? 0))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function fetchMarket(id: string): Promise<Market | null> {
|
|
119
|
+
const url = `${GAMMA_BASE}/markets/${id}`;
|
|
120
|
+
const res = await fetch(url);
|
|
121
|
+
if (!res.ok) return null;
|
|
122
|
+
const data = await res.json();
|
|
123
|
+
return parseMarket(data);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function clearCache(): void {
|
|
127
|
+
cache = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const KEYWORDS: Record<string, string[]> = {
|
|
131
|
+
BTC: ['bitcoin', 'btc', 'crypto', 'cryptocurrency'],
|
|
132
|
+
ETH: ['ethereum', 'eth', 'ether', 'defi'],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function filterBySignal(asset: string, markets: Market[]): Market[] {
|
|
136
|
+
const kw = KEYWORDS[asset] || []
|
|
137
|
+
const scored = markets
|
|
138
|
+
.map(m => {
|
|
139
|
+
const text = `${m.question} ${m.description || ''}`.toLowerCase()
|
|
140
|
+
const score = kw.reduce((s, k) => s + (text.includes(k) ? 1 : 0), 0)
|
|
141
|
+
return { m, score }
|
|
142
|
+
})
|
|
143
|
+
.filter(x => x.score > 0)
|
|
144
|
+
.sort((a, b) => b.score - a.score || b.m.volume - a.m.volume)
|
|
145
|
+
return scored.slice(0, 20).map(x => x.m)
|
|
146
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import {
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
|
|
9
|
+
import { fetchActiveMarkets, fetchMarket, Market } from '../markets/gamma';
|
|
10
|
+
import { getOrderbook, getPrice, placeOrder } from '../markets/clob';
|
|
11
|
+
import { PriceSignal } from '../signals/coinbase-ws';
|
|
12
|
+
|
|
13
|
+
// Keywords that link crypto assets to market questions
|
|
14
|
+
const ASSET_KEYWORDS: Record<string, string[]> = {
|
|
15
|
+
BTC: ['bitcoin', 'btc', 'crypto', 'cryptocurrency', 'price', 'market'],
|
|
16
|
+
ETH: ['ethereum', 'eth', 'crypto', 'cryptocurrency', 'price', 'market'],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function matchesSignal(market: Market, signal: PriceSignal): boolean {
|
|
20
|
+
const keywords = ASSET_KEYWORDS[signal.asset] ?? [];
|
|
21
|
+
const haystack = `${market.question} ${market.description}`.toLowerCase();
|
|
22
|
+
return keywords.some(kw => haystack.includes(kw));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function simpleKeywordMatch(market: Market, query: string): boolean {
|
|
26
|
+
if (!query) return true;
|
|
27
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
28
|
+
const haystack = `${market.question} ${market.description}`.toLowerCase();
|
|
29
|
+
return terms.every(term => haystack.includes(term));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const server = new Server(
|
|
33
|
+
{ name: 'polymarket', version: '1.0.0' },
|
|
34
|
+
{ capabilities: { tools: {} } }
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
38
|
+
tools: [
|
|
39
|
+
{
|
|
40
|
+
name: 'list_markets',
|
|
41
|
+
description: 'Fetch active Polymarket prediction markets with current prices',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
query: { type: 'string', description: 'Keyword filter for market question/description' },
|
|
46
|
+
min_volume: { type: 'number', description: 'Minimum USD volume (default: 10000)' },
|
|
47
|
+
limit: { type: 'number', description: 'Max results to return (default: 20)' },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'get_orderbook',
|
|
53
|
+
description: 'Get CLOB orderbook for a specific market token',
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
required: ['token_id'],
|
|
57
|
+
properties: {
|
|
58
|
+
token_id: { type: 'string', description: 'Polymarket token ID' },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'get_market_price',
|
|
64
|
+
description: 'Get current buy/sell price for a market token (0-1 = probability)',
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
required: ['token_id', 'side'],
|
|
68
|
+
properties: {
|
|
69
|
+
token_id: { type: 'string' },
|
|
70
|
+
side: { type: 'string', enum: ['buy', 'sell'] },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'assess_signal',
|
|
76
|
+
description: 'Given a crypto price signal, find relevant Polymarket markets that might be affected',
|
|
77
|
+
inputSchema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
required: ['signal'],
|
|
80
|
+
properties: {
|
|
81
|
+
signal: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
description: 'PriceSignal object',
|
|
84
|
+
required: ['asset', 'direction', 'pct_change', 'price', 'window_secs', 'signal_ts'],
|
|
85
|
+
properties: {
|
|
86
|
+
asset: { type: 'string', enum: ['BTC', 'ETH'] },
|
|
87
|
+
direction: { type: 'string', enum: ['up', 'down'] },
|
|
88
|
+
pct_change: { type: 'number' },
|
|
89
|
+
price: { type: 'number' },
|
|
90
|
+
window_secs: { type: 'number' },
|
|
91
|
+
signal_ts: { type: 'number' },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'place_order',
|
|
99
|
+
description: 'Place a Polymarket CLOB order (dry_run=true by default — logs intent without executing)',
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
required: ['token_id', 'side', 'size_usdc', 'price'],
|
|
103
|
+
properties: {
|
|
104
|
+
token_id: { type: 'string' },
|
|
105
|
+
side: { type: 'string', enum: ['BUY', 'SELL'] },
|
|
106
|
+
size_usdc: { type: 'number', description: 'Order size in USDC' },
|
|
107
|
+
price: { type: 'number', description: 'Limit price 0-1 (probability)' },
|
|
108
|
+
dry_run: { type: 'boolean', description: 'If true, log only — do not execute (default: true)' },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
116
|
+
const { name, arguments: args } = request.params;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
switch (name) {
|
|
120
|
+
case 'list_markets': {
|
|
121
|
+
const { query, min_volume = 10_000, limit = 20 } = (args ?? {}) as any;
|
|
122
|
+
const markets = await fetchActiveMarkets(min_volume, 1_000);
|
|
123
|
+
const filtered = markets
|
|
124
|
+
.filter(m => simpleKeywordMatch(m, query ?? ''))
|
|
125
|
+
.slice(0, limit);
|
|
126
|
+
return {
|
|
127
|
+
content: [{ type: 'text', text: JSON.stringify(filtered, null, 2) }],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
case 'get_orderbook': {
|
|
132
|
+
const { token_id } = args as { token_id: string };
|
|
133
|
+
const book = await getOrderbook(token_id);
|
|
134
|
+
return {
|
|
135
|
+
content: [{ type: 'text', text: JSON.stringify(book, null, 2) }],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case 'get_market_price': {
|
|
140
|
+
const { token_id, side } = args as { token_id: string; side: 'buy' | 'sell' };
|
|
141
|
+
const price = await getPrice(token_id, side);
|
|
142
|
+
return {
|
|
143
|
+
content: [{ type: 'text', text: JSON.stringify({ token_id, side, price }) }],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case 'assess_signal': {
|
|
148
|
+
const { signal } = args as { signal: PriceSignal };
|
|
149
|
+
const start = Date.now();
|
|
150
|
+
const markets = await fetchActiveMarkets(10_000, 1_000);
|
|
151
|
+
const matched = markets.filter(m => matchesSignal(m, signal));
|
|
152
|
+
const latency_ms = Date.now() - start;
|
|
153
|
+
|
|
154
|
+
const result = {
|
|
155
|
+
signal,
|
|
156
|
+
matched_count: matched.length,
|
|
157
|
+
latency_ms,
|
|
158
|
+
markets: matched.slice(0, 10).map(m => ({
|
|
159
|
+
id: m.id,
|
|
160
|
+
question: m.question,
|
|
161
|
+
volume: m.volume,
|
|
162
|
+
liquidity: m.liquidity,
|
|
163
|
+
tokens: m.tokens,
|
|
164
|
+
})),
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case 'place_order': {
|
|
172
|
+
const { token_id, side, size_usdc, price, dry_run = true } = args as any;
|
|
173
|
+
const result = await placeOrder({ token_id, side, size_usdc, price, dry_run });
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
default:
|
|
180
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
181
|
+
}
|
|
182
|
+
} catch (err: any) {
|
|
183
|
+
return {
|
|
184
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
185
|
+
isError: true,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
async function main(): Promise<void> {
|
|
191
|
+
const transport = new StdioServerTransport();
|
|
192
|
+
await server.connect(transport);
|
|
193
|
+
console.error('[mcp] Polymarket MCP server started (stdio)');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
main().catch((err) => {
|
|
197
|
+
console.error('[mcp] Fatal:', err);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
5
|
+
import Redis from 'ioredis'
|
|
6
|
+
|
|
7
|
+
const REDIS_PREFIX = process.env.POLLY_REDIS_PREFIX || 'polly'
|
|
8
|
+
|
|
9
|
+
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
|
|
10
|
+
retryStrategy: (times) => Math.min(times * 500, 5000),
|
|
11
|
+
maxRetriesPerRequest: null,
|
|
12
|
+
enableReadyCheck: false,
|
|
13
|
+
})
|
|
14
|
+
redis.on('error', (e) => console.error('[redis]', e.message))
|
|
15
|
+
|
|
16
|
+
const server = new Server(
|
|
17
|
+
{ name: 'polly-gamba-paper', version: '1.0.0' },
|
|
18
|
+
{ capabilities: { tools: {} } }
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
22
|
+
tools: [
|
|
23
|
+
{
|
|
24
|
+
name: 'place_order',
|
|
25
|
+
description: 'Place a paper trade on Polymarket. Logs the trade to Redis (no real execution).',
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
market_id: { type: 'string', description: 'Polymarket market ID' },
|
|
30
|
+
market_question: { type: 'string', description: 'Market question for logging' },
|
|
31
|
+
token_id: { type: 'string', description: 'Token ID (YES or NO token)' },
|
|
32
|
+
outcome: { type: 'string', description: 'YES or NO' },
|
|
33
|
+
side: { type: 'string', enum: ['BUY', 'SELL'] },
|
|
34
|
+
size_usdc: { type: 'number', description: 'Size in USDC (virtual)' },
|
|
35
|
+
price: { type: 'number', description: 'Current price (0-1)' },
|
|
36
|
+
reasoning: { type: 'string', description: 'Why you are making this trade' }
|
|
37
|
+
},
|
|
38
|
+
required: ['market_id', 'market_question', 'outcome', 'side', 'size_usdc', 'price', 'reasoning']
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'skip_market',
|
|
43
|
+
description: 'Explicitly decide not to trade on a market, with reasoning.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
market_id: { type: 'string' },
|
|
48
|
+
market_question: { type: 'string' },
|
|
49
|
+
reasoning: { type: 'string', description: 'Why you are skipping' }
|
|
50
|
+
},
|
|
51
|
+
required: ['market_id', 'market_question', 'reasoning']
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'skip_all',
|
|
56
|
+
description: 'Skip all markets in the current scan — use only when you have zero opinion on any market.',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
reasoning: { type: 'string', description: 'Why you are skipping all markets' }
|
|
61
|
+
},
|
|
62
|
+
required: ['reasoning']
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'get_positions',
|
|
67
|
+
description: 'Returns all open positions from Redis.',
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {},
|
|
71
|
+
required: []
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'close_position',
|
|
76
|
+
description: 'Marks a position as closed with exit price and P&L.',
|
|
77
|
+
inputSchema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
market_id: { type: 'string', description: 'Polymarket market ID' },
|
|
81
|
+
outcome: { type: 'string', description: 'YES or NO' },
|
|
82
|
+
exit_price: { type: 'number', description: 'Exit price (0-1)' },
|
|
83
|
+
reason: { type: 'string', enum: ['take_profit', 'stop_loss', 'expiry', 'manual'], description: 'Reason for closing' }
|
|
84
|
+
},
|
|
85
|
+
required: ['market_id', 'outcome', 'exit_price', 'reason']
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'get_pnl_summary',
|
|
90
|
+
description: 'Returns P&L summary across all open and closed positions.',
|
|
91
|
+
inputSchema: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
properties: {},
|
|
94
|
+
required: []
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
101
|
+
const { name, arguments: args } = req.params
|
|
102
|
+
const ts = Date.now()
|
|
103
|
+
|
|
104
|
+
if (name === 'place_order') {
|
|
105
|
+
const a = args as any
|
|
106
|
+
const position = { ...a, ts, status: 'open', pnl: null }
|
|
107
|
+
await redis.lpush(`${REDIS_PREFIX}:positions`, JSON.stringify(position))
|
|
108
|
+
await redis.ltrim(`${REDIS_PREFIX}:positions`, 0, 9999)
|
|
109
|
+
await redis.publish(`${REDIS_PREFIX}:events`, JSON.stringify({ type: 'trade', ...position }))
|
|
110
|
+
console.error(`[paper] TRADE ${a.side} ${a.outcome} "${String(a.market_question).slice(0, 50)}" @ ${a.price} $${a.size_usdc}`)
|
|
111
|
+
return {
|
|
112
|
+
content: [{
|
|
113
|
+
type: 'text',
|
|
114
|
+
text: `Paper trade logged: ${a.side} ${a.outcome} on "${a.market_question}" @ ${a.price} for $${a.size_usdc} USDC. Position recorded in Redis.`
|
|
115
|
+
}]
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (name === 'skip_market') {
|
|
120
|
+
const a = args as any
|
|
121
|
+
await redis.lpush(`${REDIS_PREFIX}:skips`, JSON.stringify({ ...a, ts }))
|
|
122
|
+
await redis.ltrim(`${REDIS_PREFIX}:skips`, 0, 9999)
|
|
123
|
+
console.error(`[paper] SKIP "${String(a.market_question).slice(0, 50)}"`)
|
|
124
|
+
return {
|
|
125
|
+
content: [{ type: 'text', text: `Skip recorded for "${a.market_question}": ${a.reasoning}` }]
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (name === 'skip_all') {
|
|
130
|
+
const a = args as any
|
|
131
|
+
await redis.lpush(`${REDIS_PREFIX}:skips`, JSON.stringify({ skip_all: true, reasoning: a.reasoning, ts }))
|
|
132
|
+
await redis.ltrim(`${REDIS_PREFIX}:skips`, 0, 9999)
|
|
133
|
+
console.error(`[paper] SKIP_ALL: ${String(a.reasoning).slice(0, 100)}`)
|
|
134
|
+
return {
|
|
135
|
+
content: [{ type: 'text', text: `Skip all recorded: ${a.reasoning}` }]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (name === 'get_positions') {
|
|
140
|
+
const raw = await redis.lrange(`${REDIS_PREFIX}:positions`, 0, -1)
|
|
141
|
+
const closedIds = new Set(await redis.smembers(`${REDIS_PREFIX}:closed_ids`))
|
|
142
|
+
const positions = raw.map(r => {
|
|
143
|
+
try { return JSON.parse(r) } catch { return null }
|
|
144
|
+
}).filter(Boolean).filter((p: any) => {
|
|
145
|
+
if (p.status === 'closed') return false
|
|
146
|
+
const key = `${p.market_id}_${p.outcome}_${p.ts}`
|
|
147
|
+
return !closedIds.has(key)
|
|
148
|
+
})
|
|
149
|
+
return {
|
|
150
|
+
content: [{ type: 'text', text: JSON.stringify(positions, null, 2) }]
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (name === 'close_position') {
|
|
155
|
+
const a = args as any
|
|
156
|
+
const raw = await redis.lrange(`${REDIS_PREFIX}:positions`, 0, -1)
|
|
157
|
+
const positions = raw.map(r => {
|
|
158
|
+
try { return JSON.parse(r) } catch { return null }
|
|
159
|
+
}).filter(Boolean)
|
|
160
|
+
|
|
161
|
+
// Find matching open position
|
|
162
|
+
const match = positions.find((p: any) =>
|
|
163
|
+
p.market_id === a.market_id &&
|
|
164
|
+
p.outcome?.toLowerCase() === String(a.outcome).toLowerCase() &&
|
|
165
|
+
p.status !== 'closed'
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if (!match) {
|
|
169
|
+
return { content: [{ type: 'text', text: `No open position found for market ${a.market_id} outcome ${a.outcome}` }] }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const shares = match.size_usdc / match.price
|
|
173
|
+
const pnl = shares * a.exit_price - match.size_usdc
|
|
174
|
+
const closed = {
|
|
175
|
+
...match,
|
|
176
|
+
status: 'closed',
|
|
177
|
+
exit_price: a.exit_price,
|
|
178
|
+
pnl,
|
|
179
|
+
closed_at: Date.now(),
|
|
180
|
+
reason: a.reason,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const posKey = `${match.market_id}_${match.outcome}_${match.ts}`
|
|
184
|
+
await redis.sadd(`${REDIS_PREFIX}:closed_ids`, posKey)
|
|
185
|
+
await redis.lpush(`${REDIS_PREFIX}:closed_positions`, JSON.stringify(closed))
|
|
186
|
+
await redis.ltrim(`${REDIS_PREFIX}:closed_positions`, 0, 9999)
|
|
187
|
+
await redis.lpush(`${REDIS_PREFIX}:log`, JSON.stringify({ type: 'position_closed', data: closed, ts: Date.now() }))
|
|
188
|
+
await redis.ltrim(`${REDIS_PREFIX}:log`, 0, 9999)
|
|
189
|
+
|
|
190
|
+
console.error(`[paper] CLOSE ${a.reason.toUpperCase()} "${String(match.market_question).slice(0, 50)}" ${match.outcome} pnl=${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)}`)
|
|
191
|
+
return {
|
|
192
|
+
content: [{ type: 'text', text: `Position closed: ${a.reason}. P&L: ${pnl >= 0 ? '+' : ''}$${pnl.toFixed(2)} USDC` }]
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (name === 'get_pnl_summary') {
|
|
197
|
+
const [openRaw, closedRaw] = await Promise.all([
|
|
198
|
+
redis.lrange(`${REDIS_PREFIX}:positions`, 0, -1),
|
|
199
|
+
redis.lrange(`${REDIS_PREFIX}:closed_positions`, 0, -1),
|
|
200
|
+
])
|
|
201
|
+
const closedIds = new Set(await redis.smembers(`${REDIS_PREFIX}:closed_ids`))
|
|
202
|
+
|
|
203
|
+
const allPositions = openRaw.map(r => { try { return JSON.parse(r) } catch { return null } }).filter(Boolean)
|
|
204
|
+
const openPositions = allPositions.filter((p: any) => {
|
|
205
|
+
if (p.status === 'closed') return false
|
|
206
|
+
return !closedIds.has(`${p.market_id}_${p.outcome}_${p.ts}`)
|
|
207
|
+
})
|
|
208
|
+
const closedPositions = closedRaw.map(r => { try { return JSON.parse(r) } catch { return null } }).filter(Boolean)
|
|
209
|
+
|
|
210
|
+
const totalInvested = openPositions.reduce((s: number, p: any) => s + (p.size_usdc || 0), 0)
|
|
211
|
+
const totalRealized = closedPositions.reduce((s: number, p: any) => s + (p.pnl || 0), 0)
|
|
212
|
+
const wins = closedPositions.filter((p: any) => (p.pnl || 0) > 0).length
|
|
213
|
+
const winRate = closedPositions.length > 0 ? wins / closedPositions.length : 0
|
|
214
|
+
|
|
215
|
+
const byReason: Record<string, number> = {}
|
|
216
|
+
for (const p of closedPositions) {
|
|
217
|
+
const r = p.reason || 'unknown'
|
|
218
|
+
byReason[r] = (byReason[r] || 0) + 1
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const summary = {
|
|
222
|
+
open_positions: openPositions.length,
|
|
223
|
+
closed_positions: closedPositions.length,
|
|
224
|
+
total_invested: Math.round(totalInvested * 100) / 100,
|
|
225
|
+
total_realized_pnl: Math.round(totalRealized * 100) / 100,
|
|
226
|
+
win_rate: Math.round(winRate * 100) / 100,
|
|
227
|
+
by_reason: byReason,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }] }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
throw new Error(`Unknown tool: ${name}`)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
async function main() {
|
|
237
|
+
const transport = new StdioServerTransport()
|
|
238
|
+
await server.connect(transport)
|
|
239
|
+
console.error('[mcp] polly-gamba-paper MCP server started (stdio)')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
main().catch((e) => {
|
|
243
|
+
console.error('[mcp] Fatal:', e)
|
|
244
|
+
process.exit(1)
|
|
245
|
+
})
|