Skip to content

Commit cda409d

Browse files
committed
feat: add Polymarket links, balance flash animation, and docs update
- Add event_slug to Market model and parse from Gamma API responses - Add "↗ PM" links in MarketList linking to real Polymarket event pages - Add balance flash animation (scale + purple) on cash changes after trades - Pass refreshBalance via Outlet context so Markets page triggers updates - Update README with web dashboard section, architecture diagram, web command - Add API surface mapping table to CLAUDE.md
1 parent f53a886 commit cda409d

10 files changed

Lines changed: 156 additions & 18 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ mcp_server.py → engine.py (trading tools, 30 MCP tools)
8181
- **No price/book caching**: always live from API. Market metadata cached 5 min.
8282
- **Multi-account**: separate SQLite databases at `~/.pm-trader/<account>/paper.db`
8383

84+
## API surface mapping
85+
86+
| Gamma API endpoint | api.py method | CLI | MCP | Web (FastAPI) |
87+
|---|---|---|---|---|
88+
| `GET /markets` (list) | `list_markets()` | `markets list` | `list_markets` | `GET /api/markets` |
89+
| `GET /markets` (single) | `get_market()` | `markets get` | `get_market` | `GET /api/markets/{slug}/book` |
90+
| `GET /public-search` | `search_markets()` | `markets search` | `search_markets` | `GET /api/markets?query=` |
91+
| `GET /events` | `get_markets_by_tag()` | `markets list --tag` | `get_markets_by_tag` | `GET /api/markets?tag=` |
92+
| `GET /tags` | `get_tags()` | `markets tags` | `get_tags` | `GET /api/tags` |
93+
| `GET /categories` | `get_categories()` ||| `GET /api/categories` |
94+
8495
## Testing rules
8596

8697
- **Always run tests after changes**: `python3 -m pytest tests/ -x -q -m "not live"`

README.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pm-trader init --balance 10000 # $10k paper money
1818
pm-trader markets search "bitcoin" # find markets
1919
pm-trader buy will-bitcoin-hit-100k yes 500 # buy $500 of YES
2020
pm-trader stats --card # shareable stats card
21+
pm-trader web # or use the web dashboard
2122
```
2223

2324
That's it. Your AI agent is now trading Polymarket with zero risk.
@@ -63,6 +64,38 @@ pm-trader portfolio
6364
pm-trader stats
6465
```
6566

67+
68+
69+
---
70+
71+
72+
## Architecture
73+
74+
```
75+
┌─────────────────────────────────────────────────┐
76+
│ 接入层 (Entry Points) │
77+
│ │
78+
│ cli.py → CLI │
79+
│ mcp_server.py → AI Agent (MCP) │
80+
│ web/app.py → Web Dashboard (FastAPI) │
81+
├─────────────────────────────────────────────────┤
82+
│ 业务层 (Core Logic) │
83+
│ │
84+
│ engine.py → Trading Engine │
85+
│ analytics.py → Stats & Analytics │
86+
│ orders.py → Limit Order State Machine │
87+
│ orderbook.py → Order Book Simulation │
88+
├─────────────────────────────────────────────────┤
89+
│ 数据层 (Data) │
90+
│ │
91+
│ db.py → SQLite (WAL mode) │
92+
│ api.py → Polymarket API (Gamma/CLOB) │
93+
└─────────────────────────────────────────────────┘
94+
```
95+
96+
97+
98+
6699
## CLI commands
67100

68101
| Command | Description |
@@ -94,10 +127,34 @@ pm-trader stats
94127
| `benchmark pk STRAT_A STRAT_B` | Battle: who's the better trader? |
95128
| `accounts list` | List named accounts |
96129
| `accounts create NAME` | Create account for A/B testing |
130+
| `web [--port N] [--reload]` | Start web dashboard |
97131
| `mcp` | Start MCP server (stdio transport) |
98132

99133
Global flags: `--data-dir PATH`, `--account NAME` (or env vars `PM_TRADER_DATA_DIR`, `PM_TRADER_ACCOUNT`).
100134

135+
136+
137+
138+
## Web dashboard
139+
140+
A browser-based UI for browsing markets, placing trades, and monitoring your portfolio.
141+
142+
```bash
143+
pm-trader web # http://127.0.0.1:8000
144+
pm-trader web --port 3000 # custom port
145+
pm-trader web --reload # auto-reload for development
146+
```
147+
148+
Features:
149+
- Browse and search markets with category filters
150+
- Place buy/sell orders (FOK/FAK)
151+
- Live portfolio and balance tracking
152+
- Direct links to Polymarket for each market
153+
154+
155+
156+
157+
101158
## MCP server — what your agent can do
102159

103160
Your agent gets 26 tools via the [Model Context Protocol](https://modelcontextprotocol.io):
@@ -149,6 +206,11 @@ Add to your Claude Code config:
149206
| `pk_card` | Head-to-head comparison between two accounts |
150207
| `pk_battle` | Run two strategies head-to-head, auto-compare |
151208

209+
210+
211+
---
212+
213+
152214
## Strategy examples
153215

154216
Three ready-to-use strategies in `examples/`:

frontend/src/api/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export interface MarketItem {
7878
volume: number
7979
liquidity: number
8080
end_date: string | null
81+
event_slug: string
8182
}
8283

8384
export interface OrderBookLevel {

frontend/src/components/Layout.tsx

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,36 @@
1-
import { NavLink, Outlet } from 'react-router-dom'
2-
import { useEffect, useState } from 'react'
1+
import { NavLink, Outlet, useOutletContext } from 'react-router-dom'
2+
import { useCallback, useEffect, useRef, useState } from 'react'
33
import { fetchAccounts, fetchBalance, setAccount, getAccount } from '../api/client'
44
import type { Balance } from '../api/types'
55

66
export default function Layout() {
77
const [accounts, setAccounts] = useState<string[]>([])
88
const [selected, setSelected] = useState(getAccount())
99
const [balance, setBalance] = useState<Balance | null>(null)
10+
const [flash, setFlash] = useState(false)
11+
const prevCash = useRef<number | null>(null)
12+
1013
useEffect(() => {
1114
fetchAccounts().then(setAccounts).catch(() => {})
1215
}, [])
1316

17+
const refreshBalance = useCallback(() => {
18+
fetchBalance().then(b => {
19+
if (prevCash.current !== null && b.cash !== prevCash.current) {
20+
setFlash(true)
21+
setTimeout(() => setFlash(false), 800)
22+
}
23+
prevCash.current = b.cash
24+
setBalance(b)
25+
}).catch(() => {})
26+
}, [])
27+
1428
useEffect(() => {
1529
setAccount(selected)
16-
const refresh = () => fetchBalance().then(setBalance).catch(() => {})
17-
refresh()
18-
const interval = setInterval(refresh, 60_000)
30+
refreshBalance()
31+
const interval = setInterval(refreshBalance, 60_000)
1932
return () => clearInterval(interval)
20-
}, [selected])
33+
}, [selected, refreshBalance])
2134

2235
return (
2336
<div style={{ minHeight: '100vh', background: '#fafafa' }}>
@@ -38,13 +51,18 @@ export default function Layout() {
3851
</div>
3952
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
4053
{balance && (
41-
<div style={{ display: 'flex', gap: 12, alignItems: 'center', fontSize: '0.9em' }}>
42-
<span style={{ color: '#333' }}>
43-
<span style={{ color: '#888', marginRight: 4 }}>Portfolio</span>
54+
<div style={{
55+
display: 'flex', gap: 12, alignItems: 'center', fontSize: '0.9em',
56+
transition: 'transform 0.3s, color 0.3s',
57+
transform: flash ? 'scale(1.1)' : 'scale(1)',
58+
color: flash ? '#4f46e5' : '#333',
59+
}}>
60+
<span>
61+
<span style={{ color: flash ? '#4f46e5' : '#888', marginRight: 4 }}>Portfolio</span>
4462
${balance.total_value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
4563
</span>
46-
<span style={{ color: '#333' }}>
47-
<span style={{ color: '#888', marginRight: 4 }}>Cash</span>
64+
<span>
65+
<span style={{ color: flash ? '#4f46e5' : '#888', marginRight: 4 }}>Cash</span>
4866
${balance.cash.toLocaleString(undefined, { minimumFractionDigits: 2 })}
4967
</span>
5068
</div>
@@ -59,7 +77,7 @@ export default function Layout() {
5977
</div>
6078
</nav>
6179
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '20px 24px' }}>
62-
<Outlet />
80+
<Outlet context={{ refreshBalance }} />
6381
</main>
6482
</div>
6583
)

frontend/src/components/MarketList.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,18 @@ export default function MarketList({ markets, selected, onSelect }: Props) {
2323
borderLeft: selected?.condition_id === m.condition_id ? '3px solid #4f46e5' : '3px solid transparent',
2424
}}
2525
>
26-
<div style={{ fontWeight: 600, fontSize: '0.9em' }}>{m.question}</div>
26+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', gap: 8 }}>
27+
<span style={{ fontWeight: 600, fontSize: '0.9em' }}>{m.question}</span>
28+
<a
29+
href={`https://polymarket.com/event/${m.event_slug || m.slug}`}
30+
target="_blank"
31+
rel="noopener noreferrer"
32+
onClick={e => e.stopPropagation()}
33+
style={{ fontSize: '0.75em', color: '#4f46e5', textDecoration: 'none', whiteSpace: 'nowrap', flexShrink: 0 }}
34+
onMouseEnter={e => (e.currentTarget.style.opacity = '0.7')}
35+
onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
36+
>↗ PM</a>
37+
</div>
2738
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 6, fontSize: '0.8em', color: '#666' }}>
2839
<span>YES: <strong style={{ color: '#16a34a' }}>${m.outcome_prices[0]?.toFixed(2)}</strong></span>
2940
<span>NO: <strong style={{ color: '#dc2626' }}>${m.outcome_prices[1]?.toFixed(2)}</strong></span>

frontend/src/pages/Markets.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useState, useCallback } from 'react'
2+
import { useOutletContext } from 'react-router-dom'
23
import { fetchMarkets } from '../api/client'
34
import type { MarketItem } from '../api/types'
45
import MarketList from '../components/MarketList'
@@ -17,6 +18,7 @@ const CATEGORIES = [
1718
]
1819

1920
export default function Markets() {
21+
const { refreshBalance } = useOutletContext<{ refreshBalance: () => void }>()
2022
const [markets, setMarkets] = useState<MarketItem[]>([])
2123
const [selected, setSelected] = useState<MarketItem | null>(null)
2224
const [query, setQuery] = useState('')
@@ -83,7 +85,7 @@ export default function Markets() {
8385
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
8486
<MarketList markets={markets} selected={selected} onSelect={setSelected} />
8587
{selected ? (
86-
<TradePanel market={selected} onTradeComplete={() => doSearch(query || undefined, activeTag || undefined)} />
88+
<TradePanel market={selected} onTradeComplete={() => { doSearch(query || undefined, activeTag || undefined); refreshBalance() }} />
8789
) : (
8890
<div style={{ background: 'white', borderRadius: 8, padding: 40, textAlign: 'center', color: '#888', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
8991
Select a market to trade

pm_trader/api.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,14 @@ def search_markets(self, query: str, *, limit: int = 10) -> list[Market]:
184184
return []
185185
markets: list[Market] = []
186186
for event in data.get("events", []):
187+
event_slug = event.get("slug", "")
187188
for m in event.get("markets", []):
188189
if _has_condition_id(m):
189190
try:
190-
markets.append(_parse_market(m))
191+
mkt = _parse_market(m)
192+
if not mkt.event_slug:
193+
mkt.event_slug = event_slug
194+
markets.append(mkt)
191195
except (KeyError, TypeError):
192196
continue
193197
return markets[:limit]
@@ -241,11 +245,16 @@ def get_markets_by_tag(
241245
if not isinstance(events, list):
242246
return []
243247
# Each event embeds a list of markets — flatten them
244-
all_markets: list[dict] = []
248+
all_markets: list[Market] = []
245249
for event in events:
250+
event_slug = event.get("slug", "")
246251
for m in event.get("markets", []):
247-
all_markets.append(m)
248-
return self._parse_market_list(all_markets)[:limit]
252+
if _has_condition_id(m):
253+
mkt = _parse_market(m)
254+
if not mkt.event_slug:
255+
mkt.event_slug = event_slug
256+
all_markets.append(mkt)
257+
return all_markets[:limit]
249258

250259
def get_event(self, slug: str) -> dict:
251260
"""Fetch event details (group of related markets). Cached 5 min."""
@@ -410,6 +419,13 @@ def _parse_market(data: dict) -> Market:
410419
data.get("minimum_tick_size", 0.01))
411420
tick_size = float(tick_size_raw) if tick_size_raw else 0.01
412421

422+
# event slug: extract from nested events list (Gamma API)
423+
event_slug = data.get("event_slug", "")
424+
if not event_slug:
425+
events = data.get("events", [])
426+
if isinstance(events, list) and events:
427+
event_slug = events[0].get("slug", "")
428+
413429
return Market(
414430
condition_id=condition_id,
415431
slug=data.get("slug", ""),
@@ -425,6 +441,7 @@ def _parse_market(data: dict) -> Market:
425441
end_date=data.get("endDateIso", data.get("end_date_iso", data.get("end_date", ""))),
426442
fee_rate_bps=int(data.get("fee_rate_bps", 0) or 0),
427443
tick_size=tick_size,
444+
event_slug=event_slug,
428445
)
429446

430447

pm_trader/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ class Market:
135135
end_date: str = ""
136136
fee_rate_bps: int = 0
137137
tick_size: float = 0.01
138+
event_slug: str = ""
138139

139140
def get_token_id(self, outcome: str) -> str:
140141
"""Token ID for any outcome (case-insensitive)."""

pm_trader/web/routes/markets.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ def _market_to_dict(m) -> dict:
1212
"outcomes": m.outcomes, "outcome_prices": m.outcome_prices,
1313
"active": m.active, "closed": m.closed, "volume": m.volume,
1414
"liquidity": m.liquidity, "end_date": m.end_date,
15+
"event_slug": m.event_slug,
1516
}
1617

1718
def _book_to_dict(book) -> dict:

tests/test_api.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,20 @@ def test_null_volume_liquidity(self):
128128
assert market.volume == 0.0
129129
assert market.liquidity == 0.0
130130

131+
def test_event_slug_from_nested_events(self):
132+
data = {**SAMPLE_GAMMA_MARKET, "events": [{"slug": "parent-event"}]}
133+
market = _parse_market(data)
134+
assert market.event_slug == "parent-event"
135+
136+
def test_event_slug_direct(self):
137+
data = {**SAMPLE_GAMMA_MARKET, "event_slug": "direct-slug"}
138+
market = _parse_market(data)
139+
assert market.event_slug == "direct-slug"
140+
141+
def test_event_slug_default_empty(self):
142+
market = _parse_market(SAMPLE_GAMMA_MARKET)
143+
assert market.event_slug == ""
144+
131145

132146
# ---------------------------------------------------------------------------
133147
# _parse_order_book tests

0 commit comments

Comments
 (0)