Skip to content

Commit 4b28321

Browse files
gonggzclaude
andcommitted
fix: improve copytrack replay accuracy and add web account management
Copytrack: - Add MERGE and SPLIT support to replay engine and stats - Raise max_trades default from 10k to 100k across API, CLI, and web - Update frontend button text and per-trade color coding for new types Web: - Add account create/delete/reset endpoints and Layout UI controls - Add RequestValidationError handler for consistent 422 JSON envelope - Fix client.ts error handling for non-JSON responses - Add host: 0.0.0.0 to Vite dev server for LAN access Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ba9a0cd commit 4b28321

10 files changed

Lines changed: 273 additions & 16 deletions

File tree

frontend/src/api/client.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ function params(extra?: Record<string, string>): string {
1717

1818
async function api<T>(url: string, init?: RequestInit): Promise<T> {
1919
const resp = await fetch(url, init)
20-
const json: ApiResponse<T> = await resp.json()
20+
let json: ApiResponse<T>
21+
try {
22+
json = await resp.json()
23+
} catch {
24+
throw new Error(`Server error (${resp.status})`)
25+
}
2126
if (!json.ok) {
22-
throw new Error(json.error || 'Unknown error')
27+
throw new Error(json.error || `Request failed (${resp.status})`)
2328
}
2429
return json.data
2530
}
@@ -95,3 +100,17 @@ export const fetchCopytrackReplay = (replayId: number) =>
95100
// Accounts
96101
export const fetchAccounts = () => api<string[]>('/api/accounts')
97102
export const fetchAccountsCompare = () => api<(Stats & { account: string })[]>('/api/accounts/compare')
103+
export const createAccount = (name: string, balance: number) =>
104+
api<unknown>('/api/accounts/create', {
105+
method: 'POST',
106+
headers: { 'Content-Type': 'application/json' },
107+
body: JSON.stringify({ name, balance }),
108+
})
109+
export const deleteAccount = (name: string) =>
110+
api<unknown>(`/api/accounts/${encodeURIComponent(name)}`, { method: 'DELETE' })
111+
export const resetAccount = () =>
112+
api<unknown>(`/api/accounts/reset?${params()}`, {
113+
method: 'POST',
114+
headers: { 'Content-Type': 'application/json' },
115+
body: JSON.stringify({ confirm: true }),
116+
})

frontend/src/components/Layout.tsx

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NavLink, Outlet, useOutletContext } from 'react-router-dom'
22
import { useCallback, useEffect, useRef, useState } from 'react'
3-
import { fetchAccounts, fetchBalance, setAccount, getAccount } from '../api/client'
3+
import { fetchAccounts, fetchBalance, setAccount, getAccount, resetAccount, createAccount, deleteAccount } from '../api/client'
44
import type { Balance } from '../api/types'
55

66
export default function Layout() {
@@ -9,11 +9,17 @@ export default function Layout() {
99
const [balance, setBalance] = useState<Balance | null>(null)
1010
const [flash, setFlash] = useState(false)
1111
const prevCash = useRef<number | null>(null)
12+
const [showCreate, setShowCreate] = useState(false)
13+
const [newName, setNewName] = useState('')
14+
const [newBalance, setNewBalance] = useState('10000')
15+
const [creating, setCreating] = useState(false)
1216

13-
useEffect(() => {
17+
const refreshAccounts = useCallback(() => {
1418
fetchAccounts().then(setAccounts).catch(() => {})
1519
}, [])
1620

21+
useEffect(() => { refreshAccounts() }, [refreshAccounts])
22+
1723
const refreshBalance = useCallback(() => {
1824
fetchBalance().then(b => {
1925
if (prevCash.current !== null && b.cash !== prevCash.current) {
@@ -78,8 +84,98 @@ export default function Layout() {
7884
>
7985
{accounts.map(a => <option key={a} value={a}>{a}</option>)}
8086
</select>
87+
<button
88+
onClick={() => setShowCreate(!showCreate)}
89+
title="Create account"
90+
style={{
91+
padding: '2px 8px', border: '1px solid #ccc', borderRadius: 4, background: '#f8f8f8',
92+
cursor: 'pointer', fontSize: '0.9em', fontWeight: 600,
93+
}}
94+
>+</button>
95+
{selected !== 'default' && (
96+
<button
97+
onClick={async () => {
98+
if (!confirm(`Delete account "${selected}"? This cannot be undone.`)) return
99+
try {
100+
await deleteAccount(selected)
101+
setSelected('default')
102+
refreshAccounts()
103+
} catch { /* ignore */ }
104+
}}
105+
title="Delete account"
106+
style={{
107+
padding: '2px 8px', border: '1px solid #fca5a5', borderRadius: 4, background: '#fef2f2',
108+
cursor: 'pointer', fontSize: '0.8em', color: '#dc2626',
109+
}}
110+
>Delete</button>
111+
)}
112+
<button
113+
onClick={async () => {
114+
if (!confirm(`Reset account "${selected}"? All trades and positions will be cleared.`)) return
115+
try {
116+
await resetAccount()
117+
prevCash.current = null
118+
refreshBalance()
119+
} catch { /* ignore */ }
120+
}}
121+
title="Reset account"
122+
style={{
123+
padding: '2px 8px', border: '1px solid #ddd', borderRadius: 4, background: '#f8f8f8',
124+
cursor: 'pointer', fontSize: '0.8em', color: '#888',
125+
}}
126+
>Reset</button>
81127
</div>
82128
</nav>
129+
130+
{showCreate && (
131+
<div style={{
132+
background: 'white', borderBottom: '1px solid #e5e7eb', padding: '12px 24px',
133+
display: 'flex', gap: 12, alignItems: 'center',
134+
}}>
135+
<input
136+
value={newName}
137+
onChange={e => setNewName(e.target.value)}
138+
placeholder="Account name"
139+
style={{ padding: '6px 10px', borderRadius: 4, border: '1px solid #ccc', fontSize: '0.85em' }}
140+
/>
141+
<input
142+
type="number"
143+
value={newBalance}
144+
onChange={e => setNewBalance(e.target.value)}
145+
placeholder="Balance"
146+
style={{ padding: '6px 10px', borderRadius: 4, border: '1px solid #ccc', fontSize: '0.85em', width: 100 }}
147+
/>
148+
<button
149+
onClick={async () => {
150+
if (!newName.trim()) return
151+
setCreating(true)
152+
try {
153+
await createAccount(newName.trim(), parseFloat(newBalance) || 10000)
154+
const name = newName.trim()
155+
setNewName('')
156+
setNewBalance('10000')
157+
setShowCreate(false)
158+
await refreshAccounts()
159+
setSelected(name)
160+
} catch { /* ignore */ }
161+
setCreating(false)
162+
}}
163+
disabled={creating || !newName.trim()}
164+
style={{
165+
padding: '6px 16px', border: 'none', borderRadius: 4, background: '#4f46e5',
166+
color: 'white', cursor: creating ? 'not-allowed' : 'pointer', fontSize: '0.85em',
167+
}}
168+
>{creating ? 'Creating...' : 'Create'}</button>
169+
<button
170+
onClick={() => setShowCreate(false)}
171+
style={{
172+
padding: '6px 12px', border: '1px solid #ccc', borderRadius: 4, background: 'white',
173+
cursor: 'pointer', fontSize: '0.85em', color: '#888',
174+
}}
175+
>Cancel</button>
176+
</div>
177+
)}
178+
83179
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '20px 24px' }}>
84180
<Outlet context={{ refreshBalance }} />
85181
</main>

frontend/src/pages/Copytrack.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export default function Copytrack() {
159159
opacity: fetchingAddr === t.address ? 0.6 : 1,
160160
}}
161161
>
162-
{fetchingAddr === t.address ? 'Fetching...' : 'Fetch (last 10k)'}
162+
{fetchingAddr === t.address ? 'Fetching...' : 'Fetch All'}
163163
</button>
164164
<span style={{ fontSize: '0.75em', color: '#888' }}>Replay last</span>
165165
<input
@@ -394,11 +394,11 @@ export default function Copytrack() {
394394
}
395395
const openSlugs = new Set(
396396
Object.entries(slugSides)
397-
.filter(([, sides]) => sides.has('BUY') && !sides.has('SELL') && !sides.has('REDEEM'))
397+
.filter(([, sides]) => (sides.has('BUY') || sides.has('SPLIT')) && !sides.has('SELL') && !sides.has('REDEEM') && !sides.has('MERGE'))
398398
.map(([slug]) => slug)
399399
)
400400
return perTrade.map((t, i) => {
401-
const sideColor = t.side === 'BUY' ? '#16a34a' : t.side === 'REDEEM' ? '#7c3aed' : '#dc2626'
401+
const sideColor = t.side === 'BUY' ? '#16a34a' : t.side === 'SELL' ? '#dc2626' : t.side === 'REDEEM' ? '#7c3aed' : t.side === 'SPLIT' ? '#0891b2' : t.side === 'MERGE' ? '#ea580c' : '#888'
402402
const isOpen = openSlugs.has(t.market_slug)
403403
return (
404404
<tr key={i} style={{ borderBottom: '1px solid #f0f0f0', background: isOpen ? '#fffbeb' : 'transparent' }}>

frontend/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export default defineConfig({
55
plugins: [react()],
66
server: {
77
port: 5173,
8+
host: '0.0.0.0',
89
proxy: {
910
'/api': {
1011
target: 'http://127.0.0.1:8000',

pm_trader/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -916,7 +916,7 @@ def copytrack_trades(identifier: str, limit: int) -> None:
916916

917917
@copytrack.command("fetch")
918918
@click.argument("identifier")
919-
@click.option("--max-trades", type=int, default=10_000, help="Max trades to fetch.")
919+
@click.option("--max-trades", type=int, default=100_000, help="Max trades to fetch.")
920920
@click.pass_context
921921
def copytrack_fetch_cmd(ctx: click.Context, identifier: str, max_trades: int) -> None:
922922
"""Fetch a target's trade history and store locally."""

pm_trader/copytrack.py

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,19 @@ def compute_original_stats(trades: list[dict]) -> dict:
3535
buys = [t for t in trades if t.get("side") == "BUY"]
3636
sells = [t for t in trades if t.get("side") == "SELL"]
3737
redeems = [t for t in trades if t.get("type") == "REDEEM"]
38-
total_invested = sum(t.get("usdcSize", 0.0) for t in buys)
38+
splits = [t for t in trades if t.get("side") == "SPLIT"]
39+
merges = [t for t in trades if t.get("side") == "MERGE"]
40+
# SPLIT spends $1 per share → outflow; MERGE returns $1 per share → inflow
41+
total_invested = (sum(t.get("usdcSize", 0.0) for t in buys)
42+
+ sum(t.get("usdcSize", 0.0) for t in splits))
3943
total_returned = (sum(t.get("usdcSize", 0.0) for t in sells)
40-
+ sum(t.get("usdcSize", 0.0) for t in redeems))
44+
+ sum(t.get("usdcSize", 0.0) for t in redeems)
45+
+ sum(t.get("usdcSize", 0.0) for t in merges))
4146

4247
pnl = total_returned - total_invested
4348
roi_pct = (pnl / total_invested * 100) if total_invested > 0 else 0.0
4449
return {"total_trades": len(trades), "buy_count": len(buys), "sell_count": len(sells),
45-
"redeem_count": len(redeems),
50+
"redeem_count": len(redeems), "split_count": len(splits), "merge_count": len(merges),
4651
"total_invested": total_invested, "total_returned": total_returned,
4752
"pnl": pnl, "roi_pct": roi_pct}
4853

@@ -52,7 +57,7 @@ def fetch(
5257
db: "Database",
5358
data_client: "PolymarketDataClient",
5459
*,
55-
max_trades: int = 10_000,
60+
max_trades: int = 100_000,
5661
) -> dict:
5762
"""Fetch a user's trades and redeems from API and store in DB.
5863
@@ -253,6 +258,77 @@ def replay(
253258
engine.db.update_cash(account.cash + replay_usd_sell)
254259
replayed.append(trade)
255260
replay_usd_final = replay_usd_sell
261+
262+
elif side == "SPLIT":
263+
# SPLIT: spend $1 per share → get 1 YES + 1 NO share each
264+
split_cost = compute_replay_amount(original_usd, target.sizing)
265+
account = engine.get_account()
266+
if account.cash < split_cost:
267+
skipped.append({**orig, "reason": f"Insufficient balance for SPLIT: need ${split_cost:.2f}, have ${account.cash:.2f}"})
268+
continue
269+
split_shares = split_cost # $1 per share pair
270+
271+
for split_outcome in ("yes", "no"):
272+
existing = engine.db.get_position(condition_id, split_outcome)
273+
if existing and existing.shares > 0:
274+
total_shares = existing.shares + split_shares
275+
total_cost = existing.total_cost + split_cost
276+
new_avg = total_cost / total_shares
277+
else:
278+
total_shares = split_shares
279+
total_cost = split_cost
280+
new_avg = 1.0
281+
engine.db.upsert_position(
282+
market_condition_id=condition_id, market_slug=slug,
283+
market_question=title, outcome=split_outcome,
284+
shares=total_shares, avg_entry_price=new_avg, total_cost=total_cost)
285+
286+
engine.db.update_cash(account.cash - split_cost)
287+
trade = engine.db.insert_trade(
288+
market_condition_id=condition_id, market_slug=slug,
289+
market_question=title, event_slug=event_slug,
290+
outcome="split", side="buy", order_type="fok",
291+
avg_price=1.0, amount_usd=split_cost,
292+
shares=split_shares, fee_rate_bps=0, fee=0.0,
293+
slippage=0.0, levels_filled=1, is_partial=False)
294+
replayed.append(trade)
295+
replay_usd_final = split_cost
296+
297+
elif side == "MERGE":
298+
# MERGE: burn 1 YES + 1 NO share → get $1 back
299+
yes_pos = engine.db.get_position(condition_id, "yes")
300+
no_pos = engine.db.get_position(condition_id, "no")
301+
if not yes_pos or yes_pos.shares <= 0 or not no_pos or no_pos.shares <= 0:
302+
skipped.append({**orig, "reason": "Need both YES and NO positions to merge"})
303+
continue
304+
305+
merge_shares = min(original_shares, yes_pos.shares, no_pos.shares)
306+
merge_usd = merge_shares # $1 per merged pair
307+
308+
for merge_outcome, pos in (("yes", yes_pos), ("no", no_pos)):
309+
remaining_shares = pos.shares - merge_shares
310+
cost_of_merged = merge_shares * pos.avg_entry_price
311+
remaining_cost = pos.total_cost - cost_of_merged
312+
realized = (merge_usd / 2) - cost_of_merged # each side gets half the $1
313+
engine.db.upsert_position(
314+
market_condition_id=condition_id, market_slug=slug,
315+
market_question=title, outcome=merge_outcome,
316+
shares=remaining_shares, avg_entry_price=pos.avg_entry_price,
317+
total_cost=max(remaining_cost, 0.0),
318+
realized_pnl=pos.realized_pnl + realized)
319+
320+
account = engine.get_account()
321+
engine.db.update_cash(account.cash + merge_usd)
322+
trade = engine.db.insert_trade(
323+
market_condition_id=condition_id, market_slug=slug,
324+
market_question=title, event_slug=event_slug,
325+
outcome="merge", side="sell", order_type="fok",
326+
avg_price=1.0, amount_usd=merge_usd,
327+
shares=merge_shares, fee_rate_bps=0, fee=0.0,
328+
slippage=0.0, levels_filled=1, is_partial=False)
329+
replayed.append(trade)
330+
replay_usd_final = merge_usd
331+
256332
else:
257333
skipped.append({**orig, "reason": f"Unknown side: {side}"})
258334
continue

pm_trader/data_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def get_all_trades(
6868
*,
6969
start: int | None = None,
7070
end: int | None = None,
71-
max_trades: int = 10_000,
71+
max_trades: int = 100_000,
7272
type: str = "TRADE",
7373
) -> list[dict]:
7474
"""Fetch complete activity history with auto-pagination."""

pm_trader/web/app.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from fastapi import FastAPI, Request
4+
from fastapi.exceptions import RequestValidationError
45
from fastapi.middleware.cors import CORSMiddleware
56
from fastapi.responses import JSONResponse
67

@@ -16,6 +17,15 @@ def create_app() -> FastAPI:
1617
allow_headers=["*"],
1718
)
1819

20+
@app.exception_handler(RequestValidationError)
21+
async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
22+
errors = exc.errors()
23+
msg = "; ".join(f"{e['loc'][-1]}: {e['msg']}" for e in errors) if errors else "Validation error"
24+
return JSONResponse(
25+
status_code=422,
26+
content={"ok": False, "error": msg, "code": "VALIDATION_ERROR"},
27+
)
28+
1929
@app.exception_handler(SimError)
2030
async def sim_error_handler(request: Request, exc: SimError) -> JSONResponse:
2131
return JSONResponse(

0 commit comments

Comments
 (0)