From 9ae1b396d64ad17345d23c261993d2d5c013f1b4 Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:23:35 -0400 Subject: [PATCH 01/19] feat: add copytrack data models and error types to models.py Adds SizingStrategy, Target, TradeComparison, Comparison, ReplayResult dataclasses and TargetNotFoundError, UsernameResolutionError, ReplayError error classes to support the copytrack feature. --- pm_trader/models.py | 85 +++++++++++++++++++++++++++++++++++++++++++- tests/test_models.py | 85 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) diff --git a/pm_trader/models.py b/pm_trader/models.py index 2ea1120..48a3f0b 100644 --- a/pm_trader/models.py +++ b/pm_trader/models.py @@ -3,7 +3,8 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any +from datetime import timedelta +from typing import Any, Literal # --------------------------------------------------------------------------- @@ -113,6 +114,29 @@ def __init__(self, message: str, status_code: int | None = None) -> None: self.status_code = status_code +class TargetNotFoundError(SimError): + code = "TARGET_NOT_FOUND" + + def __init__(self, identifier: str) -> None: + super().__init__(f"Copy-trade target not found: {identifier}") + self.identifier = identifier + + +class UsernameResolutionError(SimError): + code = "USERNAME_RESOLUTION_FAILED" + + def __init__(self, username: str) -> None: + super().__init__(f"Could not resolve username: {username}") + self.username = username + + +class ReplayError(SimError): + code = "REPLAY_FAILED" + + def __init__(self, message: str) -> None: + super().__init__(message) + + # --------------------------------------------------------------------------- # Market # --------------------------------------------------------------------------- @@ -319,3 +343,62 @@ class ResolveResult: position: Position payout: float account: Account + + +# --------------------------------------------------------------------------- +# Copytrack +# --------------------------------------------------------------------------- + +@dataclass +class SizingStrategy: + """How to size replayed trades.""" + + mode: Literal["equal", "proportional", "fixed"] + fixed_amount: float | None = None + scale: float | None = None + + +@dataclass +class Target: + """A Polymarket user to copy-trade.""" + + username: str + address: str + sizing: SizingStrategy + delay: timedelta + + +@dataclass +class TradeComparison: + """Per-trade comparison between original and replay.""" + + timestamp: str + market_slug: str + side: str + outcome: str + original_price: float + replay_price: float + price_diff: float + original_usd: float + replay_usd: float + + +@dataclass +class Comparison: + """Per-trade + aggregate comparison.""" + + per_trade: list[TradeComparison] + original_stats: dict + replay_stats: dict + summary: dict + + +@dataclass +class ReplayResult: + """Complete result of a copy-trade replay.""" + + target: Target + original_trades: list[dict] + replayed_trades: list[Trade] + skipped: list[dict] + comparison: Comparison diff --git a/tests/test_models.py b/tests/test_models.py index d5071fc..33e512e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,12 +2,22 @@ from __future__ import annotations +from datetime import timedelta + import pytest from pm_trader.models import ( + Comparison, Market, Position, + ReplayResult, + SizingStrategy, + Target, + TargetNotFoundError, TickSizeViolationError, + TradeComparison, + UsernameResolutionError, + ReplayError, ) @@ -86,3 +96,78 @@ def test_current_price(self): def test_percent_pnl_zero_cost(self): pos = self._pos(total_cost=0.0) assert pos.percent_pnl(0.80) == 0.0 + + +class TestCopytrackErrors: + def test_target_not_found(self): + e = TargetNotFoundError("@someuser") + assert e.code == "TARGET_NOT_FOUND" + assert "@someuser" in e.message + + def test_username_resolution_error(self): + e = UsernameResolutionError("@baduser") + assert e.code == "USERNAME_RESOLUTION_FAILED" + assert "@baduser" in e.message + + def test_replay_error(self): + e = ReplayError("something went wrong") + assert e.code == "REPLAY_FAILED" + + +class TestSizingStrategy: + def test_equal_sizing(self): + s = SizingStrategy(mode="equal", fixed_amount=None, scale=None) + assert s.mode == "equal" + + def test_proportional_sizing(self): + s = SizingStrategy(mode="proportional", fixed_amount=None, scale=0.1) + assert s.scale == 0.1 + + def test_fixed_sizing(self): + s = SizingStrategy(mode="fixed", fixed_amount=50.0, scale=None) + assert s.fixed_amount == 50.0 + + +class TestTarget: + def test_create_target(self): + sizing = SizingStrategy(mode="equal", fixed_amount=None, scale=None) + t = Target(username="testuser", address="0xabc123", sizing=sizing, delay=timedelta(seconds=0)) + assert t.username == "testuser" + assert t.address == "0xabc123" + assert t.delay == timedelta(seconds=0) + + +class TestTradeComparison: + def test_create(self): + tc = TradeComparison( + timestamp="2026-01-01T00:00:00Z", + market_slug="will-x-happen", + side="BUY", + outcome="yes", + original_price=0.65, + replay_price=0.65, + price_diff=0.0, + original_usd=100.0, + replay_usd=100.0, + ) + assert tc.price_diff == 0.0 + + +class TestComparison: + def test_create(self): + c = Comparison( + per_trade=[], + original_stats={"pnl": 100.0}, + replay_stats={"pnl": 90.0}, + summary={"pnl_diff": -10.0}, + ) + assert c.summary["pnl_diff"] == -10.0 + + +class TestReplayResult: + def test_create(self): + sizing = SizingStrategy(mode="equal", fixed_amount=None, scale=None) + target = Target(username="testuser", address="0xabc", sizing=sizing, delay=timedelta(seconds=0)) + comparison = Comparison(per_trade=[], original_stats={}, replay_stats={}, summary={}) + r = ReplayResult(target=target, original_trades=[], replayed_trades=[], skipped=[], comparison=comparison) + assert r.skipped == [] From 0088c0d15388d03547330d25a7d4c120c54a7a44 Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:24:19 -0400 Subject: [PATCH 02/19] feat: add copytrack database tables and update reset() Adds copytrack_targets and copytrack_replays tables to SCHEMA_SQL. Updates reset() to drop copytrack tables first (FK order) before re-creating the full schema. --- pm_trader/db.py | 32 +++++++++++++++++++++++++ tests/test_db.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/pm_trader/db.py b/pm_trader/db.py index 7260c27..512cf61 100644 --- a/pm_trader/db.py +++ b/pm_trader/db.py @@ -57,6 +57,36 @@ data TEXT NOT NULL, fetched_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +CREATE TABLE IF NOT EXISTS copytrack_targets ( + address TEXT PRIMARY KEY, + username TEXT NOT NULL, + sizing_mode TEXT NOT NULL DEFAULT 'equal', + sizing_param REAL, + delay_seconds INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL DEFAULT (datetime('now')), + profile TEXT +); + +CREATE TABLE IF NOT EXISTS copytrack_replays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT NOT NULL REFERENCES copytrack_targets(address), + username TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + start_date TEXT, + end_date TEXT, + sizing_mode TEXT NOT NULL, + sizing_param REAL, + delay_seconds INTEGER NOT NULL, + total_trades INTEGER, + replayed INTEGER, + skipped INTEGER, + original_stats TEXT, + replay_stats TEXT, + per_trade TEXT, + status TEXT NOT NULL DEFAULT 'running' +); """ @@ -139,6 +169,8 @@ def reset(self) -> None: """Drop all data and re-create schema.""" self.conn.executescript( """\ + DROP TABLE IF EXISTS copytrack_replays; + DROP TABLE IF EXISTS copytrack_targets; DROP TABLE IF EXISTS trades; DROP TABLE IF EXISTS positions; DROP TABLE IF EXISTS account; diff --git a/tests/test_db.py b/tests/test_db.py index 461bc8e..e00c6ff 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -755,3 +755,65 @@ def test_position_outcome_constraint(self, db: Database) -> None: ) VALUES ('0x1', 's', 'q', '', 10, 0.5, 5.0) """ ) + + +# ====================================================================== +# Copytrack tables +# ====================================================================== + +class TestCopytrackTables: + def test_copytrack_targets_table_exists(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + db.conn.execute( + "INSERT INTO copytrack_targets (address, username) VALUES (?, ?)", + ("0xabc123", "testuser"), + ) + db.conn.commit() + row = db.conn.execute( + "SELECT * FROM copytrack_targets WHERE address = '0xabc123'" + ).fetchone() + assert row is not None + assert row["username"] == "testuser" + assert row["sizing_mode"] == "equal" + assert row["delay_seconds"] == 0 + db.close() + + def test_copytrack_replays_table_exists(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + db.conn.execute( + "INSERT INTO copytrack_targets (address, username) VALUES (?, ?)", + ("0xabc123", "testuser"), + ) + db.conn.execute( + """\ + INSERT INTO copytrack_replays + (address, username, started_at, sizing_mode, delay_seconds, status) + VALUES (?, ?, datetime('now'), ?, ?, ?) + """, + ("0xabc123", "testuser", "equal", 0, "running"), + ) + db.conn.commit() + row = db.conn.execute( + "SELECT * FROM copytrack_replays WHERE address = '0xabc123'" + ).fetchone() + assert row is not None + assert row["status"] == "running" + db.close() + + def test_reset_drops_copytrack_tables(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + db.init_account(10000) + db.conn.execute( + "INSERT INTO copytrack_targets (address, username) VALUES (?, ?)", + ("0xabc123", "testuser"), + ) + db.conn.commit() + db.reset() + row = db.conn.execute( + "SELECT COUNT(*) as cnt FROM copytrack_targets" + ).fetchone() + assert row["cnt"] == 0 + db.close() From 59836fa57d0593b91d1dd5612730b7cff07237cb Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:27:14 -0400 Subject: [PATCH 03/19] feat: add PolymarketDataClient with get_activity Creates pm_trader/data_api.py with PolymarketDataClient and get_activity() method that fetches a single page of user activity from the Polymarket Data API, with optional time range and sort parameters. --- pm_trader/data_api.py | 59 +++++++++++++++++++++++++++++++++ tests/test_data_api.py | 74 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 pm_trader/data_api.py create mode 100644 tests/test_data_api.py diff --git a/pm_trader/data_api.py b/pm_trader/data_api.py new file mode 100644 index 0000000..19b370e --- /dev/null +++ b/pm_trader/data_api.py @@ -0,0 +1,59 @@ +"""Polymarket Data API client. + +Fetches user trade history, positions, and profiles from the public +Polymarket Data API. No authentication required. +""" + +from __future__ import annotations + +import httpx + +from pm_trader.models import ApiError + +DATA_API_BASE = "https://data-api.polymarket.com" +GAMMA_BASE = "https://gamma-api.polymarket.com" + +_TIMEOUT = httpx.Timeout(15.0) + + +class PolymarketDataClient: + """Client for the Polymarket Data API — public, no auth.""" + + def __init__(self, *, page_delay: float = 0.2) -> None: + self._http = httpx.Client(timeout=_TIMEOUT) + self._page_delay = page_delay + self._address_cache: dict[str, str] = {} + + def close(self) -> None: + self._http.close() + + def get_activity( + self, + address: str, + *, + type: str = "TRADE", + limit: int = 500, + start: int | None = None, + end: int | None = None, + sort_by: str | None = None, + sort_direction: str | None = None, + ) -> list[dict]: + """Fetch user activity (single page).""" + params: dict[str, str | int] = { + "user": address, + "type": type, + "limit": limit, + } + if start is not None: + params["start"] = start + if end is not None: + params["end"] = end + if sort_by is not None: + params["sortBy"] = sort_by + if sort_direction is not None: + params["sortDirection"] = sort_direction + + resp = self._http.get(f"{DATA_API_BASE}/activity", params=params) + if resp.status_code != 200: + raise ApiError(f"Data API error: {resp.status_code}", status_code=resp.status_code) + return resp.json() diff --git a/tests/test_data_api.py b/tests/test_data_api.py new file mode 100644 index 0000000..b25af5b --- /dev/null +++ b/tests/test_data_api.py @@ -0,0 +1,74 @@ +"""Tests for pm_trader.data_api — Polymarket Data API client.""" + +from __future__ import annotations + +import pytest + +from pm_trader.data_api import PolymarketDataClient +from pm_trader.models import ApiError, UsernameResolutionError + + +class TestGetActivity: + def test_get_activity_returns_trades(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500", + json=[ + { + "timestamp": 1704067200, + "conditionId": "0xcond1", + "slug": "will-x-happen", + "title": "Will X happen?", + "outcome": "Yes", + "side": "BUY", + "price": 0.65, + "size": 100.0, + "usdcSize": 65.0, + "transactionHash": "0xtx1", + "eventSlug": "event-x", + } + ], + ) + client = PolymarketDataClient() + trades = client.get_activity("0xabc") + assert len(trades) == 1 + assert trades[0]["slug"] == "will-x-happen" + client.close() + + def test_get_activity_with_time_range(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&start=1000&end=2000", + json=[], + ) + client = PolymarketDataClient() + assert client.get_activity("0xabc", start=1000, end=2000) == [] + client.close() + + def test_get_activity_empty(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500", + json=[], + ) + client = PolymarketDataClient() + assert client.get_activity("0xabc") == [] + client.close() + + def test_get_activity_api_error(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500", + status_code=500, + ) + client = PolymarketDataClient() + with pytest.raises(ApiError) as exc_info: + client.get_activity("0xabc") + assert exc_info.value.status_code == 500 + client.close() + + def test_get_activity_with_sort_params(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=[], + ) + client = PolymarketDataClient() + result = client.get_activity("0xabc", sort_by="TIMESTAMP", sort_direction="DESC") + assert result == [] + client.close() From f42b4fdd3c4c2fa4f4f4097db189427b4a99bb49 Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:27:58 -0400 Subject: [PATCH 04/19] feat: add get_all_trades, get_positions, get_profile to PolymarketDataClient Adds auto-paginating get_all_trades() for complete trade history retrieval, get_positions() for current user positions, and get_profile() for public profile lookup via the Gamma API. --- pm_trader/data_api.py | 50 ++++++++++++++++++ tests/test_data_api.py | 114 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/pm_trader/data_api.py b/pm_trader/data_api.py index 19b370e..55be9d0 100644 --- a/pm_trader/data_api.py +++ b/pm_trader/data_api.py @@ -6,6 +6,8 @@ from __future__ import annotations +import time + import httpx from pm_trader.models import ApiError @@ -57,3 +59,51 @@ def get_activity( if resp.status_code != 200: raise ApiError(f"Data API error: {resp.status_code}", status_code=resp.status_code) return resp.json() + + def get_all_trades( + self, + address: str, + *, + start: int | None = None, + end: int | None = None, + max_trades: int = 10_000, + ) -> list[dict]: + """Fetch complete trade history with auto-pagination.""" + all_trades: list[dict] = [] + current_end = end + while len(all_trades) < max_trades: + page = self.get_activity( + address, + start=start, + end=current_end, + limit=500, + type="TRADE", + sort_by="TIMESTAMP", + sort_direction="DESC", + ) + if not page: + break + remaining = max_trades - len(all_trades) + all_trades.extend(page[:remaining]) + if len(page) < 500: + break + last_ts = page[-1].get("timestamp") + if last_ts is None or (current_end is not None and last_ts >= current_end): + break + current_end = last_ts + time.sleep(self._page_delay) + return all_trades + + def get_positions(self, address: str) -> list[dict]: + """Fetch current positions for a user.""" + resp = self._http.get(f"{DATA_API_BASE}/positions", params={"user": address}) + if resp.status_code != 200: + raise ApiError(f"Data API error: {resp.status_code}", status_code=resp.status_code) + return resp.json() + + def get_profile(self, address: str) -> dict: + """Fetch public profile by wallet address.""" + resp = self._http.get(f"{GAMMA_BASE}/public-profile", params={"address": address}) + if resp.status_code != 200: + raise ApiError(f"Data API error: {resp.status_code}", status_code=resp.status_code) + return resp.json() diff --git a/tests/test_data_api.py b/tests/test_data_api.py index b25af5b..cba1bc4 100644 --- a/tests/test_data_api.py +++ b/tests/test_data_api.py @@ -72,3 +72,117 @@ def test_get_activity_with_sort_params(self, httpx_mock): result = client.get_activity("0xabc", sort_by="TIMESTAMP", sort_direction="DESC") assert result == [] client.close() + + +class TestGetAllTrades: + def test_single_page(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=[{"timestamp": 1000, "slug": "m1"}, {"timestamp": 900, "slug": "m2"}], + ) + client = PolymarketDataClient(page_delay=0) + trades = client.get_all_trades("0xabc") + assert len(trades) == 2 + client.close() + + def test_multi_page(self, httpx_mock): + page1 = [{"timestamp": 1000 - i, "slug": f"m{i}"} for i in range(500)] + page2 = [{"timestamp": 400, "slug": "mlast"}] + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=page1, + ) + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC&end=501", + json=page2, + ) + client = PolymarketDataClient(page_delay=0) + trades = client.get_all_trades("0xabc") + assert len(trades) == 501 + client.close() + + def test_max_trades_cap(self, httpx_mock): + page = [{"timestamp": 1000 - i, "slug": f"m{i}"} for i in range(500)] + httpx_mock.add_response(json=page) + client = PolymarketDataClient(page_delay=0) + trades = client.get_all_trades("0xabc", max_trades=100) + assert len(trades) == 100 + client.close() + + def test_empty_first_page(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=[], + ) + client = PolymarketDataClient(page_delay=0) + trades = client.get_all_trades("0xabc") + assert trades == [] + client.close() + + def test_with_start_and_end(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&start=100&end=2000&sortBy=TIMESTAMP&sortDirection=DESC", + json=[{"timestamp": 1500, "slug": "m1"}], + ) + client = PolymarketDataClient(page_delay=0) + trades = client.get_all_trades("0xabc", start=100, end=2000) + assert len(trades) == 1 + client.close() + + def test_pagination_stops_when_last_ts_none(self, httpx_mock): + page1 = [{"slug": f"m{i}"} for i in range(500)] # no timestamp field + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=page1, + ) + client = PolymarketDataClient(page_delay=0) + trades = client.get_all_trades("0xabc") + assert len(trades) == 500 + client.close() + + +class TestGetPositions: + def test_get_positions(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/positions?user=0xabc", + json=[{"conditionId": "0xcond", "size": 100, "curPrice": 0.7}], + ) + client = PolymarketDataClient() + positions = client.get_positions("0xabc") + assert len(positions) == 1 + assert positions[0]["size"] == 100 + client.close() + + def test_get_positions_api_error(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/positions?user=0xabc", + status_code=404, + ) + client = PolymarketDataClient() + with pytest.raises(ApiError) as exc_info: + client.get_positions("0xabc") + assert exc_info.value.status_code == 404 + client.close() + + +class TestGetProfile: + def test_get_profile(self, httpx_mock): + httpx_mock.add_response( + url="https://gamma-api.polymarket.com/public-profile?address=0xabc", + json={"name": "testuser", "pseudonym": "Test", "bio": "trader"}, + ) + client = PolymarketDataClient() + profile = client.get_profile("0xabc") + assert profile["name"] == "testuser" + client.close() + + def test_get_profile_api_error(self, httpx_mock): + httpx_mock.add_response( + url="https://gamma-api.polymarket.com/public-profile?address=0xabc", + status_code=404, + ) + client = PolymarketDataClient() + with pytest.raises(ApiError) as exc_info: + client.get_profile("0xabc") + assert exc_info.value.status_code == 404 + client.close() From 32309b1d39a9ddacabb1d37b4e746df2b51db51b Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:28:44 -0400 Subject: [PATCH 05/19] feat: add resolve_username to PolymarketDataClient Adds resolve_username() that scrapes the Polymarket profile page to extract a wallet address from the Next.js __NEXT_DATA__ payload, with in-memory caching to avoid duplicate requests. --- pm_trader/data_api.py | 27 ++++++++++++++++++- tests/test_data_api.py | 61 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/pm_trader/data_api.py b/pm_trader/data_api.py index 55be9d0..2f0c98c 100644 --- a/pm_trader/data_api.py +++ b/pm_trader/data_api.py @@ -6,11 +6,13 @@ from __future__ import annotations +import json as _json +import re import time import httpx -from pm_trader.models import ApiError +from pm_trader.models import ApiError, UsernameResolutionError DATA_API_BASE = "https://data-api.polymarket.com" GAMMA_BASE = "https://gamma-api.polymarket.com" @@ -107,3 +109,26 @@ def get_profile(self, address: str) -> dict: if resp.status_code != 200: raise ApiError(f"Data API error: {resp.status_code}", status_code=resp.status_code) return resp.json() + + def resolve_username(self, username: str) -> str: + """Resolve @username to wallet address. Caches results.""" + username = username.lstrip("@") + if username in self._address_cache: + return self._address_cache[username] + + resp = self._http.get(f"https://polymarket.com/@{username}") + if resp.status_code != 200: + raise UsernameResolutionError(f"@{username}") + + match = re.search(r'', resp.text) + if not match: + raise UsernameResolutionError(f"@{username}") + + try: + data = _json.loads(match.group(1)) + address = data["props"]["pageProps"]["address"] + except (KeyError, _json.JSONDecodeError): + raise UsernameResolutionError(f"@{username}") + + self._address_cache[username] = address + return address diff --git a/tests/test_data_api.py b/tests/test_data_api.py index cba1bc4..b342fce 100644 --- a/tests/test_data_api.py +++ b/tests/test_data_api.py @@ -186,3 +186,64 @@ def test_get_profile_api_error(self, httpx_mock): client.get_profile("0xabc") assert exc_info.value.status_code == 404 client.close() + + +class TestResolveUsername: + def test_resolve_returns_address(self, httpx_mock): + httpx_mock.add_response( + url="https://polymarket.com/@testuser", + text='', + ) + client = PolymarketDataClient() + address = client.resolve_username("testuser") + assert address == "0xd8f8c13644ea84d62e1ec88c5d1215e436eb0f11" + client.close() + + def test_resolve_caches_result(self, httpx_mock): + httpx_mock.add_response( + url="https://polymarket.com/@testuser", + text='', + ) + client = PolymarketDataClient() + addr1 = client.resolve_username("testuser") + addr2 = client.resolve_username("testuser") + assert addr1 == addr2 + assert len(httpx_mock.get_requests()) == 1 + client.close() + + def test_resolve_strips_at_sign(self, httpx_mock): + httpx_mock.add_response( + url="https://polymarket.com/@testuser", + text='', + ) + client = PolymarketDataClient() + address = client.resolve_username("@testuser") + assert address == "0xabc" + client.close() + + def test_resolve_fails_raises_error(self, httpx_mock): + httpx_mock.add_response(url="https://polymarket.com/@baduser", status_code=404) + client = PolymarketDataClient() + with pytest.raises(UsernameResolutionError): + client.resolve_username("baduser") + client.close() + + def test_resolve_no_next_data_raises_error(self, httpx_mock): + httpx_mock.add_response( + url="https://polymarket.com/@testuser", + text="No script here", + ) + client = PolymarketDataClient() + with pytest.raises(UsernameResolutionError): + client.resolve_username("testuser") + client.close() + + def test_resolve_missing_address_key_raises_error(self, httpx_mock): + httpx_mock.add_response( + url="https://polymarket.com/@testuser", + text='', + ) + client = PolymarketDataClient() + with pytest.raises(UsernameResolutionError): + client.resolve_username("testuser") + client.close() From 2642626f6963478bda7a5690a713582110438a7c Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:30:58 -0400 Subject: [PATCH 06/19] feat: add copytrack.py with compute_replay_amount sizing logic Implements Task 7: creates pm_trader/copytrack.py with compute_replay_amount() supporting equal, proportional, and fixed sizing modes, with tests. --- pm_trader/copytrack.py | 21 +++++++++++++++++++++ tests/test_copytrack.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 pm_trader/copytrack.py create mode 100644 tests/test_copytrack.py diff --git a/pm_trader/copytrack.py b/pm_trader/copytrack.py new file mode 100644 index 0000000..50e4953 --- /dev/null +++ b/pm_trader/copytrack.py @@ -0,0 +1,21 @@ +"""Copy-trading replay engine. + +Fetches a target user's trade history, replays it through the paper +trader, and produces per-trade + aggregate comparisons. +""" + +from __future__ import annotations + +from pm_trader.models import SizingStrategy + + +def compute_replay_amount(original_usd: float, sizing: SizingStrategy) -> float: + """Calculate the USD amount for a replayed trade.""" + if sizing.mode == "equal": + return original_usd + elif sizing.mode == "proportional": + scale = sizing.scale if sizing.scale is not None else 1.0 + return original_usd * scale + elif sizing.mode == "fixed": + return sizing.fixed_amount if sizing.fixed_amount is not None else 10.0 + return original_usd diff --git a/tests/test_copytrack.py b/tests/test_copytrack.py new file mode 100644 index 0000000..6d55ad4 --- /dev/null +++ b/tests/test_copytrack.py @@ -0,0 +1,29 @@ +"""Tests for pm_trader.copytrack — copy-trading replay engine.""" +from __future__ import annotations + +import pytest + +from pm_trader.copytrack import compute_replay_amount +from pm_trader.models import SizingStrategy + + +class TestComputeReplayAmount: + def test_equal_sizing(self): + sizing = SizingStrategy(mode="equal", fixed_amount=None, scale=None) + assert compute_replay_amount(100.0, sizing) == 100.0 + + def test_proportional_sizing(self): + sizing = SizingStrategy(mode="proportional", fixed_amount=None, scale=0.1) + assert compute_replay_amount(1000.0, sizing) == 100.0 + + def test_fixed_sizing(self): + sizing = SizingStrategy(mode="fixed", fixed_amount=50.0, scale=None) + assert compute_replay_amount(999.99, sizing) == 50.0 + + def test_proportional_default_scale(self): + sizing = SizingStrategy(mode="proportional", fixed_amount=None, scale=None) + assert compute_replay_amount(100.0, sizing) == 100.0 + + def test_fixed_default_amount(self): + sizing = SizingStrategy(mode="fixed", fixed_amount=None, scale=None) + assert compute_replay_amount(100.0, sizing) == 10.0 From eb604d1ee5034f946b8eca8235e72a78f601e438 Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:31:37 -0400 Subject: [PATCH 07/19] feat: add compute_original_stats for original-side trade metrics Implements Task 8: adds compute_original_stats() to copytrack.py, computing buy/sell counts, total invested/returned, PnL, and win rate from raw Polymarket Data API trade records. --- pm_trader/copytrack.py | 32 ++++++++++++++++++++++++++++++++ tests/test_copytrack.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/pm_trader/copytrack.py b/pm_trader/copytrack.py index 50e4953..e6d3f16 100644 --- a/pm_trader/copytrack.py +++ b/pm_trader/copytrack.py @@ -6,6 +6,8 @@ from __future__ import annotations +from collections import defaultdict + from pm_trader.models import SizingStrategy @@ -19,3 +21,33 @@ def compute_replay_amount(original_usd: float, sizing: SizingStrategy) -> float: elif sizing.mode == "fixed": return sizing.fixed_amount if sizing.fixed_amount is not None else 10.0 return original_usd + + +def compute_original_stats(trades: list[dict]) -> dict: + """Compute aggregate stats from raw Data API trade data.""" + if not trades: + return {"total_trades": 0, "buy_count": 0, "sell_count": 0, + "total_invested": 0.0, "total_returned": 0.0, "pnl": 0.0, "win_rate": 0.0} + buys = [t for t in trades if t.get("side") == "BUY"] + sells = [t for t in trades if t.get("side") == "SELL"] + total_invested = sum(t.get("usdcSize", 0.0) for t in buys) + total_returned = sum(t.get("usdcSize", 0.0) for t in sells) + + buy_cost: dict[tuple[str, str], float] = defaultdict(float) + buy_shares: dict[tuple[str, str], float] = defaultdict(float) + for t in buys: + key = (t.get("conditionId", ""), t.get("outcome", "")) + buy_cost[key] += t.get("usdcSize", 0.0) + buy_shares[key] += t.get("size", 0.0) + wins = 0 + for t in sells: + key = (t.get("conditionId", ""), t.get("outcome", "")) + total_sh = buy_shares.get(key, 0.0) + entry_price = buy_cost[key] / total_sh if total_sh > 0 else t.get("price", 0.0) + if t.get("price", 0.0) > entry_price: + wins += 1 + win_rate = wins / len(sells) if sells else 0.0 + + return {"total_trades": len(trades), "buy_count": len(buys), "sell_count": len(sells), + "total_invested": total_invested, "total_returned": total_returned, + "pnl": total_returned - total_invested, "win_rate": win_rate} diff --git a/tests/test_copytrack.py b/tests/test_copytrack.py index 6d55ad4..fdb7c07 100644 --- a/tests/test_copytrack.py +++ b/tests/test_copytrack.py @@ -3,7 +3,7 @@ import pytest -from pm_trader.copytrack import compute_replay_amount +from pm_trader.copytrack import compute_original_stats, compute_replay_amount from pm_trader.models import SizingStrategy @@ -27,3 +27,33 @@ def test_proportional_default_scale(self): def test_fixed_default_amount(self): sizing = SizingStrategy(mode="fixed", fixed_amount=None, scale=None) assert compute_replay_amount(100.0, sizing) == 10.0 + + +class TestComputeOriginalStats: + def test_basic_stats(self): + trades = [ + {"side": "BUY", "usdcSize": 100.0, "price": 0.5, "size": 200.0, "conditionId": "c1", "outcome": "Yes", "slug": "m1"}, + {"side": "SELL", "usdcSize": 150.0, "price": 0.75, "size": 200.0, "conditionId": "c1", "outcome": "Yes", "slug": "m1"}, + ] + stats = compute_original_stats(trades) + assert stats["total_trades"] == 2 + assert stats["buy_count"] == 1 + assert stats["sell_count"] == 1 + assert stats["total_invested"] == 100.0 + assert stats["total_returned"] == 150.0 + assert stats["pnl"] == pytest.approx(50.0) + + def test_empty_trades(self): + stats = compute_original_stats([]) + assert stats["total_trades"] == 0 + assert stats["pnl"] == 0.0 + + def test_win_rate(self): + trades = [ + {"side": "BUY", "usdcSize": 100.0, "price": 0.5, "size": 200.0, "conditionId": "c1", "outcome": "Yes", "slug": "m1"}, + {"side": "SELL", "usdcSize": 120.0, "price": 0.6, "size": 200.0, "conditionId": "c1", "outcome": "Yes", "slug": "m1"}, + {"side": "BUY", "usdcSize": 100.0, "price": 0.5, "size": 200.0, "conditionId": "c2", "outcome": "Yes", "slug": "m2"}, + {"side": "SELL", "usdcSize": 80.0, "price": 0.4, "size": 200.0, "conditionId": "c2", "outcome": "Yes", "slug": "m2"}, + ] + stats = compute_original_stats(trades) + assert stats["win_rate"] == pytest.approx(0.5) From 12dd0557ca1480527ce43cc1de10abe9fabb6231 Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:32:49 -0400 Subject: [PATCH 08/19] feat: add replay() function to copytrack engine Implements Task 9: adds replay() to copytrack.py that replays a target user's trades directly into the paper trader DB at original prices, handles buy/sell sizing modes, skips on insufficient balance or missing position, and produces a full ReplayResult with per-trade and aggregate comparisons. --- pm_trader/copytrack.py | 145 ++++++++++++++++++++++++++++++++++++++++ tests/test_copytrack.py | 92 ++++++++++++++++++++++++- 2 files changed, 235 insertions(+), 2 deletions(-) diff --git a/pm_trader/copytrack.py b/pm_trader/copytrack.py index e6d3f16..14b6699 100644 --- a/pm_trader/copytrack.py +++ b/pm_trader/copytrack.py @@ -51,3 +51,148 @@ def compute_original_stats(trades: list[dict]) -> dict: return {"total_trades": len(trades), "buy_count": len(buys), "sell_count": len(sells), "total_invested": total_invested, "total_returned": total_returned, "pnl": total_returned - total_invested, "win_rate": win_rate} + + +def replay( + target: "Target", + engine: "Engine", + data_client: "PolymarketDataClient", + *, + original_trades: list[dict] | None = None, + start: int | None = None, + end: int | None = None, +) -> "ReplayResult": + """Replay a target user's trades in the paper trader. + For delay=0, uses original trade price by writing directly to DB. + """ + from pm_trader.analytics import compute_stats + from pm_trader.data_api import PolymarketDataClient + from pm_trader.engine import Engine + from pm_trader.models import ( + Comparison, ReplayResult, Target, TradeComparison, + ) + + if original_trades is None: + original_trades = data_client.get_all_trades(target.address, start=start, end=end) + + sorted_trades = sorted(original_trades, key=lambda t: t.get("timestamp", 0)) + replayed = [] + skipped = [] + per_trade_comparisons = [] + + for orig in sorted_trades: + side = orig.get("side", "").upper() + original_usd = orig.get("usdcSize", 0.0) + original_price = orig.get("price", 0.0) + original_shares = orig.get("size", 0.0) + outcome = orig.get("outcome", "").lower() + condition_id = orig.get("conditionId", "") + slug = orig.get("slug", "") + title = orig.get("title", slug) + event_slug = orig.get("eventSlug", "") + timestamp = orig.get("timestamp", 0) + + replay_usd = compute_replay_amount(original_usd, target.sizing) + + if side == "BUY": + account = engine.get_account() + if account.cash < replay_usd: + skipped.append({**orig, "reason": f"Insufficient balance: need ${replay_usd:.2f}, have ${account.cash:.2f}"}) + continue + if original_price <= 0: + skipped.append({**orig, "reason": "Original price is 0"}) + continue + replay_shares = replay_usd / original_price + + trade = engine.db.insert_trade( + market_condition_id=condition_id, market_slug=slug, + market_question=title, event_slug=event_slug, + outcome=outcome, side="buy", order_type="fok", + avg_price=original_price, amount_usd=replay_usd, + shares=replay_shares, fee_rate_bps=0, fee=0.0, + slippage=0.0, levels_filled=1, is_partial=False) + + existing = engine.db.get_position(condition_id, outcome) + if existing and existing.shares > 0: + total_shares = existing.shares + replay_shares + total_cost = existing.total_cost + replay_usd + new_avg = total_cost / total_shares if total_shares > 0 else 0.0 + else: + total_shares = replay_shares + total_cost = replay_usd + new_avg = original_price + + engine.db.upsert_position( + market_condition_id=condition_id, market_slug=slug, + market_question=title, outcome=outcome, + shares=total_shares, avg_entry_price=new_avg, total_cost=total_cost) + engine.db.update_cash(account.cash - replay_usd) + replayed.append(trade) + replay_usd_final = replay_usd + + elif side == "SELL": + pos = engine.db.get_position(condition_id, outcome) + if pos is None or pos.shares <= 0: + skipped.append({**orig, "reason": "No position to sell"}) + continue + + if target.sizing.mode == "equal": + sell_shares = min(original_shares, pos.shares) + elif target.sizing.mode == "proportional": + scale = target.sizing.scale or 1.0 + sell_shares = min(original_shares * scale, pos.shares) + else: + sell_shares = pos.shares + + replay_usd_sell = sell_shares * original_price + cost_of_sold = sell_shares * pos.avg_entry_price + realized = replay_usd_sell - cost_of_sold + + trade = engine.db.insert_trade( + market_condition_id=condition_id, market_slug=slug, + market_question=title, event_slug=event_slug, + outcome=outcome, side="sell", order_type="fok", + avg_price=original_price, amount_usd=replay_usd_sell, + shares=sell_shares, fee_rate_bps=0, fee=0.0, + slippage=0.0, levels_filled=1, is_partial=False) + + remaining_shares = pos.shares - sell_shares + remaining_cost = pos.total_cost - cost_of_sold + new_realized = pos.realized_pnl + realized + + engine.db.upsert_position( + market_condition_id=condition_id, market_slug=slug, + market_question=title, outcome=outcome, + shares=remaining_shares, avg_entry_price=pos.avg_entry_price, + total_cost=max(remaining_cost, 0.0), realized_pnl=new_realized) + + account = engine.get_account() + engine.db.update_cash(account.cash + replay_usd_sell) + replayed.append(trade) + replay_usd_final = replay_usd_sell + else: + skipped.append({**orig, "reason": f"Unknown side: {side}"}) + continue + + per_trade_comparisons.append(TradeComparison( + timestamp=str(timestamp), market_slug=slug, side=side, outcome=outcome, + original_price=original_price, replay_price=original_price, + price_diff=0.0, original_usd=original_usd, replay_usd=replay_usd_final)) + + original_stats = compute_original_stats(sorted_trades) + account = engine.get_account() + all_trades = engine.db.get_trades(limit=100_000) + replay_stats = compute_stats(all_trades, account, positions_value=0.0) + + summary = { + "pnl_diff": replay_stats["pnl"] - original_stats["pnl"], + "total_trades_original": original_stats["total_trades"], + "total_trades_replayed": len(replayed), + "total_skipped": len(skipped), + } + + comparison = Comparison(per_trade=per_trade_comparisons, + original_stats=original_stats, replay_stats=replay_stats, summary=summary) + + return ReplayResult(target=target, original_trades=sorted_trades, + replayed_trades=replayed, skipped=skipped, comparison=comparison) diff --git a/tests/test_copytrack.py b/tests/test_copytrack.py index fdb7c07..2f5ed7f 100644 --- a/tests/test_copytrack.py +++ b/tests/test_copytrack.py @@ -1,10 +1,14 @@ """Tests for pm_trader.copytrack — copy-trading replay engine.""" from __future__ import annotations +from datetime import timedelta + import pytest -from pm_trader.copytrack import compute_original_stats, compute_replay_amount -from pm_trader.models import SizingStrategy +from pm_trader.copytrack import compute_original_stats, compute_replay_amount, replay +from pm_trader.data_api import PolymarketDataClient +from pm_trader.engine import Engine +from pm_trader.models import SizingStrategy, Target class TestComputeReplayAmount: @@ -57,3 +61,87 @@ def test_win_rate(self): ] stats = compute_original_stats(trades) assert stats["win_rate"] == pytest.approx(0.5) + + +class TestReplay: + def _make_engine(self, tmp_path): + engine = Engine(tmp_path) + engine.init_account(10_000.0) + return engine + + def test_replay_basic_buy(self, tmp_path, httpx_mock): + engine = self._make_engine(tmp_path) + client = PolymarketDataClient() + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [{"timestamp": 1704067200, "conditionId": "0xcond1", "slug": "will-x-happen", + "title": "Will X happen?", "outcome": "Yes", "side": "BUY", "price": 0.65, + "size": 100.0, "usdcSize": 65.0, "eventSlug": "event-x"}] + result = replay(target, engine, client, original_trades=original_trades) + assert result.comparison.replay_stats["total_trades"] == 1 + assert len(result.replayed_trades) == 1 + assert result.replayed_trades[0].side == "buy" + assert result.replayed_trades[0].avg_price == pytest.approx(0.65) + assert len(result.skipped) == 0 + account = engine.get_account() + assert account.cash < 10_000.0 + engine.close() + client.close() + + def test_replay_buy_and_sell(self, tmp_path, httpx_mock): + engine = self._make_engine(tmp_path) + client = PolymarketDataClient() + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", "title": "Market 1?", + "outcome": "Yes", "side": "BUY", "price": 0.50, "size": 100.0, "usdcSize": 50.0, "eventSlug": ""}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", "title": "Market 1?", + "outcome": "Yes", "side": "SELL", "price": 0.70, "size": 100.0, "usdcSize": 70.0, "eventSlug": ""}, + ] + result = replay(target, engine, client, original_trades=original_trades) + assert len(result.replayed_trades) == 2 + assert result.comparison.per_trade[0].side == "BUY" + assert result.comparison.per_trade[1].side == "SELL" + engine.close() + client.close() + + def test_replay_proportional_sizing(self, tmp_path, httpx_mock): + engine = self._make_engine(tmp_path) + client = PolymarketDataClient() + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="proportional", scale=0.5), delay=timedelta(seconds=0)) + original_trades = [{"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market 1?", "outcome": "Yes", "side": "BUY", "price": 0.50, + "size": 200.0, "usdcSize": 100.0, "eventSlug": ""}] + result = replay(target, engine, client, original_trades=original_trades) + assert result.replayed_trades[0].amount_usd == pytest.approx(50.0) + engine.close() + client.close() + + def test_replay_skips_insufficient_balance(self, tmp_path, httpx_mock): + engine = self._make_engine(tmp_path) + engine.db.update_cash(0.50) + client = PolymarketDataClient() + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [{"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market 1?", "outcome": "Yes", "side": "BUY", "price": 0.50, + "size": 200.0, "usdcSize": 100.0, "eventSlug": ""}] + result = replay(target, engine, client, original_trades=original_trades) + assert len(result.replayed_trades) == 0 + assert len(result.skipped) == 1 + assert "insufficient" in result.skipped[0]["reason"].lower() + engine.close() + client.close() + + def test_replay_empty_trades(self, tmp_path, httpx_mock): + engine = self._make_engine(tmp_path) + client = PolymarketDataClient() + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + result = replay(target, engine, client, original_trades=[]) + assert len(result.replayed_trades) == 0 + assert result.comparison.original_stats["total_trades"] == 0 + engine.close() + client.close() From eec8268fe7eb514bce74570b037e7117b715cc30 Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:38:18 -0400 Subject: [PATCH 09/19] feat: add copytrack add/list/remove CLI commands Add copytrack command group with add (by @username or 0x address), list (all tracked targets), and remove subcommands. Includes 5 tests. --- pm_trader/cli.py | 105 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 42 +++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/pm_trader/cli.py b/pm_trader/cli.py index cc9f7aa..78e6f9f 100644 --- a/pm_trader/cli.py +++ b/pm_trader/cli.py @@ -798,6 +798,111 @@ def watch(ctx: click.Context, slugs_or_ids: tuple[str, ...], outcomes: tuple[str sys.exit(1) +# --------------------------------------------------------------------------- +# copytrack commands +# --------------------------------------------------------------------------- + +@main.group() +def copytrack(): + """Copy-trade management — track and replay Polymarket users.""" + pass + + +@copytrack.command("add") +@click.argument("identifier") +@click.option("--sizing", type=click.Choice(["equal", "proportional", "fixed"]), default="equal") +@click.option("--scale", type=float, default=None, help="Scale factor for proportional sizing.") +@click.option("--fixed-amount", type=float, default=None, help="Fixed USD amount per trade.") +@click.option("--delay", type=int, default=0, help="Delay in seconds.") +@click.pass_context +def copytrack_add(ctx: click.Context, identifier: str, sizing: str, scale: float | None, fixed_amount: float | None, delay: int) -> None: + """Add a copy-trade target. IDENTIFIER can be @username or 0x... address.""" + from pm_trader.data_api import PolymarketDataClient + + account_dir = _get_account_dir(ctx) + engine = Engine(account_dir) + + try: + if identifier.startswith("0x"): + address = identifier + username = identifier[:10] + "..." + else: + client = PolymarketDataClient() + try: + address = client.resolve_username(identifier) + username = identifier.lstrip("@") + finally: + client.close() + + sizing_param = scale if sizing == "proportional" else fixed_amount + + engine.db.conn.execute( + """INSERT OR REPLACE INTO copytrack_targets + (address, username, sizing_mode, sizing_param, delay_seconds) + VALUES (?, ?, ?, ?, ?)""", + (address, username, sizing, sizing_param, delay), + ) + engine.db.conn.commit() + + click.echo(_ok({ + "address": address, + "username": username, + "sizing_mode": sizing, + "sizing_param": sizing_param, + "delay_seconds": delay, + })) + except SimError as e: + click.echo(_err(e)) + finally: + engine.close() + + +@copytrack.command("list") +@click.pass_context +def copytrack_list(ctx: click.Context) -> None: + """List all copy-trade targets.""" + account_dir = _get_account_dir(ctx) + engine = Engine(account_dir) + + try: + rows = engine.db.conn.execute("SELECT * FROM copytrack_targets ORDER BY added_at").fetchall() + targets = [dict(row) for row in rows] + click.echo(_ok(targets)) + finally: + engine.close() + + +@copytrack.command("remove") +@click.argument("identifier") +@click.pass_context +def copytrack_remove(ctx: click.Context, identifier: str) -> None: + """Remove a copy-trade target by address or username.""" + from pm_trader.models import TargetNotFoundError + + account_dir = _get_account_dir(ctx) + engine = Engine(account_dir) + + try: + identifier_clean = identifier.lstrip("@") + engine.db.conn.execute( + "DELETE FROM copytrack_replays WHERE address IN " + "(SELECT address FROM copytrack_targets WHERE address = ? OR username = ?)", + (identifier_clean, identifier_clean), + ) + cursor = engine.db.conn.execute( + "DELETE FROM copytrack_targets WHERE address = ? OR username = ?", + (identifier_clean, identifier_clean), + ) + engine.db.conn.commit() + if cursor.rowcount == 0: + raise TargetNotFoundError(identifier) + click.echo(_ok({"removed": identifier})) + except SimError as e: + click.echo(_err(e)) + finally: + engine.close() + + # --------------------------------------------------------------------------- # Web dashboard # --------------------------------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py index 2c3fa6b..5f61a24 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1098,3 +1098,45 @@ def test_web_reload(self, mock_run): "pm_trader.web.app:create_app", factory=True, host="127.0.0.1", port=9999, reload=True, ) + + +class TestCopytrackCLI: + def test_copytrack_add(self, runner, data_dir, httpx_mock): + httpx_mock.add_response( + url="https://polymarket.com/@testuser", + text='', + ) + result = _invoke(runner, ["copytrack", "add", "@testuser"], data_dir) + assert result.exit_code == 0 + data = _parse(result) + assert data["ok"] is True + assert data["data"]["address"] == "0xabc123" + + def test_copytrack_add_raw_address(self, runner, data_dir): + result = _invoke(runner, ["copytrack", "add", "0xabc123"], data_dir) + assert result.exit_code == 0 + data = _parse(result) + assert data["ok"] is True + assert data["data"]["address"] == "0xabc123" + + def test_copytrack_list_empty(self, runner, data_dir): + result = _invoke(runner, ["copytrack", "list"], data_dir) + assert result.exit_code == 0 + data = _parse(result) + assert data["ok"] is True + assert data["data"] == [] + + def test_copytrack_list_with_targets(self, runner, data_dir): + _invoke(runner, ["copytrack", "add", "0xabc123"], data_dir) + result = _invoke(runner, ["copytrack", "list"], data_dir) + data = _parse(result) + assert len(data["data"]) == 1 + + def test_copytrack_remove(self, runner, data_dir): + _invoke(runner, ["copytrack", "add", "0xabc123"], data_dir) + result = _invoke(runner, ["copytrack", "remove", "0xabc123"], data_dir) + data = _parse(result) + assert data["ok"] is True + result2 = _invoke(runner, ["copytrack", "list"], data_dir) + data2 = _parse(result2) + assert data2["data"] == [] From 45995f221106674053eca5cea52ff7c16c4bbaec Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:39:19 -0400 Subject: [PATCH 10/19] feat: add copytrack trades and replay CLI commands Add trades command to fetch user trade history from Data API, and replay command to replay a target's trades through the paper trader with comparison stats. Includes 2 new tests. --- pm_trader/cli.py | 113 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 32 +++++++++++++ 2 files changed, 145 insertions(+) diff --git a/pm_trader/cli.py b/pm_trader/cli.py index 78e6f9f..c5202a6 100644 --- a/pm_trader/cli.py +++ b/pm_trader/cli.py @@ -903,6 +903,119 @@ def copytrack_remove(ctx: click.Context, identifier: str) -> None: engine.close() +@copytrack.command("trades") +@click.argument("identifier") +@click.option("--limit", type=int, default=20) +@click.pass_context +def copytrack_trades(ctx: click.Context, identifier: str, limit: int) -> None: + """Fetch trade history for a Polymarket user.""" + from pm_trader.data_api import PolymarketDataClient + + client = PolymarketDataClient() + try: + if not identifier.startswith("0x"): + address = client.resolve_username(identifier) + else: + address = identifier + trades = client.get_activity(address, limit=limit) + click.echo(_ok(trades)) + except SimError as e: + click.echo(_err(e)) + finally: + client.close() + + +@copytrack.command("replay") +@click.argument("identifier") +@click.option("--start", type=str, default=None, help="Start date (YYYY-MM-DD).") +@click.option("--end", type=str, default=None, help="End date (YYYY-MM-DD).") +@click.option("--sizing", type=click.Choice(["equal", "proportional", "fixed"]), default=None) +@click.option("--scale", type=float, default=None) +@click.option("--fixed-amount", type=float, default=None) +@click.option("--delay", type=int, default=None) +@click.pass_context +def copytrack_replay_cmd(ctx: click.Context, identifier: str, start: str | None, end: str | None, sizing: str | None, scale: float | None, fixed_amount: float | None, delay: int | None) -> None: + """Replay a target's trades in your paper trader.""" + from datetime import datetime, timedelta + + from pm_trader.copytrack import replay + from pm_trader.data_api import PolymarketDataClient + from pm_trader.models import SizingStrategy, Target + + account_dir = _get_account_dir(ctx) + + client = PolymarketDataClient() + try: + if not identifier.startswith("0x"): + address = client.resolve_username(identifier) + username = identifier.lstrip("@") + else: + address = identifier + username = identifier[:10] + "..." + + sizing_mode = sizing or "equal" + sizing_param = scale if sizing_mode == "proportional" else fixed_amount + delay_seconds = delay if delay is not None else 0 + + target = Target( + username=username, + address=address, + sizing=SizingStrategy( + mode=sizing_mode, + fixed_amount=fixed_amount, + scale=scale, + ), + delay=timedelta(seconds=delay_seconds), + ) + + start_ts = None + end_ts = None + if start: + start_ts = int(datetime.strptime(start, "%Y-%m-%d").timestamp()) + if end: + end_ts = int(datetime.strptime(end, "%Y-%m-%d").timestamp()) + + engine = Engine(account_dir) + try: + result = replay(target, engine, client, start=start_ts, end=end_ts) + + import json as _json + per_trade_json = _json.dumps([dataclasses.asdict(tc) for tc in result.comparison.per_trade]) + engine.db.conn.execute( + """INSERT INTO copytrack_replays + (address, username, started_at, finished_at, start_date, end_date, + sizing_mode, sizing_param, delay_seconds, + total_trades, replayed, skipped, + original_stats, replay_stats, per_trade, status) + VALUES (?, ?, datetime('now'), datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'done')""", + (address, username, start, end, + sizing_mode, sizing_param, delay_seconds, + len(result.original_trades), len(result.replayed_trades), len(result.skipped), + _json.dumps(result.comparison.original_stats), + _json.dumps(result.comparison.replay_stats), + per_trade_json), + ) + engine.db.conn.commit() + + output = { + "target": f"@{target.username}", + "trades_replayed": len(result.replayed_trades), + "trades_skipped": len(result.skipped), + "comparison": { + "original": result.comparison.original_stats, + "replay": result.comparison.replay_stats, + "diff": result.comparison.summary, + }, + } + click.echo(_ok(output)) + finally: + engine.close() + except SimError as e: + click.echo(_err(e)) + finally: + client.close() + + # --------------------------------------------------------------------------- # Web dashboard # --------------------------------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py index 5f61a24..68e0a66 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1140,3 +1140,35 @@ def test_copytrack_remove(self, runner, data_dir): result2 = _invoke(runner, ["copytrack", "list"], data_dir) data2 = _parse(result2) assert data2["data"] == [] + + def test_copytrack_trades(self, runner, data_dir, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=20", + json=[ + {"timestamp": 1000, "slug": "m1", "side": "BUY", "price": 0.5, + "usdcSize": 50.0, "outcome": "Yes"}, + ], + ) + result = _invoke(runner, ["copytrack", "trades", "0xabc", "--limit", "20"], data_dir) + data = _parse(result) + assert data["ok"] is True + assert len(data["data"]) == 1 + + def test_copytrack_replay(self, runner, data_dir, httpx_mock): + # Init account first + _invoke(runner, ["init"], data_dir) + # Add target + _invoke(runner, ["copytrack", "add", "0xabc"], data_dir) + # Mock trade fetch — get_all_trades calls get_activity with these params + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=[ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market 1?", "outcome": "Yes", "side": "BUY", + "price": 0.50, "size": 100.0, "usdcSize": 50.0, "eventSlug": ""}, + ], + ) + result = _invoke(runner, ["copytrack", "replay", "0xabc"], data_dir) + data = _parse(result) + assert data["ok"] is True + assert data["data"]["trades_replayed"] == 1 From 586e6dcc0da2182d36638b72f06311bad8badb5b Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:40:03 -0400 Subject: [PATCH 11/19] feat: add copytrack compare CLI command Add compare subcommand to view the latest replay comparison for a tracked target, including original vs replay stats and per-trade breakdown. Includes 1 new test. --- pm_trader/cli.py | 43 +++++++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 15 +++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/pm_trader/cli.py b/pm_trader/cli.py index c5202a6..2b8a7de 100644 --- a/pm_trader/cli.py +++ b/pm_trader/cli.py @@ -1016,6 +1016,49 @@ def copytrack_replay_cmd(ctx: click.Context, identifier: str, start: str | None, client.close() +@copytrack.command("compare") +@click.argument("identifier") +@click.pass_context +def copytrack_compare(ctx: click.Context, identifier: str) -> None: + """View the latest replay comparison for a target.""" + import json as _json + + account_dir = _get_account_dir(ctx) + engine = Engine(account_dir) + + try: + identifier_clean = identifier.lstrip("@") + row = engine.db.conn.execute( + """SELECT * FROM copytrack_replays + WHERE address = ? OR username = ? + ORDER BY id DESC LIMIT 1""", + (identifier_clean, identifier_clean), + ).fetchone() + if row is None: + from pm_trader.models import TargetNotFoundError + raise TargetNotFoundError(identifier) + + result = dict(row) + for field in ("original_stats", "replay_stats", "per_trade"): + if result.get(field): + result[field] = _json.loads(result[field]) + + click.echo(_ok({ + "target": f"@{result['username']}", + "replay_id": result["id"], + "status": result["status"], + "comparison": { + "original": result.get("original_stats", {}), + "replay": result.get("replay_stats", {}), + "per_trade": result.get("per_trade", []), + }, + })) + except SimError as e: + click.echo(_err(e)) + finally: + engine.close() + + # --------------------------------------------------------------------------- # Web dashboard # --------------------------------------------------------------------------- diff --git a/tests/test_cli.py b/tests/test_cli.py index 68e0a66..8961103 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1172,3 +1172,18 @@ def test_copytrack_replay(self, runner, data_dir, httpx_mock): data = _parse(result) assert data["ok"] is True assert data["data"]["trades_replayed"] == 1 + + def test_copytrack_compare(self, runner, data_dir, httpx_mock): + _invoke(runner, ["init"], data_dir) + _invoke(runner, ["copytrack", "add", "0xabc"], data_dir) + httpx_mock.add_response(json=[ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", + "price": 0.5, "size": 100, "usdcSize": 50, "eventSlug": ""}, + ]) + _invoke(runner, ["copytrack", "replay", "0xabc"], data_dir) + result = _invoke(runner, ["copytrack", "compare", "0xabc"], data_dir) + data = _parse(result) + assert data["ok"] is True + assert "original" in data["data"]["comparison"] + assert "replay" in data["data"]["comparison"] From b47508e60524e4cda1f445a809a464863754f62b Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:43:04 -0400 Subject: [PATCH 12/19] refactor(copytrack): use context manager pattern and add sys.exit(1) on errors --- pm_trader/cli.py | 199 +++++++++++++++++++++-------------------------- 1 file changed, 89 insertions(+), 110 deletions(-) diff --git a/pm_trader/cli.py b/pm_trader/cli.py index 2b8a7de..2b18bed 100644 --- a/pm_trader/cli.py +++ b/pm_trader/cli.py @@ -819,57 +819,49 @@ def copytrack_add(ctx: click.Context, identifier: str, sizing: str, scale: float """Add a copy-trade target. IDENTIFIER can be @username or 0x... address.""" from pm_trader.data_api import PolymarketDataClient - account_dir = _get_account_dir(ctx) - engine = Engine(account_dir) + with _get_engine(ctx) as engine: + try: + if identifier.startswith("0x"): + address = identifier + username = identifier[:10] + "..." + else: + client = PolymarketDataClient() + try: + address = client.resolve_username(identifier) + username = identifier.lstrip("@") + finally: + client.close() - try: - if identifier.startswith("0x"): - address = identifier - username = identifier[:10] + "..." - else: - client = PolymarketDataClient() - try: - address = client.resolve_username(identifier) - username = identifier.lstrip("@") - finally: - client.close() - - sizing_param = scale if sizing == "proportional" else fixed_amount - - engine.db.conn.execute( - """INSERT OR REPLACE INTO copytrack_targets - (address, username, sizing_mode, sizing_param, delay_seconds) - VALUES (?, ?, ?, ?, ?)""", - (address, username, sizing, sizing_param, delay), - ) - engine.db.conn.commit() - - click.echo(_ok({ - "address": address, - "username": username, - "sizing_mode": sizing, - "sizing_param": sizing_param, - "delay_seconds": delay, - })) - except SimError as e: - click.echo(_err(e)) - finally: - engine.close() + sizing_param = scale if sizing == "proportional" else fixed_amount + + engine.db.conn.execute( + """INSERT OR REPLACE INTO copytrack_targets + (address, username, sizing_mode, sizing_param, delay_seconds) + VALUES (?, ?, ?, ?, ?)""", + (address, username, sizing, sizing_param, delay), + ) + engine.db.conn.commit() + + click.echo(_ok({ + "address": address, + "username": username, + "sizing_mode": sizing, + "sizing_param": sizing_param, + "delay_seconds": delay, + })) + except SimError as e: + click.echo(_err(e)) + sys.exit(1) @copytrack.command("list") @click.pass_context def copytrack_list(ctx: click.Context) -> None: """List all copy-trade targets.""" - account_dir = _get_account_dir(ctx) - engine = Engine(account_dir) - - try: + with _get_engine(ctx) as engine: rows = engine.db.conn.execute("SELECT * FROM copytrack_targets ORDER BY added_at").fetchall() targets = [dict(row) for row in rows] click.echo(_ok(targets)) - finally: - engine.close() @copytrack.command("remove") @@ -879,35 +871,31 @@ def copytrack_remove(ctx: click.Context, identifier: str) -> None: """Remove a copy-trade target by address or username.""" from pm_trader.models import TargetNotFoundError - account_dir = _get_account_dir(ctx) - engine = Engine(account_dir) - - try: - identifier_clean = identifier.lstrip("@") - engine.db.conn.execute( - "DELETE FROM copytrack_replays WHERE address IN " - "(SELECT address FROM copytrack_targets WHERE address = ? OR username = ?)", - (identifier_clean, identifier_clean), - ) - cursor = engine.db.conn.execute( - "DELETE FROM copytrack_targets WHERE address = ? OR username = ?", - (identifier_clean, identifier_clean), - ) - engine.db.conn.commit() - if cursor.rowcount == 0: - raise TargetNotFoundError(identifier) - click.echo(_ok({"removed": identifier})) - except SimError as e: - click.echo(_err(e)) - finally: - engine.close() + with _get_engine(ctx) as engine: + try: + identifier_clean = identifier.lstrip("@") + engine.db.conn.execute( + "DELETE FROM copytrack_replays WHERE address IN " + "(SELECT address FROM copytrack_targets WHERE address = ? OR username = ?)", + (identifier_clean, identifier_clean), + ) + cursor = engine.db.conn.execute( + "DELETE FROM copytrack_targets WHERE address = ? OR username = ?", + (identifier_clean, identifier_clean), + ) + engine.db.conn.commit() + if cursor.rowcount == 0: + raise TargetNotFoundError(identifier) + click.echo(_ok({"removed": identifier})) + except SimError as e: + click.echo(_err(e)) + sys.exit(1) @copytrack.command("trades") @click.argument("identifier") @click.option("--limit", type=int, default=20) -@click.pass_context -def copytrack_trades(ctx: click.Context, identifier: str, limit: int) -> None: +def copytrack_trades(identifier: str, limit: int) -> None: """Fetch trade history for a Polymarket user.""" from pm_trader.data_api import PolymarketDataClient @@ -921,6 +909,7 @@ def copytrack_trades(ctx: click.Context, identifier: str, limit: int) -> None: click.echo(_ok(trades)) except SimError as e: click.echo(_err(e)) + sys.exit(1) finally: client.close() @@ -942,8 +931,6 @@ def copytrack_replay_cmd(ctx: click.Context, identifier: str, start: str | None, from pm_trader.data_api import PolymarketDataClient from pm_trader.models import SizingStrategy, Target - account_dir = _get_account_dir(ctx) - client = PolymarketDataClient() try: if not identifier.startswith("0x"): @@ -975,12 +962,10 @@ def copytrack_replay_cmd(ctx: click.Context, identifier: str, start: str | None, if end: end_ts = int(datetime.strptime(end, "%Y-%m-%d").timestamp()) - engine = Engine(account_dir) - try: + with _get_engine(ctx) as engine: result = replay(target, engine, client, start=start_ts, end=end_ts) - import json as _json - per_trade_json = _json.dumps([dataclasses.asdict(tc) for tc in result.comparison.per_trade]) + per_trade_json = json.dumps([dataclasses.asdict(tc) for tc in result.comparison.per_trade]) engine.db.conn.execute( """INSERT INTO copytrack_replays (address, username, started_at, finished_at, start_date, end_date, @@ -991,8 +976,8 @@ def copytrack_replay_cmd(ctx: click.Context, identifier: str, start: str | None, (address, username, start, end, sizing_mode, sizing_param, delay_seconds, len(result.original_trades), len(result.replayed_trades), len(result.skipped), - _json.dumps(result.comparison.original_stats), - _json.dumps(result.comparison.replay_stats), + json.dumps(result.comparison.original_stats), + json.dumps(result.comparison.replay_stats), per_trade_json), ) engine.db.conn.commit() @@ -1008,10 +993,9 @@ def copytrack_replay_cmd(ctx: click.Context, identifier: str, start: str | None, }, } click.echo(_ok(output)) - finally: - engine.close() except SimError as e: click.echo(_err(e)) + sys.exit(1) finally: client.close() @@ -1021,42 +1005,37 @@ def copytrack_replay_cmd(ctx: click.Context, identifier: str, start: str | None, @click.pass_context def copytrack_compare(ctx: click.Context, identifier: str) -> None: """View the latest replay comparison for a target.""" - import json as _json - - account_dir = _get_account_dir(ctx) - engine = Engine(account_dir) + with _get_engine(ctx) as engine: + try: + identifier_clean = identifier.lstrip("@") + row = engine.db.conn.execute( + """SELECT * FROM copytrack_replays + WHERE address = ? OR username = ? + ORDER BY id DESC LIMIT 1""", + (identifier_clean, identifier_clean), + ).fetchone() + if row is None: + from pm_trader.models import TargetNotFoundError + raise TargetNotFoundError(identifier) + + result = dict(row) + for field in ("original_stats", "replay_stats", "per_trade"): + if result.get(field): + result[field] = json.loads(result[field]) - try: - identifier_clean = identifier.lstrip("@") - row = engine.db.conn.execute( - """SELECT * FROM copytrack_replays - WHERE address = ? OR username = ? - ORDER BY id DESC LIMIT 1""", - (identifier_clean, identifier_clean), - ).fetchone() - if row is None: - from pm_trader.models import TargetNotFoundError - raise TargetNotFoundError(identifier) - - result = dict(row) - for field in ("original_stats", "replay_stats", "per_trade"): - if result.get(field): - result[field] = _json.loads(result[field]) - - click.echo(_ok({ - "target": f"@{result['username']}", - "replay_id": result["id"], - "status": result["status"], - "comparison": { - "original": result.get("original_stats", {}), - "replay": result.get("replay_stats", {}), - "per_trade": result.get("per_trade", []), - }, - })) - except SimError as e: - click.echo(_err(e)) - finally: - engine.close() + click.echo(_ok({ + "target": f"@{result['username']}", + "replay_id": result["id"], + "status": result["status"], + "comparison": { + "original": result.get("original_stats", {}), + "replay": result.get("replay_stats", {}), + "per_trade": result.get("per_trade", []), + }, + })) + except SimError as e: + click.echo(_err(e)) + sys.exit(1) # --------------------------------------------------------------------------- From ed512c530493415a496a74a7b47fa500860cccd1 Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:45:17 -0400 Subject: [PATCH 13/19] feat: add copytrack web routes for targets CRUD Add /api/copytrack/targets endpoints (GET, POST, DELETE) for managing copy-trade targets via the web API. Register copytrack router in app.py. --- pm_trader/web/app.py | 3 ++ pm_trader/web/routes/copytrack.py | 67 +++++++++++++++++++++++++++++++ tests/test_web/test_copytrack.py | 66 ++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 pm_trader/web/routes/copytrack.py create mode 100644 tests/test_web/test_copytrack.py diff --git a/pm_trader/web/app.py b/pm_trader/web/app.py index dd89ede..7c0a26e 100644 --- a/pm_trader/web/app.py +++ b/pm_trader/web/app.py @@ -53,6 +53,9 @@ async def generic_error_handler(request: Request, exc: Exception) -> JSONRespons from pm_trader.web.routes.accounts import router as accounts_router app.include_router(accounts_router) + from pm_trader.web.routes.copytrack import router as copytrack_router + app.include_router(copytrack_router) + @app.get("/api/health") def health() -> dict: return {"ok": True, "data": {"status": "healthy"}} diff --git a/pm_trader/web/routes/copytrack.py b/pm_trader/web/routes/copytrack.py new file mode 100644 index 0000000..40a3a8b --- /dev/null +++ b/pm_trader/web/routes/copytrack.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from fastapi import APIRouter, Query +from pydantic import BaseModel + +from pm_trader.web.deps import get_engine + +router = APIRouter(prefix="/api/copytrack", tags=["copytrack"]) + + +class AddTargetRequest(BaseModel): + address: str + username: str + sizing_mode: str = "equal" + sizing_param: float | None = None + delay_seconds: int = 0 + + +@router.get("/targets") +def list_targets(account: str = Query("default")) -> dict: + engine = get_engine(account) + try: + rows = engine.db.conn.execute( + "SELECT * FROM copytrack_targets ORDER BY added_at" + ).fetchall() + return {"ok": True, "data": [dict(row) for row in rows]} + finally: + engine.close() + + +@router.post("/targets") +def add_target(body: AddTargetRequest, account: str = Query("default")) -> dict: + engine = get_engine(account) + try: + engine.db.conn.execute( + """INSERT OR REPLACE INTO copytrack_targets + (address, username, sizing_mode, sizing_param, delay_seconds) + VALUES (?, ?, ?, ?, ?)""", + (body.address, body.username, body.sizing_mode, + body.sizing_param, body.delay_seconds), + ) + engine.db.conn.commit() + return {"ok": True, "data": { + "address": body.address, + "username": body.username, + "sizing_mode": body.sizing_mode, + "sizing_param": body.sizing_param, + "delay_seconds": body.delay_seconds, + }} + finally: + engine.close() + + +@router.delete("/targets/{address:path}") +def remove_target(address: str, account: str = Query("default")) -> dict: + engine = get_engine(account) + try: + engine.db.conn.execute( + "DELETE FROM copytrack_replays WHERE address = ?", (address,) + ) + engine.db.conn.execute( + "DELETE FROM copytrack_targets WHERE address = ?", (address,) + ) + engine.db.conn.commit() + return {"ok": True, "data": {"removed": address}} + finally: + engine.close() diff --git a/tests/test_web/test_copytrack.py b/tests/test_web/test_copytrack.py new file mode 100644 index 0000000..b0dfc72 --- /dev/null +++ b/tests/test_web/test_copytrack.py @@ -0,0 +1,66 @@ +"""Tests for copytrack web routes.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from pm_trader.web.app import create_app + + +@pytest.fixture +def web_client(tmp_path, monkeypatch): + monkeypatch.setenv("PM_TRADER_HOME", str(tmp_path)) + monkeypatch.setenv("PM_TRADER_STATIC_DIR", str(tmp_path / "nonexistent")) + app = create_app() + return TestClient(app) + + +@pytest.fixture +def initialized_client(web_client, tmp_path, monkeypatch): + """Web client with an initialized account.""" + from pm_trader.db import Database + db = Database(tmp_path / "default") + db.init_schema() + db.init_account(10_000.0) + db.close() + return web_client + + +class TestCopytrackTargets: + def test_list_empty(self, initialized_client): + resp = initialized_client.get("/api/copytrack/targets") + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["data"] == [] + + def test_add_target(self, initialized_client): + resp = initialized_client.post("/api/copytrack/targets", json={ + "address": "0xabc123", + "username": "testuser", + "sizing_mode": "equal", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["data"]["address"] == "0xabc123" + + def test_add_then_list(self, initialized_client): + initialized_client.post("/api/copytrack/targets", json={ + "address": "0xabc123", + "username": "testuser", + }) + resp = initialized_client.get("/api/copytrack/targets") + data = resp.json() + assert len(data["data"]) == 1 + + def test_delete_target(self, initialized_client): + initialized_client.post("/api/copytrack/targets", json={ + "address": "0xabc123", + "username": "testuser", + }) + resp = initialized_client.delete("/api/copytrack/targets/0xabc123") + assert resp.status_code == 200 + resp2 = initialized_client.get("/api/copytrack/targets") + assert resp2.json()["data"] == [] From ee65c7e1d23138634c6763d201873d52c571080d Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:47:01 -0400 Subject: [PATCH 14/19] feat: add copytrack replay and chart web routes Add POST /api/copytrack/replay to trigger copy-trade replays, GET /api/copytrack/replay/{id} to fetch results, and GET /api/copytrack/chart/{id} for P&L curve data. --- pm_trader/web/routes/copytrack.py | 126 ++++++++++++++++++++++++++++++ tests/test_web/test_copytrack.py | 71 +++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/pm_trader/web/routes/copytrack.py b/pm_trader/web/routes/copytrack.py index 40a3a8b..a237964 100644 --- a/pm_trader/web/routes/copytrack.py +++ b/pm_trader/web/routes/copytrack.py @@ -1,5 +1,8 @@ from __future__ import annotations +import json as _json +from datetime import timedelta + from fastapi import APIRouter, Query from pydantic import BaseModel @@ -65,3 +68,126 @@ def remove_target(address: str, account: str = Query("default")) -> dict: return {"ok": True, "data": {"removed": address}} finally: engine.close() + + +class ReplayRequest(BaseModel): + address: str + sizing_mode: str = "equal" + sizing_param: float | None = None + delay_seconds: int = 0 + start: str | None = None + end: str | None = None + + +@router.post("/replay") +def trigger_replay(body: ReplayRequest, account: str = Query("default")) -> dict: + """Trigger a copy-trade replay (synchronous for now).""" + import dataclasses + from datetime import datetime + + from pm_trader.copytrack import replay + from pm_trader.data_api import PolymarketDataClient + from pm_trader.models import SizingStrategy, Target + + engine = get_engine(account) + client = PolymarketDataClient() + try: + row = engine.db.conn.execute( + "SELECT * FROM copytrack_targets WHERE address = ?", + (body.address,), + ).fetchone() + username = dict(row)["username"] if row else body.address[:10] + "..." + + target = Target( + username=username, + address=body.address, + sizing=SizingStrategy( + mode=body.sizing_mode, + scale=body.sizing_param if body.sizing_mode == "proportional" else None, + fixed_amount=body.sizing_param if body.sizing_mode == "fixed" else None, + ), + delay=timedelta(seconds=body.delay_seconds), + ) + + start_ts = int(datetime.strptime(body.start, "%Y-%m-%d").timestamp()) if body.start else None + end_ts = int(datetime.strptime(body.end, "%Y-%m-%d").timestamp()) if body.end else None + + result = replay(target, engine, client, start=start_ts, end=end_ts) + + per_trade_json = _json.dumps([dataclasses.asdict(tc) for tc in result.comparison.per_trade]) + + cursor = engine.db.conn.execute( + """INSERT INTO copytrack_replays + (address, username, started_at, finished_at, start_date, end_date, + sizing_mode, sizing_param, delay_seconds, + total_trades, replayed, skipped, + original_stats, replay_stats, per_trade, status) + VALUES (?, ?, datetime('now'), datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'done')""", + (body.address, username, body.start, body.end, + body.sizing_mode, body.sizing_param, body.delay_seconds, + len(result.original_trades), len(result.replayed_trades), len(result.skipped), + _json.dumps(result.comparison.original_stats), + _json.dumps(result.comparison.replay_stats), + per_trade_json), + ) + engine.db.conn.commit() + replay_id = cursor.lastrowid + + return {"ok": True, "data": { + "replay_id": replay_id, + "replayed": len(result.replayed_trades), + "skipped": len(result.skipped), + "comparison": result.comparison.summary, + }} + finally: + engine.close() + client.close() + + +@router.get("/replay/{replay_id}") +def get_replay(replay_id: int, account: str = Query("default")) -> dict: + engine = get_engine(account) + try: + row = engine.db.conn.execute( + "SELECT * FROM copytrack_replays WHERE id = ?", (replay_id,) + ).fetchone() + if row is None: + return {"ok": False, "error": "Replay not found", "code": "NOT_FOUND"} + result = dict(row) + for field in ("original_stats", "replay_stats", "per_trade"): + if result.get(field): + result[field] = _json.loads(result[field]) + return {"ok": True, "data": result} + finally: + engine.close() + + +@router.get("/chart/{replay_id}") +def get_chart(replay_id: int, account: str = Query("default")) -> dict: + """Return P&L curve data for original vs replay (two lines).""" + engine = get_engine(account) + try: + row = engine.db.conn.execute( + "SELECT per_trade FROM copytrack_replays WHERE id = ?", (replay_id,) + ).fetchone() + if row is None: + return {"ok": False, "error": "Replay not found", "code": "NOT_FOUND"} + + per_trade = _json.loads(row["per_trade"]) if row["per_trade"] else [] + + original_cumulative = 0.0 + replay_cumulative = 0.0 + chart_data = [] + for tc in per_trade: + sign = -1.0 if tc["side"] == "BUY" else 1.0 + original_cumulative += sign * tc["original_usd"] + replay_cumulative += sign * tc["replay_usd"] + chart_data.append({ + "timestamp": tc["timestamp"], + "original_pnl": round(original_cumulative, 2), + "replay_pnl": round(replay_cumulative, 2), + }) + + return {"ok": True, "data": chart_data} + finally: + engine.close() diff --git a/tests/test_web/test_copytrack.py b/tests/test_web/test_copytrack.py index b0dfc72..ce77242 100644 --- a/tests/test_web/test_copytrack.py +++ b/tests/test_web/test_copytrack.py @@ -64,3 +64,74 @@ def test_delete_target(self, initialized_client): assert resp.status_code == 200 resp2 = initialized_client.get("/api/copytrack/targets") assert resp2.json()["data"] == [] + + +class TestCopytrackReplay: + def test_trigger_replay(self, initialized_client, httpx_mock): + initialized_client.post("/api/copytrack/targets", json={ + "address": "0xabc", "username": "testuser", + }) + httpx_mock.add_response( + json=[ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", + "price": 0.5, "size": 100, "usdcSize": 50, "eventSlug": ""}, + ], + ) + resp = initialized_client.post("/api/copytrack/replay", json={ + "address": "0xabc", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["data"]["replayed"] == 1 + + def test_get_replay_result(self, initialized_client, httpx_mock): + initialized_client.post("/api/copytrack/targets", json={ + "address": "0xabc", "username": "testuser", + }) + httpx_mock.add_response(json=[]) + initialized_client.post("/api/copytrack/replay", json={ + "address": "0xabc", + }) + resp = initialized_client.get("/api/copytrack/replay/1") + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["data"]["address"] == "0xabc" + + def test_get_replay_not_found(self, initialized_client): + resp = initialized_client.get("/api/copytrack/replay/999") + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is False + assert data["code"] == "NOT_FOUND" + + def test_chart_endpoint(self, initialized_client, httpx_mock): + initialized_client.post("/api/copytrack/targets", json={ + "address": "0xabc", "username": "testuser", + }) + httpx_mock.add_response( + json=[ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", + "price": 0.5, "size": 100, "usdcSize": 50, "eventSlug": ""}, + ], + ) + initialized_client.post("/api/copytrack/replay", json={ + "address": "0xabc", + }) + resp = initialized_client.get("/api/copytrack/chart/1") + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert len(data["data"]) == 1 + assert "original_pnl" in data["data"][0] + assert "replay_pnl" in data["data"][0] + + def test_chart_not_found(self, initialized_client): + resp = initialized_client.get("/api/copytrack/chart/999") + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is False + assert data["code"] == "NOT_FOUND" From 02ab3a50f5d908da4f5510ea9ec3142795b0f77b Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:50:29 -0400 Subject: [PATCH 15/19] test(copytrack): add live E2E tests and edge case coverage --- tests/test_copytrack.py | 106 ++++++++++++++++++++++++++++++++++++++++ tests/test_e2e_live.py | 31 ++++++++++++ 2 files changed, 137 insertions(+) diff --git a/tests/test_copytrack.py b/tests/test_copytrack.py index 2f5ed7f..eeb400f 100644 --- a/tests/test_copytrack.py +++ b/tests/test_copytrack.py @@ -145,3 +145,109 @@ def test_replay_empty_trades(self, tmp_path, httpx_mock): assert result.comparison.original_stats["total_trades"] == 0 engine.close() client.close() + + def test_replay_skips_zero_price(self, tmp_path, httpx_mock): + engine = self._make_engine(tmp_path) + client = PolymarketDataClient() + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [{"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", "price": 0.0, + "size": 100.0, "usdcSize": 50.0, "eventSlug": ""}] + result = replay(target, engine, client, original_trades=original_trades) + assert len(result.skipped) == 1 + assert "price is 0" in result.skipped[0]["reason"].lower() + engine.close() + client.close() + + def test_replay_skips_sell_no_position(self, tmp_path, httpx_mock): + engine = self._make_engine(tmp_path) + client = PolymarketDataClient() + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [{"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "SELL", "price": 0.7, + "size": 100.0, "usdcSize": 70.0, "eventSlug": ""}] + result = replay(target, engine, client, original_trades=original_trades) + assert len(result.skipped) == 1 + assert "no position" in result.skipped[0]["reason"].lower() + engine.close() + client.close() + + def test_replay_skips_unknown_side(self, tmp_path, httpx_mock): + engine = self._make_engine(tmp_path) + client = PolymarketDataClient() + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [{"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "UNKNOWN", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "eventSlug": ""}] + result = replay(target, engine, client, original_trades=original_trades) + assert len(result.skipped) == 1 + assert "unknown side" in result.skipped[0]["reason"].lower() + engine.close() + client.close() + + def test_replay_buy_into_existing_position(self, tmp_path, httpx_mock): + """Buying into a market where we already have a position merges correctly.""" + engine = self._make_engine(tmp_path) + client = PolymarketDataClient() + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", + "outcome": "Yes", "side": "BUY", "price": 0.50, "size": 100.0, "usdcSize": 50.0, "eventSlug": ""}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", + "outcome": "Yes", "side": "BUY", "price": 0.60, "size": 50.0, "usdcSize": 30.0, "eventSlug": ""}, + ] + result = replay(target, engine, client, original_trades=original_trades) + assert len(result.replayed_trades) == 2 + pos = engine.db.get_position("0xcond1", "yes") + assert pos is not None + assert pos.shares == pytest.approx(100.0 + 50.0) + assert pos.total_cost == pytest.approx(50.0 + 30.0) + engine.close() + client.close() + + def test_replay_proportional_sell(self, tmp_path, httpx_mock): + """Proportional sizing scales sell shares.""" + engine = self._make_engine(tmp_path) + client = PolymarketDataClient() + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="proportional", scale=0.5), delay=timedelta(seconds=0)) + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", + "outcome": "Yes", "side": "BUY", "price": 0.50, "size": 200.0, "usdcSize": 100.0, "eventSlug": ""}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", + "outcome": "Yes", "side": "SELL", "price": 0.70, "size": 200.0, "usdcSize": 140.0, "eventSlug": ""}, + ] + result = replay(target, engine, client, original_trades=original_trades) + assert len(result.replayed_trades) == 2 + sell_trade = result.replayed_trades[1] + assert sell_trade.shares == pytest.approx(100.0) + engine.close() + client.close() + + def test_replay_fixed_sell_uses_all_shares(self, tmp_path, httpx_mock): + """Fixed sizing sells all shares on sell.""" + engine = self._make_engine(tmp_path) + client = PolymarketDataClient() + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="fixed", fixed_amount=25.0), delay=timedelta(seconds=0)) + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", + "outcome": "Yes", "side": "BUY", "price": 0.50, "size": 200.0, "usdcSize": 100.0, "eventSlug": ""}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", + "outcome": "Yes", "side": "SELL", "price": 0.70, "size": 200.0, "usdcSize": 140.0, "eventSlug": ""}, + ] + result = replay(target, engine, client, original_trades=original_trades) + assert len(result.replayed_trades) == 2 + sell_trade = result.replayed_trades[1] + assert sell_trade.shares == pytest.approx(50.0) + engine.close() + client.close() + + def test_unknown_sizing_mode_fallback(self): + """Unknown sizing mode returns original amount.""" + sizing = SizingStrategy(mode="unknown") # type: ignore[arg-type] + assert compute_replay_amount(100.0, sizing) == 100.0 diff --git a/tests/test_e2e_live.py b/tests/test_e2e_live.py index e8cc3c2..3702c11 100644 --- a/tests/test_e2e_live.py +++ b/tests/test_e2e_live.py @@ -743,3 +743,34 @@ def test_final_state_consistent(self, engine: Engine): f"→ {p['live_price']:.4f} P&L: ${p['unrealized_pnl']:>+8.2f}" ) print(f"{'='*60}") + + +# --------------------------------------------------------------------------- +# Data API live tests +# --------------------------------------------------------------------------- + +class TestDataApiLive: + @pytest.mark.live + def test_get_activity_real_user(self): + from pm_trader.data_api import PolymarketDataClient + client = PolymarketDataClient() + try: + trades = client.get_activity("0xd8f8c13644ea84d62e1ec88c5d1215e436eb0f11", limit=5) + if not trades: + pytest.skip("No trades returned — user may have no recent activity") + assert isinstance(trades, list) + assert "side" in trades[0] + assert "price" in trades[0] + finally: + client.close() + + @pytest.mark.live + def test_get_profile_real_user(self): + from pm_trader.data_api import PolymarketDataClient + client = PolymarketDataClient() + try: + profile = client.get_profile("0xd8f8c13644ea84d62e1ec88c5d1215e436eb0f11") + assert isinstance(profile, dict) + assert "name" in profile or "pseudonym" in profile + finally: + client.close() From 6f50dedd0a37791bc2df7cf61696af2c5c774e3e Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 16:50:32 -0400 Subject: [PATCH 16/19] docs: update test count in CLAUDE.md --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9e8864b..6d4fa0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ Forked from [agent-next/polymarket-paper-trader](https://github.com/agent-next/p # Install pip install -e ".[dev]" -# Tests (615 non-live + 42 live = 657 total, 100% coverage) +# Tests (783 non-live + 44 live = 827 total, 99% coverage) python3 -m pytest tests/ -x -q -m "not live" # fast, skip live API tests python3 -m pytest tests/ -v # verbose python3 -m pytest tests/ --cov=pm_trader --cov-report=term-missing # coverage From f0af34217d2e7c87e4f3184b686df863cf74c3b8 Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 17:10:44 -0400 Subject: [PATCH 17/19] test(copytrack): add CLI error path tests for 100% coverage --- tests/test_cli.py | 95 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8961103..55f9f92 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1187,3 +1187,98 @@ def test_copytrack_compare(self, runner, data_dir, httpx_mock): assert data["ok"] is True assert "original" in data["data"]["comparison"] assert "replay" in data["data"]["comparison"] + + def test_copytrack_add_username_resolution_error(self, runner, data_dir, httpx_mock): + """Cover the SimError catch in copytrack_add (lines 852-854).""" + httpx_mock.add_response( + url="https://polymarket.com/@baduser", + text="no data here", + ) + result = _invoke(runner, ["copytrack", "add", "@baduser"], data_dir) + data = _parse(result) + assert data["ok"] is False + assert result.exit_code == 1 + + def test_copytrack_remove_not_found(self, runner, data_dir): + """Cover the TargetNotFoundError in copytrack_remove (lines 888-892).""" + result = _invoke(runner, ["copytrack", "remove", "0xnonexistent"], data_dir) + data = _parse(result) + assert data["ok"] is False + assert data["code"] == "TARGET_NOT_FOUND" + assert result.exit_code == 1 + + def test_copytrack_trades_username_resolution(self, runner, data_dir, httpx_mock): + """Cover username resolution path in copytrack_trades (line 905).""" + httpx_mock.add_response( + url="https://polymarket.com/@testuser", + text='', + ) + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xresolved&type=TRADE&limit=20", + json=[{"side": "BUY", "price": 0.5}], + ) + result = _invoke(runner, ["copytrack", "trades", "@testuser"], data_dir) + data = _parse(result) + assert data["ok"] is True + + def test_copytrack_trades_error(self, runner, data_dir, httpx_mock): + """Cover the SimError catch in copytrack_trades (lines 910-912).""" + httpx_mock.add_response( + url="https://polymarket.com/@baduser", + text="no data", + ) + result = _invoke(runner, ["copytrack", "trades", "@baduser"], data_dir) + data = _parse(result) + assert data["ok"] is False + assert result.exit_code == 1 + + def test_copytrack_replay_username_resolution(self, runner, data_dir, httpx_mock): + """Cover username resolution path in copytrack_replay (lines 937-938).""" + _invoke(runner, ["init"], data_dir) + # Add target first (by raw address to avoid needing polymarket.com mock) + _invoke(runner, ["copytrack", "add", "0xresolved"], data_dir) + # Mock username resolution for the replay command + httpx_mock.add_response( + url="https://polymarket.com/@testuser", + text='', + ) + # Mock the activity fetch for replay + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xresolved&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=[], + ) + result = _invoke(runner, ["copytrack", "replay", "@testuser"], data_dir) + assert result.exit_code == 0 + data = _parse(result) + assert data["ok"] is True + + def test_copytrack_replay_with_dates(self, runner, data_dir, httpx_mock): + """Cover start/end date parsing in copytrack_replay (lines 961, 963).""" + _invoke(runner, ["init"], data_dir) + _invoke(runner, ["copytrack", "add", "0xabc"], data_dir) + httpx_mock.add_response(json=[]) + result = _invoke(runner, [ + "copytrack", "replay", "0xabc", + "--start", "2026-01-01", "--end", "2026-03-01", + ], data_dir) + data = _parse(result) + assert data["ok"] is True + + def test_copytrack_replay_error(self, runner, data_dir, httpx_mock): + """Cover the SimError catch in copytrack_replay (lines 996-998).""" + httpx_mock.add_response( + url="https://polymarket.com/@baduser", + text="no data", + ) + result = _invoke(runner, ["copytrack", "replay", "@baduser"], data_dir) + data = _parse(result) + assert data["ok"] is False + assert result.exit_code == 1 + + def test_copytrack_compare_not_found(self, runner, data_dir): + """Cover TargetNotFoundError in copytrack_compare (lines 1018-1019, 1036-1038).""" + result = _invoke(runner, ["copytrack", "compare", "0xnonexistent"], data_dir) + data = _parse(result) + assert data["ok"] is False + assert data["code"] == "TARGET_NOT_FOUND" + assert result.exit_code == 1 From 0842e5e5dee3f2575c93184273b0f92fcd01eb97 Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Fri, 13 Mar 2026 17:10:52 -0400 Subject: [PATCH 18/19] docs: update test count to 791 (100% coverage) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6d4fa0d..b4882c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ Forked from [agent-next/polymarket-paper-trader](https://github.com/agent-next/p # Install pip install -e ".[dev]" -# Tests (783 non-live + 44 live = 827 total, 99% coverage) +# Tests (791 non-live + 44 live = 835 total, 100% coverage) python3 -m pytest tests/ -x -q -m "not live" # fast, skip live API tests python3 -m pytest tests/ -v # verbose python3 -m pytest tests/ --cov=pm_trader --cov-report=term-missing # coverage From 566f32d9e8f1baf6949c6eae3bac90efe0986fde Mon Sep 17 00:00:00 2001 From: Qichen Huang Date: Sun, 15 Mar 2026 00:41:11 -0400 Subject: [PATCH 19/19] feat: migrate copytrack to activity-based replay with REDEEM support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace deprecated /closed-positions API with /activity endpoint (TRADE + REDEEM). Replay engine now processes BUY, SELL, and REDEEM activities chronologically. REDEEM win/loss determined by API usdcSize (>0 = won, 0 = lost). - Add REDEEM handling: winning positions get cash back, losing positions zeroed out - Add `type` column to copytrack_raw_trades for TRADE/REDEEM deduplication - Update limit parameter to count by market (conditionId), not individual activities - Add Copytrack frontend page with per-trade comparisons and charts - Add open market indicators (yellow bg + ⏳) for unredeemed positions - Add replay history pagination (show 5 by default) - Add live E2E test verifying limit=1 aligns with latest real activity - 818 tests pass, 100% coverage --- CLAUDE.md | 8 +- .../2026-03-14-copytrack-activity-replay.md | 863 ++++++++++++++++++ .../2026-03-14-copytrack-fetch-separation.md | 757 +++++++++++++++ .../2026-03-13-copytrack-frontend-design.md | 96 ++ ...-03-14-copytrack-activity-replay-design.md | 108 +++ frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 32 +- frontend/src/api/types.ts | 43 + frontend/src/components/Layout.tsx | 4 + frontend/src/components/PositionsList.tsx | 128 ++- frontend/src/components/TradeHistory.tsx | 2 +- frontend/src/pages/Copytrack.tsx | 443 +++++++++ frontend/src/pages/Dashboard.tsx | 2 +- pm_trader/cli.py | 68 +- pm_trader/copytrack.py | 145 ++- pm_trader/data_api.py | 52 +- pm_trader/db.py | 59 ++ pm_trader/web/routes/copytrack.py | 113 ++- tests/test_cli.py | 105 ++- tests/test_copytrack.py | 430 ++++++--- tests/test_data_api.py | 77 ++ tests/test_db.py | 83 ++ tests/test_e2e_live.py | 69 ++ tests/test_web/test_copytrack.py | 140 ++- 24 files changed, 3575 insertions(+), 254 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-14-copytrack-activity-replay.md create mode 100644 docs/superpowers/plans/2026-03-14-copytrack-fetch-separation.md create mode 100644 docs/superpowers/specs/2026-03-13-copytrack-frontend-design.md create mode 100644 docs/superpowers/specs/2026-03-14-copytrack-activity-replay-design.md create mode 100644 frontend/src/pages/Copytrack.tsx diff --git a/CLAUDE.md b/CLAUDE.md index b4882c3..7d0f37c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,9 +24,15 @@ python3 -m pytest tests/test_e2e_live.py -v # live API (requires ne python3 -m pytest tests/test_engine.py -x -q # one file python3 -m pytest tests/test_engine.py::TestBuy::test_buy_yes -x -q # one test -# Run +# Run (first time only — do NOT re-run init if account already exists, it resets cash) pm-trader init --balance 10000 pm-trader-mcp # MCP server on stdio + +# Dev (frontend + backend with auto-reload, no init needed) +pm-trader web --port 8000 --reload # backend API (FastAPI + Uvicorn) +cd frontend && npm install && npm run dev # frontend (Vite + React, port 5173) +# Frontend proxies /api → http://127.0.0.1:8000 +# Open http://localhost:5173 for the dashboard ``` ## Architecture diff --git a/docs/superpowers/plans/2026-03-14-copytrack-activity-replay.md b/docs/superpowers/plans/2026-03-14-copytrack-activity-replay.md new file mode 100644 index 0000000..78b5b14 --- /dev/null +++ b/docs/superpowers/plans/2026-03-14-copytrack-activity-replay.md @@ -0,0 +1,863 @@ +# Copytrack Activity-Based Replay Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace closed-positions-based replay with activity-based replay (BUY/SELL/REDEEM) for accurate P&L tracking. + +**Architecture:** Fetch BUY+SELL+REDEEM activities from `/activity` endpoint, store all in `copytrack_raw_trades` with a `type` column. Replay processes activities chronologically — BUY spends cash, SELL returns cash, REDEEM settles winning positions at $1/share. No `/closed-positions` dependency. + +**Tech Stack:** SQLite, httpx, Click CLI, FastAPI, React + +**Spec:** `docs/superpowers/specs/2026-03-14-copytrack-activity-replay-design.md` + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `pm_trader/db.py` | Modify | Add `type` column to schema, update CRUD, remove positions table/methods | +| `pm_trader/data_api.py` | Modify | Add `type` param to `get_all_trades()`, update `/closed-positions` URL | +| `pm_trader/copytrack.py` | Modify | Rewrite `fetch()` and `replay()` for activity-based approach | +| `pm_trader/cli.py` | Modify | Remove `--max-positions` from fetch command | +| `pm_trader/web/routes/copytrack.py` | Modify | Remove `max_positions`, update replay to drop settlement simulation | +| `frontend/src/api/client.ts` | Modify | Update fetch response type | +| `frontend/src/pages/Copytrack.tsx` | Modify | Update fetch status text | +| `tests/test_db.py` | Modify | Update raw data tests for `type` column, remove positions tests | +| `tests/test_copytrack.py` | Modify | Rewrite for activity-based replay with REDEEM | +| `tests/test_cli.py` | Modify | Update fetch/replay tests | +| `tests/test_web/test_copytrack.py` | Modify | Update helpers and mocks | +| `tests/test_data_api.py` | Modify | Keep closed-positions tests (method still exists), no changes needed | + +--- + +## Chunk 1: Database Schema + +### Task 1: Update `copytrack_raw_trades` schema and CRUD + +**Files:** +- Modify: `pm_trader/db.py:91-107` (schema), `pm_trader/db.py:110-126` (drop positions schema), `pm_trader/db.py:414-484` (CRUD methods) +- Test: `tests/test_db.py` + +- [ ] **Step 1: Update schema in `db.py`** + +In `SCHEMA_SQL`, replace the `copytrack_raw_trades` and `copytrack_raw_positions` table definitions: + +```python +# Replace lines 91-126 with: +CREATE TABLE IF NOT EXISTS copytrack_raw_trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT NOT NULL, + timestamp INTEGER NOT NULL, + condition_id TEXT NOT NULL, + slug TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + event_slug TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL DEFAULT '', + side TEXT NOT NULL DEFAULT '', + price REAL NOT NULL DEFAULT 0, + size REAL NOT NULL DEFAULT 0, + usdc_size REAL NOT NULL DEFAULT 0, + tx_hash TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'TRADE', + raw_json TEXT NOT NULL, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(address, timestamp, condition_id, tx_hash, type) +); +``` + +Remove the entire `copytrack_raw_positions` table definition. + +- [ ] **Step 2: Update `upsert_raw_trades()` to accept `type`** + +```python +def upsert_raw_trades(self, address: str, trades: list[dict], *, type: str = "TRADE") -> int: + """Insert raw activity data from API. Deduplicates on unique constraint.""" + inserted = 0 + for t in trades: + raw = json.dumps(t) + self.conn.execute( + """INSERT OR IGNORE INTO copytrack_raw_trades + (address, timestamp, condition_id, slug, title, event_slug, + outcome, side, price, size, usdc_size, tx_hash, type, raw_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (address, t.get("timestamp", 0), t.get("conditionId", ""), + t.get("slug", ""), t.get("title", ""), t.get("eventSlug", ""), + t.get("outcome", ""), t.get("side", ""), t.get("price", 0.0), + t.get("size", 0.0), t.get("usdcSize", 0.0), + t.get("transactionHash", ""), type, raw), + ) + inserted += self.conn.execute("SELECT changes()").fetchone()[0] + self.conn.commit() + return inserted +``` + +- [ ] **Step 3: Remove `upsert_raw_positions()` and `get_raw_positions()` methods entirely** + +Delete lines 443-478 (`upsert_raw_positions` and `get_raw_positions`). + +- [ ] **Step 4: Update `delete_raw_data()` — remove positions cleanup** + +```python +def delete_raw_data(self, address: str) -> None: + """Delete all cached raw data for an address.""" + self.conn.execute("DELETE FROM copytrack_raw_trades WHERE address = ?", (address,)) + self.conn.commit() +``` + +- [ ] **Step 5: Update `reset()` — remove `copytrack_raw_positions` drop** + +In the `reset()` method, remove the line `DROP TABLE IF EXISTS copytrack_raw_positions;`. + +- [ ] **Step 6: Update tests in `tests/test_db.py`** + +In `TestCopytrackRawData`: +- Remove all tests that use `upsert_raw_positions` or `get_raw_positions` +- Update `test_insert_and_get_raw_trades` to verify `type` column defaults to `'TRADE'` +- Add `test_insert_redeem_type` — insert with `type="REDEEM"`, verify it's stored and retrievable +- Add `test_dedup_different_types` — same `(address, timestamp, condition_id, tx_hash)` but different `type` should both insert (not dedup) +- Update `test_delete_raw_data` to remove positions assertions + +```python +def test_insert_redeem_type(self, db): + redeems = [{"timestamp": 5000, "conditionId": "0xc1", "slug": "m1", + "title": "M?", "eventSlug": "", "outcome": "", "side": "", + "price": 0, "size": 100.0, "usdcSize": 100.0, + "transactionHash": "0xtx5", "type": "REDEEM"}] + inserted = db.upsert_raw_trades("0xabc", redeems, type="REDEEM") + assert inserted == 1 + rows = db.get_raw_trades("0xabc") + assert len(rows) == 1 + assert rows[0]["type"] == "REDEEM" + +def test_dedup_different_types(self, db): + trade = [{"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", + "title": "M?", "eventSlug": "", "outcome": "Yes", "side": "BUY", + "price": 0.5, "size": 100, "usdcSize": 50, + "transactionHash": "0xtx1"}] + redeem = [{"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", + "title": "M?", "eventSlug": "", "outcome": "", "side": "", + "price": 0, "size": 100, "usdcSize": 100, + "transactionHash": "0xtx1", "type": "REDEEM"}] + db.upsert_raw_trades("0xabc", trade) + db.upsert_raw_trades("0xabc", redeem, type="REDEEM") + rows = db.get_raw_trades("0xabc") + assert len(rows) == 2 +``` + +- [ ] **Step 7: Run tests** + +Run: `python3 -m pytest tests/test_db.py -x -v -k "copytrack"` +Expected: All pass + +- [ ] **Step 8: Commit** + +```bash +git add pm_trader/db.py tests/test_db.py +git commit -m "feat(copytrack): add type column to raw_trades, remove raw_positions table" +``` + +--- + +## Chunk 2: Fetch + Data API + +### Task 2: Generalize `get_all_trades()` with `type` parameter + +**Files:** +- Modify: `pm_trader/data_api.py:65-97` (`get_all_trades`), `pm_trader/data_api.py:121` (closed-positions URL) + +- [ ] **Step 1: Add `type` parameter to `get_all_trades()`** + +Change the signature and the hardcoded `type="TRADE"`: + +```python +def get_all_trades( + self, + address: str, + *, + start: int | None = None, + end: int | None = None, + max_trades: int = 10_000, + type: str = "TRADE", +) -> list[dict]: + """Fetch complete activity history with auto-pagination.""" + all_trades: list[dict] = [] + current_end = end + while len(all_trades) < max_trades: + page = self.get_activity( + address, + start=start, + end=current_end, + limit=500, + type=type, + sort_by="TIMESTAMP", + sort_direction="DESC", + ) + # ... rest unchanged +``` + +- [ ] **Step 2: Update `/closed-positions` URL to `/v1/closed-positions`** + +In `get_closed_positions()`, change: +```python +f"{DATA_API_BASE}/closed-positions" +``` +to: +```python +f"{DATA_API_BASE}/v1/closed-positions" +``` + +- [ ] **Step 3: Run data_api tests** + +Run: `python3 -m pytest tests/test_data_api.py -x -v` +Expected: `TestGetClosedPositions` tests need mock URL updated to `/v1/closed-positions`. Update the mock URLs in those tests. + +- [ ] **Step 4: Commit** + +```bash +git add pm_trader/data_api.py tests/test_data_api.py +git commit -m "feat(data-api): add type param to get_all_trades, update closed-positions to v1" +``` + +### Task 3: Rewrite `fetch()` in `copytrack.py` + +**Files:** +- Modify: `pm_trader/copytrack.py:48-71` (`fetch` function) +- Test: `tests/test_copytrack.py` + +- [ ] **Step 1: Rewrite `fetch()` function** + +```python +def fetch( + address: str, + db: "Database", + data_client: "PolymarketDataClient", + *, + max_trades: int = 10_000, +) -> dict: + """Fetch a user's trades and redeems from API and store in DB. + + Returns summary dict with counts. + """ + trades = data_client.get_all_trades(address, max_trades=max_trades, type="TRADE") + trades_inserted = db.upsert_raw_trades(address, trades, type="TRADE") + + redeems = data_client.get_all_trades(address, max_trades=max_trades, type="REDEEM") + redeems_inserted = db.upsert_raw_trades(address, redeems, type="REDEEM") + + return { + "trades_fetched": len(trades), + "trades_new": trades_inserted, + "redeems_fetched": len(redeems), + "redeems_new": redeems_inserted, + } +``` + +- [ ] **Step 2: Update `TestFetch` in `tests/test_copytrack.py`** + +Replace `_mock_fetch_apis` to mock TRADE + REDEEM activity calls instead of closed-positions: + +```python +class TestFetch: + def _mock_fetch_apis(self, httpx_mock, trades=None, redeems=None): + """Mock activity API for TRADE and REDEEM types.""" + if trades is None: + trades = [{"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", "title": "M?", + "eventSlug": "", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "transactionHash": "0xtx1", + "type": "TRADE"}] + if redeems is None: + redeems = [{"timestamp": 2000, "conditionId": "0xc1", "slug": "m1", "title": "M?", + "eventSlug": "", "outcome": "", "side": "", "price": 0, + "size": 100.0, "usdcSize": 100.0, "transactionHash": "0xtx2", + "type": "REDEEM"}] + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=trades, + ) + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=REDEEM&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=redeems, + ) + + def test_fetch_stores_trades_and_redeems(self, tmp_path, httpx_mock): + from pm_trader.data_api import PolymarketDataClient + + self._mock_fetch_apis(httpx_mock) + db = Database(tmp_path) + db.init_schema() + client = PolymarketDataClient(page_delay=0) + result = fetch("0xabc", db, client) + assert result["trades_fetched"] == 1 + assert result["trades_new"] == 1 + assert result["redeems_fetched"] == 1 + assert result["redeems_new"] == 1 + rows = db.get_raw_trades("0xabc") + assert len(rows) == 2 # 1 trade + 1 redeem + client.close() + db.close() + + def test_fetch_dedup_on_second_call(self, tmp_path, httpx_mock): + from pm_trader.data_api import PolymarketDataClient + + self._mock_fetch_apis(httpx_mock) + self._mock_fetch_apis(httpx_mock) # second call + + db = Database(tmp_path) + db.init_schema() + client = PolymarketDataClient(page_delay=0) + fetch("0xabc", db, client) + result2 = fetch("0xabc", db, client) + assert result2["trades_new"] == 0 + assert result2["redeems_new"] == 0 + assert len(db.get_raw_trades("0xabc")) == 2 # still 1 trade + 1 redeem + client.close() + db.close() +``` + +- [ ] **Step 3: Run tests** + +Run: `python3 -m pytest tests/test_copytrack.py::TestFetch -x -v` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add pm_trader/copytrack.py tests/test_copytrack.py +git commit -m "feat(copytrack): rewrite fetch to pull TRADE+REDEEM activities" +``` + +--- + +## Chunk 3: Replay Rewrite + +### Task 4: Rewrite `replay()` for activity-based approach + +**Files:** +- Modify: `pm_trader/copytrack.py:31-45` (`compute_original_stats`), `pm_trader/copytrack.py:74-260` (`replay`) +- Test: `tests/test_copytrack.py` + +- [ ] **Step 1: Update `compute_original_stats()` for REDEEM** + +```python +def compute_original_stats(trades: list[dict]) -> dict: + """Compute aggregate stats from raw Data API activity data.""" + if not trades: + return {"total_trades": 0, "buy_count": 0, "sell_count": 0, "redeem_count": 0, + "total_invested": 0.0, "total_returned": 0.0, "pnl": 0.0, "roi_pct": 0.0} + buys = [t for t in trades if t.get("side") == "BUY"] + sells = [t for t in trades if t.get("side") == "SELL"] + redeems = [t for t in trades if t.get("type") == "REDEEM"] + total_invested = sum(t.get("usdcSize", 0.0) for t in buys) + total_returned = (sum(t.get("usdcSize", 0.0) for t in sells) + + sum(t.get("usdcSize", 0.0) for t in redeems)) + + pnl = total_returned - total_invested + roi_pct = (pnl / total_invested * 100) if total_invested > 0 else 0.0 + return {"total_trades": len(trades), "buy_count": len(buys), "sell_count": len(sells), + "redeem_count": len(redeems), + "total_invested": total_invested, "total_returned": total_returned, + "pnl": pnl, "roi_pct": roi_pct} +``` + +- [ ] **Step 2: Rewrite `replay()` function** + +Replace the entire `replay()` function. Key changes: +- Remove closed-positions reading (`get_raw_positions`, `closed_cids`, `resolution_price`, `official_pnl`) +- Remove `closed_trades` filtering — all activities participate +- Add REDEEM handling branch +- Remove post-replay settlement simulation block (lines 227-238) +- Remove `positions_value` from `compute_stats()` call +- Remove `official_pnl` override of `original_stats` + +```python +def replay( + target: "Target", + engine: "Engine", + db: "Database", + *, + original_trades: list[dict] | None = None, + limit: int | None = None, +) -> "ReplayResult": + """Replay a target user's activities from locally cached data. + + Requires fetch() to have been called first to populate raw data tables. + """ + from pm_trader.analytics import compute_stats + from pm_trader.models import ( + Comparison, ReplayResult, TradeComparison, + ) + + # Read from local cache (includes both TRADE and REDEEM) + if original_trades is None: + raw_rows = db.get_raw_trades(target.address, limit=100_000) + original_trades = [json.loads(r["raw_json"]) for r in raw_rows] + + sorted_trades = sorted(original_trades, key=lambda t: t.get("timestamp", 0)) + + # Apply limit by market count: take the most recent N markets (by last activity time) + if limit is not None: + cid_last_ts: dict[str, int] = {} + for t in sorted_trades: + cid = t.get("conditionId", "") + ts = t.get("timestamp", 0) + if cid not in cid_last_ts or ts > cid_last_ts[cid]: + cid_last_ts[cid] = ts + recent_cids = sorted(cid_last_ts, key=lambda c: cid_last_ts[c], reverse=True)[:limit] + recent_cid_set = set(recent_cids) + sorted_trades = [t for t in sorted_trades if t.get("conditionId", "") in recent_cid_set] + + replayed = [] + skipped = [] + per_trade_comparisons = [] + + for orig in sorted_trades: + activity_type = orig.get("type", "TRADE") + side = orig.get("side", "").upper() + original_usd = orig.get("usdcSize", 0.0) + original_price = orig.get("price", 0.0) + original_shares = orig.get("size", 0.0) + outcome = orig.get("outcome", "").lower() + condition_id = orig.get("conditionId", "") + slug = orig.get("slug", "") + title = orig.get("title", slug) + event_slug = orig.get("eventSlug", "") + timestamp = orig.get("timestamp", 0) + + if activity_type == "REDEEM": + # Redeem: find all positions for this conditionId, cash out at $1/share + redeemed_usd = 0.0 + # Check all possible outcomes for this conditionId + for outcome_str in ("yes", "no"): + pos = engine.db.get_position(condition_id, outcome_str) + if pos and pos.shares > 0: + redeem_amount = pos.shares * 1.0 + redeemed_usd += redeem_amount + engine.db.upsert_position( + market_condition_id=condition_id, market_slug=slug, + market_question=title, outcome=outcome_str, + shares=0.0, avg_entry_price=pos.avg_entry_price, + total_cost=0.0, realized_pnl=pos.realized_pnl + (redeem_amount - pos.total_cost)) + + if redeemed_usd > 0: + account = engine.get_account() + engine.db.update_cash(account.cash + redeemed_usd) + trade = engine.db.insert_trade( + market_condition_id=condition_id, market_slug=slug, + market_question=title, event_slug=event_slug, + outcome="redeem", side="sell", order_type="fok", + avg_price=1.0, amount_usd=redeemed_usd, + shares=redeemed_usd, fee_rate_bps=0, fee=0.0, + slippage=0.0, levels_filled=1, is_partial=False) + replayed.append(trade) + replay_usd_final = redeemed_usd + else: + skipped.append({**orig, "reason": "No position to redeem"}) + continue + + per_trade_comparisons.append(TradeComparison( + timestamp=str(timestamp), market_slug=slug, side="REDEEM", outcome="redeem", + original_price=0.0, replay_price=1.0, + price_diff=0.0, original_usd=original_usd, replay_usd=replay_usd_final)) + continue + + replay_usd = compute_replay_amount(original_usd, target.sizing) + + if side == "BUY": + account = engine.get_account() + if account.cash < replay_usd: + skipped.append({**orig, "reason": f"Insufficient balance: need ${replay_usd:.2f}, have ${account.cash:.2f}"}) + continue + if original_price <= 0: + skipped.append({**orig, "reason": "Original price is 0"}) + continue + replay_shares = replay_usd / original_price + + trade = engine.db.insert_trade( + market_condition_id=condition_id, market_slug=slug, + market_question=title, event_slug=event_slug, + outcome=outcome, side="buy", order_type="fok", + avg_price=original_price, amount_usd=replay_usd, + shares=replay_shares, fee_rate_bps=0, fee=0.0, + slippage=0.0, levels_filled=1, is_partial=False) + + existing = engine.db.get_position(condition_id, outcome) + if existing and existing.shares > 0: + total_shares = existing.shares + replay_shares + total_cost = existing.total_cost + replay_usd + new_avg = total_cost / total_shares if total_shares > 0 else 0.0 + else: + total_shares = replay_shares + total_cost = replay_usd + new_avg = original_price + + engine.db.upsert_position( + market_condition_id=condition_id, market_slug=slug, + market_question=title, outcome=outcome, + shares=total_shares, avg_entry_price=new_avg, total_cost=total_cost) + engine.db.update_cash(account.cash - replay_usd) + replayed.append(trade) + replay_usd_final = replay_usd + + elif side == "SELL": + pos = engine.db.get_position(condition_id, outcome) + if pos is None or pos.shares <= 0: + skipped.append({**orig, "reason": "No position to sell"}) + continue + + if target.sizing.mode == "equal": + sell_shares = min(original_shares, pos.shares) + elif target.sizing.mode == "proportional": + scale = target.sizing.scale or 1.0 + sell_shares = min(original_shares * scale, pos.shares) + else: + sell_shares = pos.shares + + replay_usd_sell = sell_shares * original_price + cost_of_sold = sell_shares * pos.avg_entry_price + realized = replay_usd_sell - cost_of_sold + + trade = engine.db.insert_trade( + market_condition_id=condition_id, market_slug=slug, + market_question=title, event_slug=event_slug, + outcome=outcome, side="sell", order_type="fok", + avg_price=original_price, amount_usd=replay_usd_sell, + shares=sell_shares, fee_rate_bps=0, fee=0.0, + slippage=0.0, levels_filled=1, is_partial=False) + + remaining_shares = pos.shares - sell_shares + remaining_cost = pos.total_cost - cost_of_sold + new_realized = pos.realized_pnl + realized + + engine.db.upsert_position( + market_condition_id=condition_id, market_slug=slug, + market_question=title, outcome=outcome, + shares=remaining_shares, avg_entry_price=pos.avg_entry_price, + total_cost=max(remaining_cost, 0.0), realized_pnl=new_realized) + + account = engine.get_account() + engine.db.update_cash(account.cash + replay_usd_sell) + replayed.append(trade) + replay_usd_final = replay_usd_sell + else: + skipped.append({**orig, "reason": f"Unknown side: {side}"}) + continue + + per_trade_comparisons.append(TradeComparison( + timestamp=str(timestamp), market_slug=slug, side=side, outcome=outcome, + original_price=original_price, replay_price=original_price, + price_diff=0.0, original_usd=original_usd, replay_usd=replay_usd_final)) + + original_stats = compute_original_stats(sorted_trades) + account = engine.get_account() + all_trades = engine.db.get_trades(limit=100_000) + replay_stats = compute_stats(all_trades, account) + + summary = { + "pnl_diff": replay_stats["pnl"] - original_stats["pnl"], + "total_trades_original": original_stats["total_trades"], + "total_trades_replayed": len(replayed), + "total_skipped": len(skipped), + } + + comparison = Comparison(per_trade=per_trade_comparisons, + original_stats=original_stats, replay_stats=replay_stats, summary=summary) + + return ReplayResult(target=target, original_trades=sorted_trades, + replayed_trades=replayed, skipped=skipped, comparison=comparison) +``` + +- [ ] **Step 3: Update `TestComputeOriginalStats` in `tests/test_copytrack.py`** + +Add test for REDEEM in stats: + +```python +def test_stats_with_redeem(self): + trades = [ + {"side": "BUY", "usdcSize": 100.0, "type": "TRADE"}, + {"side": "", "usdcSize": 100.0, "type": "REDEEM"}, + ] + stats = compute_original_stats(trades) + assert stats["buy_count"] == 1 + assert stats["redeem_count"] == 1 + assert stats["total_invested"] == 100.0 + assert stats["total_returned"] == 100.0 + assert stats["pnl"] == pytest.approx(0.0) +``` + +- [ ] **Step 4: Rewrite `TestReplay` in `tests/test_copytrack.py`** + +Remove `_store_closed_positions` helper. All tests pass `original_trades` directly — no closed-positions dependency. Key test updates: + +1. Remove all `_store_closed_positions` calls +2. `test_replay_basic_closed` → `test_replay_buy_sell_redeem`: BUY + SELL + REDEEM, verify 3 trades replayed +3. `test_replay_filters_active_positions` → remove (no longer relevant — all activities are replayed) +4. Add `test_replay_redeem_without_position`: REDEEM with no matching position → skipped +5. Add `test_replay_losing_market`: BUY only, no REDEEM → shares stuck, P&L reflects loss +6. Keep existing BUY/SELL tests but remove `_store_closed_positions` calls + +```python +class TestReplay: + def _make_engine(self, tmp_path): + engine = Engine(tmp_path) + engine.init_account(10_000.0) + return engine + + def _make_db(self, tmp_path): + db = Database(tmp_path) + db.init_schema() + return db + + def test_replay_buy_sell_redeem(self, tmp_path): + engine = self._make_engine(tmp_path) + db = self._make_db(tmp_path) + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", "price": 0.50, + "size": 100.0, "usdcSize": 50.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "SELL", "price": 0.70, + "size": 50.0, "usdcSize": 35.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 3000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "", "side": "", "price": 0, + "size": 50.0, "usdcSize": 50.0, "eventSlug": "", "type": "REDEEM"}, + ] + result = replay(target, engine, db, original_trades=original_trades) + assert len(result.replayed_trades) == 3 + assert result.comparison.per_trade[2].side == "REDEEM" + engine.close() + db.close() + + def test_replay_redeem_without_position(self, tmp_path): + engine = self._make_engine(tmp_path) + db = self._make_db(tmp_path) + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "", "side": "", "price": 0, + "size": 100.0, "usdcSize": 100.0, "eventSlug": "", "type": "REDEEM"}, + ] + result = replay(target, engine, db, original_trades=original_trades) + assert len(result.replayed_trades) == 0 + assert len(result.skipped) == 1 + assert "no position to redeem" in result.skipped[0]["reason"].lower() + engine.close() + db.close() + + def test_replay_losing_market(self, tmp_path): + """BUY but no SELL or REDEEM — shares stuck, loss reflected.""" + engine = self._make_engine(tmp_path) + db = self._make_db(tmp_path) + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", "price": 0.50, + "size": 100.0, "usdcSize": 50.0, "eventSlug": "", "type": "TRADE"}, + ] + result = replay(target, engine, db, original_trades=original_trades) + assert len(result.replayed_trades) == 1 + # Cash went down by $50, no return + assert result.comparison.replay_stats["pnl"] < 0 + engine.close() + db.close() +``` + +Keep existing tests for: `test_replay_buy_and_sell`, `test_replay_proportional_sizing`, `test_replay_skips_insufficient_balance`, `test_replay_empty_trades`, `test_replay_skips_zero_price`, `test_replay_skips_unknown_side`, `test_replay_buy_into_existing_position`, `test_replay_proportional_sell`, `test_replay_fixed_sell_uses_all_shares`, `test_replay_reads_from_db_cache` — but remove all `_store_closed_positions` calls from each. Add `"type": "TRADE"` to trade dicts where needed. + +For `test_replay_reads_from_db_cache`, the test should still work since `upsert_raw_trades` still exists and `replay()` still reads from DB. + +- [ ] **Step 5: Run tests** + +Run: `python3 -m pytest tests/test_copytrack.py -x -v` +Expected: All pass + +- [ ] **Step 6: Commit** + +```bash +git add pm_trader/copytrack.py tests/test_copytrack.py +git commit -m "feat(copytrack): rewrite replay for activity-based approach with REDEEM" +``` + +--- + +## Chunk 4: CLI, Web, Frontend, Integration Tests + +### Task 5: Update CLI + +**Files:** +- Modify: `pm_trader/cli.py:917-942` (`copytrack_fetch_cmd`) +- Test: `tests/test_cli.py` + +- [ ] **Step 1: Remove `--max-positions` from `copytrack_fetch_cmd`** + +Remove the `@click.option("--max-positions", ...)` decorator and `max_positions` parameter. Update the `fetch()` call to not pass `max_positions`. + +- [ ] **Step 2: Update `test_copytrack_fetch` in `tests/test_cli.py`** + +Add REDEEM mock URL, update assertions for new response shape: + +```python +def test_copytrack_fetch(self, runner, data_dir, httpx_mock): + _invoke(runner, ["init"], data_dir) + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=[ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", + "price": 0.5, "size": 100, "usdcSize": 50, "eventSlug": "", + "transactionHash": "0xtx1", "type": "TRADE"}, + ], + ) + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=REDEEM&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=[], + ) + result = _invoke(runner, ["copytrack", "fetch", "0xabc"], data_dir) + data = _parse(result) + assert data["ok"] is True + assert data["data"]["trades_fetched"] == 1 + assert data["data"]["redeems_fetched"] == 0 +``` + +- [ ] **Step 3: Update replay tests in `tests/test_cli.py`** + +Update `test_copytrack_replay`, `test_copytrack_compare`, `test_copytrack_replay_with_limit` to remove `upsert_raw_positions` calls. Store REDEEM activities via `upsert_raw_trades("0xabc", [...], type="REDEEM")` where needed for settlement. + +For `test_copytrack_replay`: remove `upsert_raw_positions`, trades should work without closed positions now. + +For `test_copytrack_replay_with_limit`: remove `upsert_raw_positions`, add REDEEM activities so limit by market works correctly. + +- [ ] **Step 4: Run CLI tests** + +Run: `python3 -m pytest tests/test_cli.py::TestCopytrackCLI -x -v` +Expected: All pass + +- [ ] **Step 5: Commit** + +```bash +git add pm_trader/cli.py tests/test_cli.py +git commit -m "feat(cli): update copytrack fetch/replay for activity-based approach" +``` + +### Task 6: Update Web Routes + +**Files:** +- Modify: `pm_trader/web/routes/copytrack.py:123-143` (`FetchRequest`, `trigger_fetch`), `pm_trader/web/routes/copytrack.py:154-220` (`trigger_replay`) +- Test: `tests/test_web/test_copytrack.py` + +- [ ] **Step 1: Update `FetchRequest` — remove `max_positions`** + +```python +class FetchRequest(BaseModel): + address: str + max_trades: int = 10_000 +``` + +- [ ] **Step 2: Update `trigger_fetch()` — remove `max_positions` from call** + +```python +result = fetch(body.address, engine.db, client, max_trades=body.max_trades) +``` + +- [ ] **Step 3: Update `trigger_replay()` — remove settlement simulation** + +In `trigger_replay()`, remove the `positions_value` computation and the `official_pnl` override. The replay function no longer needs these. The `replay()` call stays the same — it already handles everything internally. + +- [ ] **Step 4: Update `_store_test_data` helper in `tests/test_web/test_copytrack.py`** + +Replace `upsert_raw_positions` with storing REDEEM activities: + +```python +def _store_test_data(tmp_path, trades=None, redeems=None): + """Store raw data in DB to simulate a prior fetch.""" + from pm_trader.db import Database + db = Database(tmp_path / "default") + db.init_schema() + if trades is None: + trades = [{"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", + "price": 0.5, "size": 100.0, "usdcSize": 50.0, "eventSlug": "", + "transactionHash": "0xtx1", "type": "TRADE"}] + db.upsert_raw_trades("0xabc", trades, type="TRADE") + if redeems is not None: + db.upsert_raw_trades("0xabc", redeems, type="REDEEM") + db.close() +``` + +- [ ] **Step 5: Update `TestCopytrackFetch` mock URLs — add REDEEM mock** + +Add a second `httpx_mock.add_response` for `type=REDEEM` URL. + +- [ ] **Step 6: Update replay/chart tests to remove positions dependency** + +- [ ] **Step 7: Run web tests** + +Run: `python3 -m pytest tests/test_web/test_copytrack.py -x -v` +Expected: All pass + +- [ ] **Step 8: Commit** + +```bash +git add pm_trader/web/routes/copytrack.py tests/test_web/test_copytrack.py +git commit -m "feat(web): update copytrack routes for activity-based replay" +``` + +### Task 7: Update Frontend + +**Files:** +- Modify: `frontend/src/api/client.ts` +- Modify: `frontend/src/pages/Copytrack.tsx` + +- [ ] **Step 1: Update fetch response type in `client.ts`** + +```typescript +export const triggerCopytrackFetch = (address: string) => + api<{ trades_fetched: number; trades_new: number; redeems_fetched: number; redeems_new: number }>(`/api/copytrack/fetch?${params()}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address }), + }) +``` + +- [ ] **Step 2: Update fetch status text in `Copytrack.tsx`** + +Change the `handleFetch` success text: + +```typescript +setFetchStatus(prev => ({ + ...prev, + [target.address]: `${result.trades_fetched.toLocaleString()} trades, ${result.redeems_fetched.toLocaleString()} redeems fetched`, +})) +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +Run: `cd frontend && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/api/client.ts frontend/src/pages/Copytrack.tsx +git commit -m "feat(frontend): update fetch status for activity-based approach" +``` + +### Task 8: Full Test Suite + +- [ ] **Step 1: Run full test suite** + +Run: `python3 -m pytest tests/ -x -q -m "not live"` +Expected: All 813+ tests pass + +- [ ] **Step 2: Fix any failures** + +If any tests fail due to leftover `raw_positions` references, update them. + +- [ ] **Step 3: Final commit if needed** + +```bash +git commit -m "fix: resolve remaining test failures from activity-based replay migration" +``` diff --git a/docs/superpowers/plans/2026-03-14-copytrack-fetch-separation.md b/docs/superpowers/plans/2026-03-14-copytrack-fetch-separation.md new file mode 100644 index 0000000..85b7d94 --- /dev/null +++ b/docs/superpowers/plans/2026-03-14-copytrack-fetch-separation.md @@ -0,0 +1,757 @@ +# Copytrack Fetch/Replay Separation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Separate data fetching from replay computation so replays are instant after the first fetch. + +**Architecture:** Add two SQLite tables (`copytrack_raw_trades`, `copytrack_raw_positions`) to cache raw API data per address. New `fetch()` function in `copytrack.py` pulls from API and stores locally. Existing `replay()` reads from local DB instead of calling API. CLI gets `copytrack fetch` command; web gets `POST /api/copytrack/fetch` endpoint. + +**Tech Stack:** SQLite, httpx (existing), Click CLI, FastAPI + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `pm_trader/db.py` | Modify | Add 2 new tables to schema, add CRUD methods, update `reset()` | +| `pm_trader/copytrack.py` | Modify | Add `fetch()` function, modify `replay()` to read from DB | +| `pm_trader/cli.py` | Modify | Add `copytrack fetch` command, update `copytrack replay` | +| `pm_trader/web/routes/copytrack.py` | Modify | Add `POST /api/copytrack/fetch`, update `POST /api/copytrack/replay` | +| `tests/test_db.py` | Modify | Tests for new DB methods | +| `tests/test_copytrack.py` | Modify | Tests for `fetch()`, update `replay()` tests | +| `tests/test_cli.py` | Modify | Tests for `copytrack fetch` CLI command | +| `tests/test_web/test_copytrack.py` | Modify | Tests for fetch web endpoint | + +--- + +## Chunk 1: Database Schema + CRUD + +### Task 1: Add raw data tables to schema + +**Files:** +- Modify: `pm_trader/db.py` (SCHEMA_SQL, around line 60-89) + +The two new tables store raw API responses per address. Trades use `(address, timestamp, conditionId, transactionHash)` as a natural dedup key. Closed positions use `(address, conditionId)`. + +- [ ] **Step 1: Add tables to SCHEMA_SQL** + +Add after `copytrack_replays` table definition (line 89): + +```sql +CREATE TABLE IF NOT EXISTS copytrack_raw_trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT NOT NULL, + timestamp INTEGER NOT NULL, + condition_id TEXT NOT NULL, + slug TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + event_slug TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL DEFAULT '', + side TEXT NOT NULL DEFAULT '', + price REAL NOT NULL DEFAULT 0, + size REAL NOT NULL DEFAULT 0, + usdc_size REAL NOT NULL DEFAULT 0, + tx_hash TEXT NOT NULL DEFAULT '', + raw_json TEXT NOT NULL, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(address, timestamp, condition_id, tx_hash) +); + +CREATE TABLE IF NOT EXISTS copytrack_raw_positions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT NOT NULL, + condition_id TEXT NOT NULL, + cur_price REAL NOT NULL DEFAULT 0, + avg_price REAL NOT NULL DEFAULT 0, + total_bought REAL NOT NULL DEFAULT 0, + realized_pnl REAL NOT NULL DEFAULT 0, + outcome TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + slug TEXT NOT NULL DEFAULT '', + event_slug TEXT NOT NULL DEFAULT '', + end_date TEXT, + raw_json TEXT NOT NULL, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(address, condition_id) +); +``` + +- [ ] **Step 2: Update `reset()` to drop new tables** + +In `db.py` `reset()` method (line 168-181), add drops before the existing ones: + +```python +DROP TABLE IF EXISTS copytrack_raw_positions; +DROP TABLE IF EXISTS copytrack_raw_trades; +``` + +- [ ] **Step 3: Run existing tests to verify schema change doesn't break anything** + +Run: `python3 -m pytest tests/test_db.py -x -q` +Expected: all pass + +- [ ] **Step 4: Commit** + +```bash +git add pm_trader/db.py +git commit -m "feat(copytrack): add raw trades and positions tables to schema" +``` + +### Task 2: Add CRUD methods for raw data + +**Files:** +- Modify: `pm_trader/db.py` +- Modify: `tests/test_db.py` + +- [ ] **Step 1: Write failing tests for insert/query methods** + +Add to `tests/test_db.py`: + +```python +class TestCopytrackRawData: + def test_insert_and_get_raw_trades(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + trades = [ + {"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", "title": "M1?", + "eventSlug": "e1", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "transactionHash": "0xtx1"}, + ] + db.upsert_raw_trades("0xabc", trades) + result = db.get_raw_trades("0xabc") + assert len(result) == 1 + assert result[0]["condition_id"] == "0xc1" + db.close() + + def test_raw_trades_dedup(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + trade = {"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", "title": "M1?", + "eventSlug": "e1", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "transactionHash": "0xtx1"} + db.upsert_raw_trades("0xabc", [trade]) + db.upsert_raw_trades("0xabc", [trade]) # duplicate + assert len(db.get_raw_trades("0xabc")) == 1 + db.close() + + def test_raw_trades_limit(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + trades = [{"timestamp": i, "conditionId": f"0xc{i}", "slug": "m", "title": "M?", + "eventSlug": "", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 10.0, "usdcSize": 5.0, "transactionHash": f"0xtx{i}"} + for i in range(20)] + db.upsert_raw_trades("0xabc", trades) + result = db.get_raw_trades("0xabc", limit=5) + assert len(result) == 5 + # Most recent first (DESC timestamp) + assert result[0]["timestamp"] == 19 + db.close() + + def test_insert_and_get_raw_positions(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + positions = [ + {"conditionId": "0xc1", "curPrice": 1.0, "avgPrice": 0.5, + "totalBought": 100.0, "realizedPnl": 50.0, "outcome": "Yes", + "title": "M1?", "slug": "m1", "eventSlug": "e1", "endDate": "2026-03-01"}, + ] + db.upsert_raw_positions("0xabc", positions) + result = db.get_raw_positions("0xabc") + assert len(result) == 1 + assert result[0]["cur_price"] == 1.0 + db.close() + + def test_raw_positions_upsert_overwrites(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + pos = {"conditionId": "0xc1", "curPrice": 0.5, "avgPrice": 0.5, + "totalBought": 100.0, "realizedPnl": 0.0, "outcome": "Yes", + "title": "M1?", "slug": "m1", "eventSlug": "e1"} + db.upsert_raw_positions("0xabc", [pos]) + pos["curPrice"] = 1.0 + pos["realizedPnl"] = 50.0 + db.upsert_raw_positions("0xabc", [pos]) + result = db.get_raw_positions("0xabc") + assert len(result) == 1 + assert result[0]["cur_price"] == 1.0 + assert result[0]["realized_pnl"] == 50.0 + db.close() + + def test_delete_raw_data(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + db.upsert_raw_trades("0xabc", [ + {"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", "title": "M1?", + "eventSlug": "", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 10.0, "usdcSize": 5.0, "transactionHash": "0xtx1"}, + ]) + db.upsert_raw_positions("0xabc", [ + {"conditionId": "0xc1", "curPrice": 1.0, "avgPrice": 0.5, + "totalBought": 100.0, "realizedPnl": 50.0, "outcome": "Yes", + "title": "M1?", "slug": "m1", "eventSlug": "e1"}, + ]) + db.delete_raw_data("0xabc") + assert db.get_raw_trades("0xabc") == [] + assert db.get_raw_positions("0xabc") == [] + db.close() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python3 -m pytest tests/test_db.py::TestCopytrackRawData -v` +Expected: FAIL (methods don't exist) + +- [ ] **Step 3: Implement CRUD methods in `db.py`** + +Add to `Database` class, after the Cache section: + +```python +# ------------------------------------------------------------------ +# Copytrack raw data +# ------------------------------------------------------------------ + +def upsert_raw_trades(self, address: str, trades: list[dict]) -> int: + """Insert raw trade data from API. Deduplicates on (address, timestamp, conditionId, txHash).""" + inserted = 0 + for t in trades: + raw = json.dumps(t) + self.conn.execute( + """INSERT OR IGNORE INTO copytrack_raw_trades + (address, timestamp, condition_id, slug, title, event_slug, + outcome, side, price, size, usdc_size, tx_hash, raw_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (address, t.get("timestamp", 0), t.get("conditionId", ""), + t.get("slug", ""), t.get("title", ""), t.get("eventSlug", ""), + t.get("outcome", ""), t.get("side", ""), t.get("price", 0.0), + t.get("size", 0.0), t.get("usdcSize", 0.0), + t.get("transactionHash", ""), raw), + ) + inserted += self.conn.execute("SELECT changes()").fetchone()[0] + self.conn.commit() + return inserted + +def get_raw_trades(self, address: str, *, limit: int = 100_000) -> list[dict]: + """Return cached raw trades for an address, newest first.""" + rows = self.conn.execute( + """SELECT * FROM copytrack_raw_trades + WHERE address = ? ORDER BY timestamp DESC LIMIT ?""", + (address, limit), + ).fetchall() + return [dict(row) for row in rows] + +def upsert_raw_positions(self, address: str, positions: list[dict]) -> int: + """Insert/update raw closed positions from API.""" + inserted = 0 + for p in positions: + raw = json.dumps(p) + self.conn.execute( + """INSERT INTO copytrack_raw_positions + (address, condition_id, cur_price, avg_price, total_bought, + realized_pnl, outcome, title, slug, event_slug, end_date, raw_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (address, condition_id) DO UPDATE SET + cur_price = excluded.cur_price, + avg_price = excluded.avg_price, + total_bought = excluded.total_bought, + realized_pnl = excluded.realized_pnl, + outcome = excluded.outcome, + title = excluded.title, + raw_json = excluded.raw_json, + fetched_at = datetime('now')""", + (address, p.get("conditionId", ""), p.get("curPrice", 0.0), + p.get("avgPrice", 0.0), p.get("totalBought", 0.0), + p.get("realizedPnl", 0.0), p.get("outcome", ""), + p.get("title", ""), p.get("slug", ""), p.get("eventSlug", ""), + p.get("endDate"), raw), + ) + inserted += 1 + self.conn.commit() + return inserted + +def get_raw_positions(self, address: str) -> list[dict]: + """Return cached raw closed positions for an address.""" + rows = self.conn.execute( + "SELECT * FROM copytrack_raw_positions WHERE address = ?", + (address,), + ).fetchall() + return [dict(row) for row in rows] + +def delete_raw_data(self, address: str) -> None: + """Delete all cached raw data for an address.""" + self.conn.execute("DELETE FROM copytrack_raw_trades WHERE address = ?", (address,)) + self.conn.execute("DELETE FROM copytrack_raw_positions WHERE address = ?", (address,)) + self.conn.commit() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python3 -m pytest tests/test_db.py::TestCopytrackRawData -v` +Expected: all pass + +- [ ] **Step 5: Run full test suite** + +Run: `python3 -m pytest tests/ -x -q -m "not live"` +Expected: all pass + +- [ ] **Step 6: Commit** + +```bash +git add pm_trader/db.py tests/test_db.py +git commit -m "feat(copytrack): add CRUD methods for raw trade/position cache" +``` + +--- + +## Chunk 2: Fetch Function + Replay Refactor + +### Task 3: Add `fetch()` function to copytrack.py + +**Files:** +- Modify: `pm_trader/copytrack.py` +- Modify: `tests/test_copytrack.py` + +The `fetch()` function calls the Data API and stores results in DB. Returns a summary dict. + +- [ ] **Step 1: Write failing test for fetch** + +Add to `tests/test_copytrack.py`: + +```python +from pm_trader.copytrack import fetch + +class TestFetch: + def test_fetch_stores_trades_and_positions(self, tmp_path, httpx_mock): + from pm_trader.db import Database + from pm_trader.data_api import PolymarketDataClient + + # Mock trades API + httpx_mock.add_response( + url__regex=r".*/activity\?.*", + json=[ + {"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", "title": "M?", + "eventSlug": "", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "transactionHash": "0xtx1"}, + ], + ) + # Mock closed positions API + httpx_mock.add_response( + url__regex=r".*/closed-positions\?.*", + json=[ + {"conditionId": "0xc1", "curPrice": 1.0, "avgPrice": 0.5, + "totalBought": 50.0, "realizedPnl": 10.0, "outcome": "Yes", + "title": "M?", "slug": "m1", "eventSlug": ""}, + ], + ) + + db = Database(tmp_path) + db.init_schema() + client = PolymarketDataClient(page_delay=0) + result = fetch("0xabc", db, client) + assert result["trades_fetched"] >= 1 + assert result["positions_fetched"] >= 1 + assert len(db.get_raw_trades("0xabc")) == 1 + assert len(db.get_raw_positions("0xabc")) == 1 + client.close() + db.close() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python3 -m pytest tests/test_copytrack.py::TestFetch -v` +Expected: FAIL (function doesn't exist) + +- [ ] **Step 3: Implement `fetch()` in copytrack.py** + +Add to `pm_trader/copytrack.py`: + +```python +def fetch( + address: str, + db: "Database", + data_client: "PolymarketDataClient", + *, + max_trades: int = 10_000, + max_positions: int = 10_000, +) -> dict: + """Fetch a user's trades and closed positions from API and store in DB. + + Returns summary dict with counts. + """ + trades = data_client.get_all_trades(address, max_trades=max_trades) + trades_inserted = db.upsert_raw_trades(address, trades) + + positions = data_client.get_closed_positions(address, max_positions=max_positions) + positions_inserted = db.upsert_raw_positions(address, positions) + + return { + "trades_fetched": len(trades), + "trades_new": trades_inserted, + "positions_fetched": len(positions), + "positions_new": positions_inserted, + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python3 -m pytest tests/test_copytrack.py::TestFetch -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add pm_trader/copytrack.py tests/test_copytrack.py +git commit -m "feat(copytrack): add fetch() to pull and cache API data locally" +``` + +### Task 4: Modify `replay()` to read from local DB + +**Files:** +- Modify: `pm_trader/copytrack.py` +- Modify: `tests/test_copytrack.py` + +Change `replay()` to read from `copytrack_raw_trades` and `copytrack_raw_positions` tables instead of calling the Data API. The `data_client` parameter becomes optional (only needed if data hasn't been fetched yet). + +- [ ] **Step 1: Update `replay()` signature and data source** + +Change `replay()` to accept `db` parameter and read from local tables: + +```python +def replay( + target: "Target", + engine: "Engine", + db: "Database", + *, + original_trades: list[dict] | None = None, + limit: int | None = None, +) -> "ReplayResult": + """Replay a target user's trades from locally cached data. + + Requires fetch() to have been called first to populate raw data tables. + """ + from pm_trader.analytics import compute_stats + from pm_trader.models import Comparison, ReplayResult, TradeComparison + + # Read from local cache + if original_trades is None: + raw_rows = db.get_raw_trades(target.address, limit=limit or 100_000) + original_trades = [json.loads(r["raw_json"]) for r in raw_rows] + + # Read closed positions from local cache + raw_positions = db.get_raw_positions(target.address) + closed_positions = [json.loads(r["raw_json"]) for r in raw_positions] + + # ... rest of replay logic unchanged, but uses closed_positions from above + # instead of calling data_client.get_closed_positions() +``` + +Key changes: +- Replace `data_client` param with `db` param +- Read `original_trades` from `copytrack_raw_trades` via `raw_json` column +- Read `closed_positions` from `copytrack_raw_positions` via `raw_json` column +- Add `limit` param (replay most recent N trades) +- Remove all `data_client` calls from the function + +- [ ] **Step 2: Update existing replay tests** + +All existing `TestReplay` tests currently mock HTTP for closed-positions. Change them to: +1. Insert raw data into DB directly using `db.upsert_raw_trades()` and `db.upsert_raw_positions()` +2. Pass `db` instead of `client` to `replay()` +3. Remove `httpx_mock` dependency from replay tests (no more HTTP mocking needed) + +Example update for `test_replay_basic_closed`: + +```python +def test_replay_basic_closed(self, tmp_path): + engine = self._make_engine(tmp_path) + db = Database(tmp_path) + db.init_schema() + # Store raw data locally (instead of mocking HTTP) + db.upsert_raw_positions("0xabc", [ + {"conditionId": "0xcond1", "realizedPnl": 10.0, "curPrice": 1.0, + "avgPrice": 0.5, "totalBought": 65.0, "outcome": "Yes", + "title": "Will X happen?", "slug": "will-x-happen", "eventSlug": "event-x"}, + ]) + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [ + {"timestamp": 1704067200, "conditionId": "0xcond1", "slug": "will-x-happen", + "title": "Will X happen?", "outcome": "Yes", "side": "BUY", "price": 0.65, + "size": 100.0, "usdcSize": 65.0, "eventSlug": "event-x"}, + {"timestamp": 1704070800, "conditionId": "0xcond1", "slug": "will-x-happen", + "title": "Will X happen?", "outcome": "Yes", "side": "SELL", "price": 0.80, + "size": 100.0, "usdcSize": 80.0, "eventSlug": "event-x"}, + ] + result = replay(target, engine, db, original_trades=original_trades) + assert result.comparison.replay_stats["total_trades"] == 2 + assert len(result.replayed_trades) == 2 + engine.close() + db.close() +``` + +Apply same pattern to all `TestReplay` tests. The `_mock_closed_positions` helper becomes `_store_closed_positions` that calls `db.upsert_raw_positions()`. + +- [ ] **Step 3: Run tests to verify they pass** + +Run: `python3 -m pytest tests/test_copytrack.py -v` +Expected: all pass + +- [ ] **Step 4: Run full test suite** + +Run: `python3 -m pytest tests/ -x -q -m "not live"` +Expected: all pass + +- [ ] **Step 5: Commit** + +```bash +git add pm_trader/copytrack.py tests/test_copytrack.py +git commit -m "refactor(copytrack): replay reads from local DB instead of API" +``` + +--- + +## Chunk 3: CLI + Web Integration + +### Task 5: Add `copytrack fetch` CLI command + +**Files:** +- Modify: `pm_trader/cli.py` +- Modify: `tests/test_cli.py` + +- [ ] **Step 1: Write failing test** + +Add to `tests/test_cli.py` in the copytrack test section: + +```python +def test_copytrack_fetch(self, cli_runner, httpx_mock, tmp_data_dir): + httpx_mock.add_response(url__regex=r".*/activity\?.*", json=[ + {"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", "title": "M?", + "eventSlug": "", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "transactionHash": "0xtx1"}, + ]) + httpx_mock.add_response(url__regex=r".*/closed-positions\?.*", json=[ + {"conditionId": "0xc1", "curPrice": 1.0, "avgPrice": 0.5, + "totalBought": 50.0, "realizedPnl": 10.0, "outcome": "Yes", + "title": "M?", "slug": "m1", "eventSlug": ""}, + ]) + result = cli_runner.invoke(main, ["copytrack", "fetch", "0xabc"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["data"]["trades_fetched"] >= 1 +``` + +- [ ] **Step 2: Implement `copytrack fetch` command** + +Add to `pm_trader/cli.py`: + +```python +@copytrack.command("fetch") +@click.argument("identifier") +@click.option("--max-trades", type=int, default=10_000, help="Max trades to fetch") +@click.option("--max-positions", type=int, default=10_000, help="Max positions to fetch") +@click.pass_context +def copytrack_fetch_cmd(ctx, identifier, max_trades, max_positions): + """Fetch a target's trade history and store locally.""" + from pm_trader.copytrack import fetch + from pm_trader.data_api import PolymarketDataClient + + client = PolymarketDataClient() + try: + if not identifier.startswith("0x"): + address = client.resolve_username(identifier) + else: + address = identifier + + with _get_engine(ctx) as engine: + result = fetch(address, engine.db, client, + max_trades=max_trades, max_positions=max_positions) + click.echo(_ok(result)) + except SimError as e: + click.echo(_err(e)) + sys.exit(1) + finally: + client.close() +``` + +- [ ] **Step 3: Run test to verify it passes** + +Run: `python3 -m pytest tests/test_cli.py -k copytrack_fetch -v` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add pm_trader/cli.py tests/test_cli.py +git commit -m "feat(copytrack): add fetch CLI command" +``` + +### Task 6: Update `copytrack replay` CLI to use local data + +**Files:** +- Modify: `pm_trader/cli.py` +- Modify: `tests/test_cli.py` + +- [ ] **Step 1: Update replay CLI command** + +Change `copytrack_replay_cmd` to: +- Remove `PolymarketDataClient` usage +- Pass `engine.db` to `replay()` instead of `client` +- Add `--limit` option for replaying most recent N trades + +Key changes: +```python +@copytrack.command("replay") +@click.argument("identifier") +@click.option("--limit", type=int, default=None, help="Replay most recent N trades") +@click.option("--sizing", ...) +# ... other options stay the same but remove --start/--end (not needed, data already fetched) +def copytrack_replay_cmd(ctx, identifier, limit, sizing, scale, fixed_amount, delay): + """Replay a target's trades from locally cached data.""" + # ... resolve address ... + with _get_engine(ctx) as engine: + result = replay(target, engine, engine.db, limit=limit) + # ... store result same as before ... +``` + +- [ ] **Step 2: Update existing replay CLI tests** + +Change tests to: +1. First insert raw data into DB (simulate a prior fetch) +2. Then invoke replay command +3. Remove HTTP mocks for closed-positions from replay tests + +- [ ] **Step 3: Run tests** + +Run: `python3 -m pytest tests/test_cli.py -k copytrack -v` +Expected: all pass + +- [ ] **Step 4: Commit** + +```bash +git add pm_trader/cli.py tests/test_cli.py +git commit -m "refactor(copytrack): replay CLI reads from local cache" +``` + +### Task 7: Update web endpoints + +**Files:** +- Modify: `pm_trader/web/routes/copytrack.py` +- Modify: `tests/test_web/test_copytrack.py` + +- [ ] **Step 1: Add `POST /api/copytrack/fetch` endpoint** + +```python +class FetchRequest(BaseModel): + address: str + max_trades: int = 10_000 + max_positions: int = 10_000 + +@router.post("/fetch") +def trigger_fetch(body: FetchRequest, account: str = Query("default")) -> dict: + from pm_trader.copytrack import fetch + from pm_trader.data_api import PolymarketDataClient + + engine = get_engine(account) + client = PolymarketDataClient() + try: + result = fetch(body.address, engine.db, client, + max_trades=body.max_trades, max_positions=body.max_positions) + return {"ok": True, "data": result} + finally: + engine.close() + client.close() +``` + +- [ ] **Step 2: Update `POST /api/copytrack/replay` endpoint** + +Change to read from local DB: +- Remove `PolymarketDataClient` usage +- Pass `main_engine.db` to `replay()` +- The replay still runs in a temp engine for trade simulation, but reads raw data from main DB + +```python +@router.post("/replay") +def trigger_replay(body: ReplayRequest, account: str = Query("default")) -> dict: + import dataclasses, tempfile + from pathlib import Path + from pm_trader.copytrack import replay + from pm_trader.engine import Engine + from pm_trader.models import SizingStrategy, Target + + main_engine = get_engine(account) + try: + # ... build target same as before ... + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_engine = Engine(Path(tmpdir)) + tmp_engine.db.init_account(10_000.0) + try: + result = replay(target, tmp_engine, main_engine.db, limit=body.limit) + finally: + tmp_engine.close() + + # ... store result same as before ... + finally: + main_engine.close() +``` + +Add `limit` field to `ReplayRequest`: +```python +class ReplayRequest(BaseModel): + address: str + sizing_mode: str = "equal" + sizing_param: float | None = None + delay_seconds: int = 0 + limit: int | None = None +``` + +Remove `start`/`end` fields from `ReplayRequest` (not needed — data already fetched and stored locally). + +- [ ] **Step 3: Update web tests** + +Change replay tests to insert raw data into DB first, remove HTTP mocks for closed-positions. Add test for fetch endpoint. + +- [ ] **Step 4: Run full test suite** + +Run: `python3 -m pytest tests/ -x -q -m "not live"` +Expected: all pass + +- [ ] **Step 5: Commit** + +```bash +git add pm_trader/web/routes/copytrack.py tests/test_web/test_copytrack.py +git commit -m "feat(copytrack): add fetch endpoint, replay reads from local cache" +``` + +--- + +## Chunk 4: Frontend Update + +### Task 8: Add Fetch button to frontend + +**Files:** +- Modify: `frontend/src/pages/Copytrack.tsx` + +- [ ] **Step 1: Add Fetch button next to Replay button** + +In the target row, add a "Fetch" button that calls `POST /api/copytrack/fetch`. Show fetch status (fetching... / done with counts). The Replay button should only be active after data has been fetched. + +Flow in UI: +1. User adds target → "Fetch" button appears +2. User clicks Fetch → shows "Fetching..." → shows "1,234 trades, 56 positions cached" +3. User clicks Replay → instant (reads from local cache) + +- [ ] **Step 2: Add `--limit` input field for replay** + +Add an optional number input for "Replay last N trades" next to the Replay button. + +- [ ] **Step 3: Test manually in browser** + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/pages/Copytrack.tsx +git commit -m "feat(copytrack): add Fetch button and limit input to frontend" +``` diff --git a/docs/superpowers/specs/2026-03-13-copytrack-frontend-design.md b/docs/superpowers/specs/2026-03-13-copytrack-frontend-design.md new file mode 100644 index 0000000..2d84d02 --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-copytrack-frontend-design.md @@ -0,0 +1,96 @@ +# Copytrack Frontend Page — Design Spec + +## Goal + +Add a "Copytrack" page to the web dashboard that lets users manage copy-trading targets, trigger replays, and compare original vs replay performance visually. + +## Page Structure + +Single page at `/copytrack`, added as a new nav tab alongside Dashboard and Markets. + +### Section 1: Targets + +Card at the top showing all tracked targets with an inline add form. + +**Target row:** +- Username (bold) + truncated address +- Sizing badge (e.g., "equal", "proportional 0.5x", "fixed $50") +- "Replay" button — triggers a new replay for this target +- "✕" remove button — deletes target and its replays + +**Add form (bottom of card):** +- Text input: `@username or 0x address...` + - Auto-detect: starts with `@` → resolve username via backend; starts with `0x` → use as address directly +- Sizing dropdown: equal / proportional / fixed +- Scale/amount input (shown when proportional or fixed selected) +- "Add Target" button + +**Backend API used:** +- `GET /api/copytrack/targets` — list +- `POST /api/copytrack/targets` — add (body: `{address, username, sizing_mode, sizing_param}`) + - Frontend must resolve `@username` → address before POST. Use the existing CLI pattern: fetch `https://polymarket.com/@{username}` and extract address from page data. + - Alternative: add a `/api/copytrack/resolve` endpoint to do this server-side (recommended — keeps resolution logic in backend). +- `DELETE /api/copytrack/targets/{address}` — remove + +### Section 2: Replay History + +Table listing all past replays, sorted newest first. + +**Columns:** +- Target (username) +- Date +- Sizing mode +- Trades (replayed count + skipped count) +- Original P&L +- Replay P&L +- "View" action — expands/collapses the detail section below + +**Backend API used:** +- Need a new endpoint: `GET /api/copytrack/replays?account=default` — list all replays +- `POST /api/copytrack/replay` — trigger new replay (body: `{address, sizing_mode, sizing_param}`) + +### Section 3: Replay Detail (expanded) + +When a replay row is selected via "View", show an expanded detail section with: + +**Cumulative P&L Chart:** +- Dual-line chart (recharts ``) +- Blue solid line: original trader's cumulative P&L +- Orange dashed line: replay cumulative P&L +- X-axis: trade timestamps +- Y-axis: cumulative P&L in USD +- Legend in top-right corner + +**Stats Comparison Row:** +Four cards in a grid, each showing original vs replay: +- Total P&L +- ROI +- Win Rate +- Trades (total count) + +Color coding: blue (#4f46e5) for original, orange (#f97316) for replay. + +**Backend API used:** +- `GET /api/copytrack/replay/{replay_id}` — full replay data with stats +- `GET /api/copytrack/chart/{replay_id}` — P&L curve data points + +## New Backend Endpoint Needed + +`GET /api/copytrack/replays` — list all replays for the account. Returns array of replay summaries (id, address, username, date, sizing, trade counts, original/replay P&L). This endpoint does not exist yet and must be added. + +Optional: `POST /api/copytrack/resolve` — resolve `@username` to `{address, username}`. Keeps username resolution server-side rather than duplicating in frontend. + +## Frontend Files + +- `frontend/src/pages/Copytrack.tsx` — main page component +- `frontend/src/api/client.ts` — add copytrack API functions +- `frontend/src/api/types.ts` — add copytrack types +- `frontend/src/components/Layout.tsx` — add Copytrack nav link +- `frontend/src/App.tsx` (or router config) — add `/copytrack` route + +## Constraints + +- Follow existing patterns: inline styles, same color scheme, same card/table styles as Dashboard +- Use recharts `` for the P&L chart (already used by `PnlChart.tsx`) +- No new dependencies beyond what's already in the project +- Frontend proxies `/api` → backend, no CORS issues diff --git a/docs/superpowers/specs/2026-03-14-copytrack-activity-replay-design.md b/docs/superpowers/specs/2026-03-14-copytrack-activity-replay-design.md new file mode 100644 index 0000000..abcfb11 --- /dev/null +++ b/docs/superpowers/specs/2026-03-14-copytrack-activity-replay-design.md @@ -0,0 +1,108 @@ +# Copytrack Activity-Based Replay + +## Problem + +Current replay uses `/closed-positions` API to determine market outcomes (`curPrice` 0/1). This is wrong for positions where the user sold all shares before market resolution — `curPrice` shows an intermediate value (e.g., 0.59), not the market result. Additionally, the `/closed-positions` endpoint is deprecated. + +## Solution + +Replace the closed-positions approach with activity-based replay. Fetch all user activities (BUY, SELL, REDEEM) from the `/activity` endpoint and replay them chronologically. REDEEM entries represent market resolution settlement — winning shares redeemed at $1 each. Losing markets have no REDEEM; the loss is captured by the BUY cost with no offsetting return. + +No `/closed-positions` dependency. No `curPrice` inference. P&L falls out naturally: + +``` +Final value = Starting cash - BUY spend + SELL returns + REDEEM payouts +``` + +## Changes + +### Data Layer (`db.py`) + +- Add `type TEXT DEFAULT 'TRADE'` column to `copytrack_raw_trades` table +- Existing rows get `'TRADE'` via the DEFAULT — no migration needed, schema uses `CREATE TABLE IF NOT EXISTS` with the new column +- Add `type` to the unique constraint: `UNIQUE(address, timestamp, condition_id, tx_hash, type)` — prevents TRADE/REDEEM collision on same tx +- Drop `copytrack_raw_positions` table from schema +- Remove `upsert_raw_positions()`, `get_raw_positions()` methods +- Update `upsert_raw_trades()` to accept and store the `type` field +- Update `delete_raw_data()` to remove positions cleanup +- Update `reset()` to drop only `copytrack_raw_trades` (not positions) + +### Fetch (`data_api.py`, `copytrack.py`) + +- Generalize `get_all_trades()` to accept a `type` parameter (default `"TRADE"`). Call it twice: once with `type="TRADE"`, once with `type="REDEEM"` +- Both stored in `copytrack_raw_trades` with appropriate `type` value +- REDEEM activity fields from API: `side=""`, `outcome=""`, `price=0`, `outcomeIndex=999`, `size=N`, `usdcSize=N` (1:1 redemption). These are stored as-is in `raw_json`; the extracted columns (`side`, `price`, etc.) will have empty/zero values which is fine +- Update `/closed-positions` URL to `/v1/closed-positions` (method kept for other uses, no longer called by copytrack) +- Remove `get_closed_positions()` call from `copytrack.fetch()` +- Remove `max_positions` parameter from `fetch()` signature +- `fetch()` returns `{"trades_fetched": N, "redeems_fetched": M, "trades_new": X, "redeems_new": Y}` + +### Replay (`copytrack.py`) + +- Read all raw activities (TRADE + REDEEM) from DB, sorted by timestamp +- Identify activity type from `raw_json` field `type` (or from the new `type` column) +- BUY (`type=TRADE, side=BUY`): spend cash, acquire shares (unchanged) +- SELL (`type=TRADE, side=SELL`): sell shares, receive cash (unchanged) +- REDEEM (`type=REDEEM`): look up ALL positions for this `conditionId` in replay engine (there may be YES or NO). Redeem remaining shares at $1/share. Add `shares * 1.0` to cash. Zero out the position +- Remove: `closed_cids` filtering, `resolution_price` map, `curPrice` inference, post-replay settlement simulation block, `official_pnl` override from closed-positions +- Remove `positions_value` parameter from `compute_stats()` call — REDEEM already converts shares to cash, so no unredeemed positions to value +- `TradeComparison.side`: now accepts `"BUY"`, `"SELL"`, or `"REDEEM"` + +### `compute_original_stats()` + +- Currently filters on `side == "BUY"` and `side == "SELL"` +- Add REDEEM handling: entries where `type == "REDEEM"` (passed via the raw trade dict) +- `total_invested` = sum of BUY `usdcSize` +- `total_returned` = sum of SELL `usdcSize` + sum of REDEEM `usdcSize` +- `pnl` = `total_returned - total_invested` +- `roi_pct` = `pnl / total_invested * 100` + +### Limit + +- `limit` counts by market (`conditionId`), selecting the N most recent markets by their latest activity timestamp +- All activities (BUY, SELL, REDEEM) for selected markets are included + +### Chart (per_trade P&L curve) + +Each activity produces a data point. Chart code (`get_chart`) currently uses `tc["side"]` to determine sign (`BUY` = -1, else +1): +- BUY: sign = -1, cumulative P&L decreases +- SELL: sign = +1, cumulative P&L increases +- REDEEM: sign = +1, cumulative P&L increases (same branch as SELL) + +No chart code change needed — REDEEM has `side != "BUY"` so it naturally gets +1. + +### CLI (`cli.py`) + +- `copytrack fetch`: remove `--max-positions` option, remove `max_positions` from `fetch()` call +- `copytrack replay`: unchanged + +### Web (`web/routes/copytrack.py`) + +- `FetchRequest`: remove `max_positions` field +- `POST /api/copytrack/fetch`: remove `max_positions` from `fetch()` call, update response shape +- `POST /api/copytrack/replay`: remove settlement simulation logic, remove `positions_value` from stats + +### Frontend + +- Fetch status text: `"24 trades, 3 redeems fetched"` (was `"24 trades, 10 positions closed"`) +- Trades table in replay detail: show REDEEM entries alongside BUY/SELL + +### Tests + +All test files that reference closed-positions or raw_positions need updating: + +- `tests/test_db.py`: remove `TestCopytrackRawData` position tests, add `type` column tests +- `tests/test_copytrack.py`: rewrite to use TRADE+REDEEM instead of closed-positions mocks +- `tests/test_cli.py`: remove `--max-positions`, update `test_copytrack_fetch` mock URLs, update replay tests to store REDEEM data +- `tests/test_web/test_copytrack.py`: update `_store_test_data` helper, update fetch test mocks +- `tests/test_data_api.py`: update/remove `TestGetClosedPositions` if no longer used by copytrack + +### What Gets Deleted + +- `copytrack_raw_positions` table and schema +- `upsert_raw_positions()`, `get_raw_positions()` in `db.py` +- `get_closed_positions()` call in `copytrack.fetch()` +- `max_positions` parameter from `fetch()`, CLI, and web route +- `resolution_price` map, `closed_cids` set, settlement simulation in `copytrack.replay()` +- `official_pnl` override of original stats +- `positions_value` computation and parameter in replay diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f9ae577..c87ad88 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom' import Layout from './components/Layout' import Dashboard from './pages/Dashboard' import Markets from './pages/Markets' +import Copytrack from './pages/Copytrack' export default function App() { return ( @@ -10,6 +11,7 @@ export default function App() { }> } /> } /> + } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 797305a..432ebdc 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { ApiResponse, Balance, Position, Stats, TradeRecord, PnlPoint, MarketItem, OrderBook, Tag } from './types' +import type { ApiResponse, Balance, Position, Stats, TradeRecord, PnlPoint, MarketItem, OrderBook, Tag, CopytrackTarget, CopytrackReplay, CopytrackChartPoint } from './types' let currentAccount = 'default' @@ -62,6 +62,36 @@ export const sell = (slug: string, outcome: string, shares: number, order_type = body: JSON.stringify({ slug, outcome, shares, order_type }), }) +// Copytrack +export const fetchCopytrackTargets = () => api(`/api/copytrack/targets?${params()}`) +export const addCopytrackTarget = (address: string, username: string, sizing_mode: string, sizing_param: number | null) => + api(`/api/copytrack/targets?${params()}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, username, sizing_mode, sizing_param, delay_seconds: 0 }), + }) +export const removeCopytrackTarget = (address: string) => + api(`/api/copytrack/targets/${encodeURIComponent(address)}?${params()}`, { method: 'DELETE' }) +export const resolveCopytrackIdentifier = (identifier: string) => + api<{ address: string; username: string }>(`/api/copytrack/resolve`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifier }), + }) +export const triggerCopytrackFetch = (address: string) => + api<{ trades_fetched: number; trades_new: number; redeems_fetched: number; redeems_new: number }>(`/api/copytrack/fetch?${params()}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address }), + }) +export const fetchCopytrackReplays = () => api(`/api/copytrack/replays?${params()}`) +export const triggerCopytrackReplay = (address: string, sizing_mode: string, sizing_param: number | null, limit?: number) => + api<{ replay_id: number; replayed: number; skipped: number }>(`/api/copytrack/replay?${params()}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ address, sizing_mode, sizing_param, ...(limit ? { limit } : {}) }), + }) +export const fetchCopytrackChart = (replayId: number) => + api(`/api/copytrack/chart/${replayId}?${params()}`) +export const fetchCopytrackReplay = (replayId: number) => + api(`/api/copytrack/replay/${replayId}?${params()}`) + // Accounts export const fetchAccounts = () => api('/api/accounts') export const fetchAccountsCompare = () => api<(Stats & { account: string })[]>('/api/accounts/compare') diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index faeaaad..a112f51 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -97,3 +97,46 @@ export interface Tag { label: string slug: string } + +export interface CopytrackTarget { + address: string + username: string + sizing_mode: string + sizing_param: number | null + delay_seconds: number + added_at: string +} + +export interface CopytrackTradeComparison { + timestamp: string + market_slug: string + side: string + outcome: string + original_price: number + replay_price: number + price_diff: number + original_usd: number + replay_usd: number +} + +export interface CopytrackReplay { + id: number + address: string + username: string + started_at: string + sizing_mode: string + sizing_param: number | null + total_trades: number + replayed: number + skipped: number + original_stats: Record + replay_stats: Record + per_trade?: CopytrackTradeComparison[] + status: string +} + +export interface CopytrackChartPoint { + timestamp: number + original_pnl: number + replay_pnl: number +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 7e0aab0..7bd5056 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -48,6 +48,10 @@ export default function Layout() { textDecoration: 'none', color: isActive ? '#4f46e5' : '#666', fontWeight: isActive ? 600 : 400, })}>Markets + ({ + textDecoration: 'none', color: isActive ? '#4f46e5' : '#666', + fontWeight: isActive ? 600 : 400, + })}>Copytrack
{balance && ( diff --git a/frontend/src/components/PositionsList.tsx b/frontend/src/components/PositionsList.tsx index 6e64878..f8aa480 100644 --- a/frontend/src/components/PositionsList.tsx +++ b/frontend/src/components/PositionsList.tsx @@ -1,41 +1,121 @@ +import { useState } from 'react' import type { Position } from '../api/types' interface Props { positions: Position[] } +type SortKey = 'value' | 'pnl' | 'pnl_pct' + +const sortFns: Record number> = { + value: (a, b) => b.current_value - a.current_value, + pnl: (a, b) => b.unrealized_pnl - a.unrealized_pnl, + pnl_pct: (a, b) => b.percent_pnl - a.percent_pnl, +} + export default function PositionsList({ positions }: Props) { + const [sortBy, setSortBy] = useState('value') + const [asc, setAsc] = useState(false) + if (positions.length === 0) { return

No open positions

} + + const sorted = [...positions].sort((a, b) => { + const result = sortFns[sortBy](a, b) + return asc ? -result : result + }) + + const fmt = (v: number) => `$${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + const pnlColor = (v: number) => v >= 0 ? '#16a34a' : '#dc2626' + const cents = (v: number) => `${(v * 100).toFixed(1)}¢` + + const handleSort = (key: SortKey) => { + if (sortBy === key) setAsc(!asc) + else { setSortBy(key); setAsc(false) } + } + + const arrow = (key: SortKey) => sortBy === key ? (asc ? ' ▲' : ' ▼') : '' + + const headerStyle = (key: SortKey, align: 'left' | 'center' | 'right') => ({ + textAlign: align, cursor: 'pointer', userSelect: 'none' as const, + color: sortBy === key ? '#4f46e5' : '#888', + fontWeight: sortBy === key ? 600 : 500, + }) + return (
- {positions.map((p, i) => ( + {/* Header */} +
+ Market + Avg → Current + handleSort('value')}> + Value{arrow('value')} + + handleSort('pnl')}> + P&L{arrow('pnl')} + +
+ + {sorted.map((p, i) => (
- - {p.market_question || p.market_slug} - {' '} - (e.currentTarget.style.opacity = '0.7')} - onMouseLeave={e => (e.currentTarget.style.opacity = '1')} - >↗ - - - {p.outcome.toUpperCase()} @ {p.live_price.toFixed(2)} - - = 0 ? '#16a34a' : '#dc2626', - }}> - {p.unrealized_pnl >= 0 ? '+' : ''}${p.unrealized_pnl.toFixed(2)} - + {/* Market name + outcome badge + link */} +
+
+ + {p.outcome.toUpperCase()} + + + {p.market_question || p.market_slug} + + (e.currentTarget.style.opacity = '0.7')} + onMouseLeave={e => (e.currentTarget.style.opacity = '1')} + >↗ +
+
+ {p.shares.toFixed(1)} shares +
+
+ + {/* Avg → Current price */} +
+ {cents(p.avg_entry_price)} + → + {cents(p.live_price)} +
+ + {/* Current value */} +
+ {fmt(p.current_value)} +
+ + {/* P&L */} +
+
+ {p.unrealized_pnl >= 0 ? '+' : ''}{fmt(p.unrealized_pnl)} +
+
+ ({p.percent_pnl >= 0 ? '+' : ''}{p.percent_pnl.toFixed(1)}%) +
+
))}
diff --git a/frontend/src/components/TradeHistory.tsx b/frontend/src/components/TradeHistory.tsx index 83735c4..55783e4 100644 --- a/frontend/src/components/TradeHistory.tsx +++ b/frontend/src/components/TradeHistory.tsx @@ -89,7 +89,7 @@ export default function TradeHistory({ trades }: Props) { {t.side.toUpperCase()} {t.outcome.toUpperCase()} - ${t.avg_price.toFixed(2)} + {(t.avg_price * 100).toFixed(1)}¢ ${t.amount_usd.toFixed(2)} {t.shares.toFixed(2)} ${t.fee.toFixed(2)} diff --git a/frontend/src/pages/Copytrack.tsx b/frontend/src/pages/Copytrack.tsx new file mode 100644 index 0000000..6d9f86e --- /dev/null +++ b/frontend/src/pages/Copytrack.tsx @@ -0,0 +1,443 @@ +import { useEffect, useState } from 'react' +import { LineChart, Line, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer } from 'recharts' +import { + fetchCopytrackTargets, addCopytrackTarget, removeCopytrackTarget, + resolveCopytrackIdentifier, fetchCopytrackReplays, triggerCopytrackReplay, + triggerCopytrackFetch, fetchCopytrackChart, fetchCopytrackReplay, +} from '../api/client' +import type { CopytrackTarget, CopytrackReplay, CopytrackChartPoint, CopytrackTradeComparison } from '../api/types' + +export default function Copytrack() { + const [targets, setTargets] = useState([]) + const [replays, setReplays] = useState([]) + const [identifier, setIdentifier] = useState('') + const [sizingMode, setSizingMode] = useState('equal') + const [sizingParam, setSizingParam] = useState('') + const [adding, setAdding] = useState(false) + const [fetchingAddr, setFetchingAddr] = useState(null) + const [fetchStatus, setFetchStatus] = useState>({}) + const [replayingAddr, setReplayingAddr] = useState(null) + const [replayLimit, setReplayLimit] = useState>({}) + const [error, setError] = useState('') + const [expandedId, setExpandedId] = useState(null) + const [chartData, setChartData] = useState([]) + const [selectedReplay, setSelectedReplay] = useState(null) + const [perTrade, setPerTrade] = useState([]) + const [showAllReplays, setShowAllReplays] = useState(false) + + const refresh = () => { + fetchCopytrackTargets().then(setTargets).catch(() => {}) + fetchCopytrackReplays().then(setReplays).catch(() => {}) + } + + useEffect(() => { refresh() }, []) + + const handleAdd = async () => { + if (!identifier.trim()) return + setAdding(true) + setError('') + try { + const resolved = await resolveCopytrackIdentifier(identifier.trim()) + const param = sizingParam ? parseFloat(sizingParam) : null + await addCopytrackTarget(resolved.address, resolved.username, sizingMode, param) + setIdentifier('') + setSizingParam('') + refresh() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Failed to add target') + } finally { + setAdding(false) + } + } + + const handleRemove = async (address: string) => { + await removeCopytrackTarget(address) + refresh() + } + + const handleFetch = async (target: CopytrackTarget) => { + setFetchingAddr(target.address) + setFetchStatus(prev => ({ ...prev, [target.address]: 'Fetching...' })) + setError('') + try { + const result = await triggerCopytrackFetch(target.address) + setFetchStatus(prev => ({ + ...prev, + [target.address]: `${result.trades_fetched.toLocaleString()} trades, ${result.redeems_fetched.toLocaleString()} redeems fetched`, + })) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fetch failed') + setFetchStatus(prev => ({ ...prev, [target.address]: '' })) + } finally { + setFetchingAddr(null) + } + } + + const handleReplay = async (target: CopytrackTarget) => { + setReplayingAddr(target.address) + setError('') + try { + const limitVal = replayLimit[target.address] ? parseInt(replayLimit[target.address]) : undefined + const result = await triggerCopytrackReplay(target.address, target.sizing_mode, target.sizing_param, limitVal) + refresh() + setExpandedId(result.replay_id) + loadChart(result.replay_id) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Replay failed') + } finally { + setReplayingAddr(null) + } + } + + const loadChart = async (replayId: number) => { + const replay = replays.find(r => r.id === replayId) + setSelectedReplay(replay || null) + try { + const [chartResult, replayDetail] = await Promise.all([ + fetchCopytrackChart(replayId), + fetchCopytrackReplay(replayId), + ]) + setChartData(chartResult) + setPerTrade(replayDetail.per_trade || []) + } catch { + setChartData([]) + setPerTrade([]) + } + } + + const toggleExpand = (replay: CopytrackReplay) => { + if (expandedId === replay.id) { + setExpandedId(null) + setSelectedReplay(null) + setChartData([]) + setPerTrade([]) + } else { + setExpandedId(replay.id) + setSelectedReplay(replay) + loadChart(replay.id) + } + } + + const fmt = (v: number) => `$${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + const pnlColor = (v: number) => v >= 0 ? '#16a34a' : '#dc2626' + const sizingLabel = (mode: string, param: number | null) => { + if (mode === 'proportional' && param != null) return `proportional ${param}x` + if (mode === 'fixed' && param != null) return `fixed $${param}` + return mode + } + + return ( +
+ {/* Targets */} +
+
Targets
+ + {targets.map(t => ( +
+
+
+ @{t.username} + + {t.address.slice(0, 6)}...{t.address.slice(-4)} + +
+
+ + {sizingLabel(t.sizing_mode, t.sizing_param)} + + + Replay last + setReplayLimit(prev => ({ ...prev, [t.address]: e.target.value }))} + placeholder="all" + style={{ width: 40, padding: '3px 6px', border: '1px solid #ddd', borderRadius: 4, fontSize: '0.8em', textAlign: 'center' }} + /> + markets + + handleRemove(t.address)} + style={{ color: '#dc2626', cursor: 'pointer', fontSize: '1.1em' }} + >✕ +
+
+ {fetchStatus[t.address] && ( +
+ {fetchStatus[t.address]} +
+ )} +
+ ))} + + {/* Add form */} +
+ setIdentifier(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAdd()} + placeholder="@username or 0x address..." + style={{ flex: 1, minWidth: 200, padding: '6px 10px', border: '1px solid #ddd', borderRadius: 6, fontSize: '0.9em' }} + /> + + {(sizingMode === 'proportional' || sizingMode === 'fixed') && ( + setSizingParam(e.target.value)} + placeholder={sizingMode === 'proportional' ? 'scale (e.g. 0.5)' : 'amount ($)'} + style={{ width: 100, padding: '6px', border: '1px solid #ddd', borderRadius: 6, fontSize: '0.85em' }} + /> + )} + +
+ {error &&
{error}
} +
+ + {/* Replay History */} +
+
Replay History
+ + {replays.length === 0 ? ( +

No replays yet. Add a target and click "Replay" to get started.

+ ) : ( + <> + + + + {['Target', 'Date', 'Sizing', 'Trades', 'Original P&L', 'Replay P&L', ''].map(h => ( + + ))} + + + + {(showAllReplays ? replays : replays.slice(0, 5)).map(r => { + const origPnl = r.original_stats?.pnl ?? 0 + const replayPnl = r.replay_stats?.pnl ?? 0 + const isExpanded = expandedId === r.id + return ( + toggleExpand(r)}> + + + + + + + + + ) + })} + +
{h}
@{r.username}{new Date(r.started_at).toLocaleDateString()}{sizingLabel(r.sizing_mode, r.sizing_param)}{r.replayed}{r.skipped > 0 ? ` (${r.skipped} skipped)` : ''} + {origPnl >= 0 ? '+' : ''}{fmt(origPnl)} + + {replayPnl >= 0 ? '+' : ''}{fmt(replayPnl)} + + {isExpanded ? 'View ▲' : 'View ▼'} +
+ {replays.length > 5 && ( +
+ +
+ )} + + )} +
+ + {/* Expanded Replay Detail */} + {expandedId && selectedReplay && ( +
+
+
+ @{selectedReplay.username} — Replay #{selectedReplay.id} +
+ + {new Date(selectedReplay.started_at).toLocaleString()} + +
+ + {/* Chart */} +
+ {chartData.length > 0 ? ( + + + new Date(v * 1000).toLocaleDateString()} + fontSize={11} + stroke="#888" + /> + `$${v}`} + fontSize={11} + stroke="#888" + /> + fmt(v)} + labelFormatter={v => new Date(Number(v) * 1000).toLocaleString()} + /> + + + + + + ) : ( +
+ Loading chart... +
+ )} +
+ + {/* Stats comparison */} +
+ {[ + ['Total P&L', selectedReplay.original_stats?.pnl ?? 0, selectedReplay.replay_stats?.pnl ?? 0], + ['ROI', selectedReplay.original_stats?.roi_pct ?? 0, selectedReplay.replay_stats?.roi_pct ?? 0], + ['Trades', selectedReplay.original_stats?.total_trades ?? 0, selectedReplay.replay_stats?.total_trades ?? 0], + ].map(([label, orig, replay]) => ( +
+
{label as string}
+
+ + {label === 'Total P&L' ? fmt(orig as number) : + label === 'ROI' ? `${(orig as number).toFixed(1)}%` : + String(orig)} + + vs + + {label === 'Total P&L' ? fmt(replay as number) : + label === 'ROI' ? `${(replay as number).toFixed(1)}%` : + String(replay)} + +
+
+ ))} +
+ + {/* Per-trade details */} + {perTrade.length > 0 && ( +
+
Trades ({perTrade.length})
+ + + + {['Time', 'Market', 'Side', 'Outcome', 'Price', 'Original $', 'Replay $'].map(h => ( + + ))} + + + + {(() => { + // Detect open markets: conditionIds that only have BUY (no SELL/REDEEM) + const slugSides: Record> = {} + for (const t of perTrade) { + if (!slugSides[t.market_slug]) slugSides[t.market_slug] = new Set() + slugSides[t.market_slug].add(t.side) + } + const openSlugs = new Set( + Object.entries(slugSides) + .filter(([, sides]) => sides.has('BUY') && !sides.has('SELL') && !sides.has('REDEEM')) + .map(([slug]) => slug) + ) + return perTrade.map((t, i) => { + const sideColor = t.side === 'BUY' ? '#16a34a' : t.side === 'REDEEM' ? '#7c3aed' : '#dc2626' + const isOpen = openSlugs.has(t.market_slug) + return ( + + + + + + + + + + ) + }) + })()} + +
{h}
+ {new Date(Number(t.timestamp) * 1000).toLocaleDateString()} + + {t.market_slug} + {isOpen && ⏳} + + {t.side} + {t.outcome.toUpperCase()}{(t.original_price * 100).toFixed(1)}¢{fmt(t.original_usd)}{fmt(t.replay_usd)}
+ {perTrade.some(t => { + const slugSides: Record> = {} + for (const pt of perTrade) { + if (!slugSides[pt.market_slug]) slugSides[pt.market_slug] = new Set() + slugSides[pt.market_slug].add(pt.side) + } + return Object.values(slugSides).some(sides => sides.has('BUY') && !sides.has('SELL') && !sides.has('REDEEM')) + }) && ( +
+ ⏳ = open market (not yet sold/redeemed). P&L reflects cash spent, not final outcome. Re-fetch after resolution for updated results. +
+ )} +
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index d543f9e..53f938c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -71,7 +71,7 @@ export default function Dashboard() { ) : ( <> {/* Two-column: positions + metrics */} -
+
Open Positions ({positions.length})
diff --git a/pm_trader/cli.py b/pm_trader/cli.py index 2b18bed..b3af9cc 100644 --- a/pm_trader/cli.py +++ b/pm_trader/cli.py @@ -914,28 +914,57 @@ def copytrack_trades(identifier: str, limit: int) -> None: client.close() +@copytrack.command("fetch") +@click.argument("identifier") +@click.option("--max-trades", type=int, default=10_000, help="Max trades to fetch.") +@click.pass_context +def copytrack_fetch_cmd(ctx: click.Context, identifier: str, max_trades: int) -> None: + """Fetch a target's trade history and store locally.""" + from pm_trader.copytrack import fetch + from pm_trader.data_api import PolymarketDataClient + + client = PolymarketDataClient() + try: + if not identifier.startswith("0x"): + address = client.resolve_username(identifier) + else: + address = identifier + + with _get_engine(ctx) as engine: + result = fetch(address, engine.db, client, + max_trades=max_trades) + click.echo(_ok(result)) + except SimError as e: + click.echo(_err(e)) + sys.exit(1) + finally: + client.close() + + @copytrack.command("replay") @click.argument("identifier") -@click.option("--start", type=str, default=None, help="Start date (YYYY-MM-DD).") -@click.option("--end", type=str, default=None, help="End date (YYYY-MM-DD).") +@click.option("--limit", type=int, default=None, help="Replay most recent N markets.") @click.option("--sizing", type=click.Choice(["equal", "proportional", "fixed"]), default=None) @click.option("--scale", type=float, default=None) @click.option("--fixed-amount", type=float, default=None) @click.option("--delay", type=int, default=None) @click.pass_context -def copytrack_replay_cmd(ctx: click.Context, identifier: str, start: str | None, end: str | None, sizing: str | None, scale: float | None, fixed_amount: float | None, delay: int | None) -> None: - """Replay a target's trades in your paper trader.""" - from datetime import datetime, timedelta +def copytrack_replay_cmd(ctx: click.Context, identifier: str, limit: int | None, sizing: str | None, scale: float | None, fixed_amount: float | None, delay: int | None) -> None: + """Replay a target's trades from locally cached data.""" + from datetime import timedelta from pm_trader.copytrack import replay - from pm_trader.data_api import PolymarketDataClient from pm_trader.models import SizingStrategy, Target - client = PolymarketDataClient() try: if not identifier.startswith("0x"): - address = client.resolve_username(identifier) - username = identifier.lstrip("@") + from pm_trader.data_api import PolymarketDataClient + client = PolymarketDataClient() + try: + address = client.resolve_username(identifier) + username = identifier.lstrip("@") + finally: + client.close() else: address = identifier username = identifier[:10] + "..." @@ -955,25 +984,18 @@ def copytrack_replay_cmd(ctx: click.Context, identifier: str, start: str | None, delay=timedelta(seconds=delay_seconds), ) - start_ts = None - end_ts = None - if start: - start_ts = int(datetime.strptime(start, "%Y-%m-%d").timestamp()) - if end: - end_ts = int(datetime.strptime(end, "%Y-%m-%d").timestamp()) - with _get_engine(ctx) as engine: - result = replay(target, engine, client, start=start_ts, end=end_ts) + result = replay(target, engine, engine.db, limit=limit) per_trade_json = json.dumps([dataclasses.asdict(tc) for tc in result.comparison.per_trade]) engine.db.conn.execute( """INSERT INTO copytrack_replays - (address, username, started_at, finished_at, start_date, end_date, + (address, username, started_at, finished_at, sizing_mode, sizing_param, delay_seconds, total_trades, replayed, skipped, original_stats, replay_stats, per_trade, status) - VALUES (?, ?, datetime('now'), datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'done')""", - (address, username, start, end, + VALUES (?, ?, datetime('now'), datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, 'done')""", + (address, username, sizing_mode, sizing_param, delay_seconds, len(result.original_trades), len(result.replayed_trades), len(result.skipped), json.dumps(result.comparison.original_stats), @@ -996,8 +1018,6 @@ def copytrack_replay_cmd(ctx: click.Context, identifier: str, start: str | None, except SimError as e: click.echo(_err(e)) sys.exit(1) - finally: - client.close() @copytrack.command("compare") @@ -1052,7 +1072,9 @@ def web(host: str, port: int, reload: bool) -> None: import uvicorn click.echo(f"Starting dashboard at http://{host}:{port}") if reload: - uvicorn.run("pm_trader.web.app:create_app", factory=True, host=host, port=port, reload=True) + import importlib, pathlib + pkg_dir = str(pathlib.Path(importlib.import_module("pm_trader").__file__).parent) + uvicorn.run("pm_trader.web.app:create_app", factory=True, host=host, port=port, reload=True, reload_dirs=[pkg_dir]) else: from pm_trader.web.app import create_app uvicorn.run(create_app(), host=host, port=port) diff --git a/pm_trader/copytrack.py b/pm_trader/copytrack.py index 14b6699..78ded8f 100644 --- a/pm_trader/copytrack.py +++ b/pm_trader/copytrack.py @@ -2,11 +2,15 @@ Fetches a target user's trade history, replays it through the paper trader, and produces per-trade + aggregate comparisons. + +Two-step workflow: + 1. fetch() — pull data from API, store in local DB + 2. replay() — read from local DB, run simulation (instant) """ from __future__ import annotations -from collections import defaultdict +import json from pm_trader.models import SizingStrategy @@ -24,63 +28,92 @@ def compute_replay_amount(original_usd: float, sizing: SizingStrategy) -> float: def compute_original_stats(trades: list[dict]) -> dict: - """Compute aggregate stats from raw Data API trade data.""" + """Compute aggregate stats from raw Data API activity data.""" if not trades: - return {"total_trades": 0, "buy_count": 0, "sell_count": 0, - "total_invested": 0.0, "total_returned": 0.0, "pnl": 0.0, "win_rate": 0.0} + return {"total_trades": 0, "buy_count": 0, "sell_count": 0, "redeem_count": 0, + "total_invested": 0.0, "total_returned": 0.0, "pnl": 0.0, "roi_pct": 0.0} buys = [t for t in trades if t.get("side") == "BUY"] sells = [t for t in trades if t.get("side") == "SELL"] + redeems = [t for t in trades if t.get("type") == "REDEEM"] total_invested = sum(t.get("usdcSize", 0.0) for t in buys) - total_returned = sum(t.get("usdcSize", 0.0) for t in sells) - - buy_cost: dict[tuple[str, str], float] = defaultdict(float) - buy_shares: dict[tuple[str, str], float] = defaultdict(float) - for t in buys: - key = (t.get("conditionId", ""), t.get("outcome", "")) - buy_cost[key] += t.get("usdcSize", 0.0) - buy_shares[key] += t.get("size", 0.0) - wins = 0 - for t in sells: - key = (t.get("conditionId", ""), t.get("outcome", "")) - total_sh = buy_shares.get(key, 0.0) - entry_price = buy_cost[key] / total_sh if total_sh > 0 else t.get("price", 0.0) - if t.get("price", 0.0) > entry_price: - wins += 1 - win_rate = wins / len(sells) if sells else 0.0 + total_returned = (sum(t.get("usdcSize", 0.0) for t in sells) + + sum(t.get("usdcSize", 0.0) for t in redeems)) + pnl = total_returned - total_invested + roi_pct = (pnl / total_invested * 100) if total_invested > 0 else 0.0 return {"total_trades": len(trades), "buy_count": len(buys), "sell_count": len(sells), + "redeem_count": len(redeems), "total_invested": total_invested, "total_returned": total_returned, - "pnl": total_returned - total_invested, "win_rate": win_rate} + "pnl": pnl, "roi_pct": roi_pct} + + +def fetch( + address: str, + db: "Database", + data_client: "PolymarketDataClient", + *, + max_trades: int = 10_000, +) -> dict: + """Fetch a user's trades and redeems from API and store in DB. + + Returns summary dict with counts. + """ + trades = data_client.get_all_trades(address, max_trades=max_trades, type="TRADE") + trades_inserted = db.upsert_raw_trades(address, trades, type="TRADE") + + redeems = data_client.get_all_trades(address, max_trades=max_trades, type="REDEEM") + redeems_inserted = db.upsert_raw_trades(address, redeems, type="REDEEM") + + return { + "trades_fetched": len(trades), + "trades_new": trades_inserted, + "redeems_fetched": len(redeems), + "redeems_new": redeems_inserted, + } def replay( target: "Target", engine: "Engine", - data_client: "PolymarketDataClient", + db: "Database", *, original_trades: list[dict] | None = None, - start: int | None = None, - end: int | None = None, + limit: int | None = None, ) -> "ReplayResult": - """Replay a target user's trades in the paper trader. - For delay=0, uses original trade price by writing directly to DB. + """Replay a target user's activities from locally cached data. + + Requires fetch() to have been called first to populate raw data tables. """ from pm_trader.analytics import compute_stats - from pm_trader.data_api import PolymarketDataClient - from pm_trader.engine import Engine from pm_trader.models import ( - Comparison, ReplayResult, Target, TradeComparison, + Comparison, ReplayResult, TradeComparison, ) + # Read from local cache (includes both TRADE and REDEEM) if original_trades is None: - original_trades = data_client.get_all_trades(target.address, start=start, end=end) + raw_rows = db.get_raw_trades(target.address, limit=100_000) + original_trades = [json.loads(r["raw_json"]) for r in raw_rows] sorted_trades = sorted(original_trades, key=lambda t: t.get("timestamp", 0)) + + # Apply limit by market count: take the most recent N markets (by last activity time) + if limit is not None: + cid_last_ts: dict[str, int] = {} + for t in sorted_trades: + cid = t.get("conditionId", "") + ts = t.get("timestamp", 0) + if cid not in cid_last_ts or ts > cid_last_ts[cid]: + cid_last_ts[cid] = ts + recent_cids = sorted(cid_last_ts, key=lambda c: cid_last_ts[c], reverse=True)[:limit] + recent_cid_set = set(recent_cids) + sorted_trades = [t for t in sorted_trades if t.get("conditionId", "") in recent_cid_set] + replayed = [] skipped = [] per_trade_comparisons = [] for orig in sorted_trades: + activity_type = orig.get("type", "TRADE") side = orig.get("side", "").upper() original_usd = orig.get("usdcSize", 0.0) original_price = orig.get("price", 0.0) @@ -92,6 +125,56 @@ def replay( event_slug = orig.get("eventSlug", "") timestamp = orig.get("timestamp", 0) + if activity_type == "REDEEM": + # Redeem: settle positions for this conditionId. + # API usdcSize tells us the result: >0 = won, 0 = lost. + won = original_usd > 0 + redeemed_usd = 0.0 + for outcome_str in ("yes", "no"): + pos = engine.db.get_position(condition_id, outcome_str) + if pos and pos.shares > 0: + redeem_amount = pos.shares * 1.0 if won else 0.0 + redeemed_usd += redeem_amount + realized = redeem_amount - pos.total_cost + engine.db.upsert_position( + market_condition_id=condition_id, market_slug=slug, + market_question=title, outcome=outcome_str, + shares=0.0, avg_entry_price=pos.avg_entry_price, + total_cost=0.0, realized_pnl=pos.realized_pnl + realized) + + if won and redeemed_usd > 0: + account = engine.get_account() + engine.db.update_cash(account.cash + redeemed_usd) + trade = engine.db.insert_trade( + market_condition_id=condition_id, market_slug=slug, + market_question=title, event_slug=event_slug, + outcome="redeem", side="sell", order_type="fok", + avg_price=1.0, amount_usd=redeemed_usd, + shares=redeemed_usd, fee_rate_bps=0, fee=0.0, + slippage=0.0, levels_filled=1, is_partial=False) + replayed.append(trade) + replay_usd_final = redeemed_usd + elif not won: + # Lost: zero out positions, no cash back, still count as replayed + trade = engine.db.insert_trade( + market_condition_id=condition_id, market_slug=slug, + market_question=title, event_slug=event_slug, + outcome="redeem", side="sell", order_type="fok", + avg_price=0.0, amount_usd=0.0, + shares=original_shares, fee_rate_bps=0, fee=0.0, + slippage=0.0, levels_filled=1, is_partial=False) + replayed.append(trade) + replay_usd_final = 0.0 + else: + skipped.append({**orig, "reason": "No position to redeem"}) + continue + + per_trade_comparisons.append(TradeComparison( + timestamp=str(timestamp), market_slug=slug, side="REDEEM", outcome="redeem", + original_price=1.0 if won else 0.0, replay_price=1.0 if won else 0.0, + price_diff=0.0, original_usd=original_usd, replay_usd=replay_usd_final)) + continue + replay_usd = compute_replay_amount(original_usd, target.sizing) if side == "BUY": @@ -182,7 +265,7 @@ def replay( original_stats = compute_original_stats(sorted_trades) account = engine.get_account() all_trades = engine.db.get_trades(limit=100_000) - replay_stats = compute_stats(all_trades, account, positions_value=0.0) + replay_stats = compute_stats(all_trades, account) summary = { "pnl_diff": replay_stats["pnl"] - original_stats["pnl"], diff --git a/pm_trader/data_api.py b/pm_trader/data_api.py index 2f0c98c..5421f2a 100644 --- a/pm_trader/data_api.py +++ b/pm_trader/data_api.py @@ -69,8 +69,9 @@ def get_all_trades( start: int | None = None, end: int | None = None, max_trades: int = 10_000, + type: str = "TRADE", ) -> list[dict]: - """Fetch complete trade history with auto-pagination.""" + """Fetch complete activity history with auto-pagination.""" all_trades: list[dict] = [] current_end = end while len(all_trades) < max_trades: @@ -79,7 +80,7 @@ def get_all_trades( start=start, end=current_end, limit=500, - type="TRADE", + type=type, sort_by="TIMESTAMP", sort_direction="DESC", ) @@ -97,12 +98,52 @@ def get_all_trades( return all_trades def get_positions(self, address: str) -> list[dict]: - """Fetch current positions for a user.""" + """Fetch current (active) positions for a user.""" resp = self._http.get(f"{DATA_API_BASE}/positions", params={"user": address}) if resp.status_code != 200: raise ApiError(f"Data API error: {resp.status_code}", status_code=resp.status_code) return resp.json() + def get_closed_positions( + self, + address: str, + *, + max_positions: int = 10_000, + ) -> list[dict]: + """Fetch all closed/resolved positions with auto-pagination. + + API hard-caps limit at 50 per page, so we paginate via offset. + """ + _PAGE = 50 + all_positions: list[dict] = [] + offset = 0 + while len(all_positions) < max_positions: + resp = self._http.get( + f"{DATA_API_BASE}/v1/closed-positions", + params={ + "user": address, + "limit": _PAGE, + "offset": offset, + "sortBy": "TIMESTAMP", + "sortDirection": "DESC", + }, + ) + if resp.status_code != 200: + raise ApiError( + f"Data API error: {resp.status_code}", + status_code=resp.status_code, + ) + page = resp.json() + if not page: + break + remaining = max_positions - len(all_positions) + all_positions.extend(page[:remaining]) + if len(page) < _PAGE: + break + offset += _PAGE + time.sleep(self._page_delay) + return all_positions + def get_profile(self, address: str) -> dict: """Fetch public profile by wallet address.""" resp = self._http.get(f"{GAMMA_BASE}/public-profile", params={"address": address}) @@ -126,7 +167,10 @@ def resolve_username(self, username: str) -> str: try: data = _json.loads(match.group(1)) - address = data["props"]["pageProps"]["address"] + pp = data["props"]["pageProps"] + address = pp.get("address") or pp.get("proxyAddress") or pp.get("primaryAddress") + if not address: + raise KeyError("no address field") except (KeyError, _json.JSONDecodeError): raise UsernameResolutionError(f"@{username}") diff --git a/pm_trader/db.py b/pm_trader/db.py index 512cf61..314b4e2 100644 --- a/pm_trader/db.py +++ b/pm_trader/db.py @@ -87,6 +87,26 @@ per_trade TEXT, status TEXT NOT NULL DEFAULT 'running' ); + +CREATE TABLE IF NOT EXISTS copytrack_raw_trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + address TEXT NOT NULL, + timestamp INTEGER NOT NULL, + condition_id TEXT NOT NULL, + slug TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + event_slug TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL DEFAULT '', + side TEXT NOT NULL DEFAULT '', + price REAL NOT NULL DEFAULT 0, + size REAL NOT NULL DEFAULT 0, + usdc_size REAL NOT NULL DEFAULT 0, + tx_hash TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'TRADE', + raw_json TEXT NOT NULL, + fetched_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(address, timestamp, condition_id, tx_hash, type) +); """ @@ -169,6 +189,7 @@ def reset(self) -> None: """Drop all data and re-create schema.""" self.conn.executescript( """\ + DROP TABLE IF EXISTS copytrack_raw_trades; DROP TABLE IF EXISTS copytrack_replays; DROP TABLE IF EXISTS copytrack_targets; DROP TABLE IF EXISTS trades; @@ -368,6 +389,44 @@ def get_cache(self, key: str) -> dict | list | None: return None return json.loads(row["data"]) + # ------------------------------------------------------------------ + # Copytrack raw data + # ------------------------------------------------------------------ + + def upsert_raw_trades(self, address: str, trades: list[dict], *, type: str = "TRADE") -> int: + """Insert raw activity data from API. Deduplicates on unique constraint.""" + inserted = 0 + for t in trades: + raw = json.dumps(t) + self.conn.execute( + """INSERT OR IGNORE INTO copytrack_raw_trades + (address, timestamp, condition_id, slug, title, event_slug, + outcome, side, price, size, usdc_size, tx_hash, type, raw_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (address, t.get("timestamp", 0), t.get("conditionId", ""), + t.get("slug", ""), t.get("title", ""), t.get("eventSlug", ""), + t.get("outcome", ""), t.get("side", ""), t.get("price", 0.0), + t.get("size", 0.0), t.get("usdcSize", 0.0), + t.get("transactionHash", ""), type, raw), + ) + inserted += self.conn.execute("SELECT changes()").fetchone()[0] + self.conn.commit() + return inserted + + def get_raw_trades(self, address: str, *, limit: int = 100_000) -> list[dict]: + """Return cached raw trades for an address, newest first.""" + rows = self.conn.execute( + """SELECT * FROM copytrack_raw_trades + WHERE address = ? ORDER BY timestamp DESC LIMIT ?""", + (address, limit), + ).fetchall() + return [dict(row) for row in rows] + + def delete_raw_data(self, address: str) -> None: + """Delete all cached raw data for an address.""" + self.conn.execute("DELETE FROM copytrack_raw_trades WHERE address = ?", (address,)) + self.conn.commit() + # --------------------------------------------------------------------------- # Row conversion helpers diff --git a/pm_trader/web/routes/copytrack.py b/pm_trader/web/routes/copytrack.py index a237964..6652875 100644 --- a/pm_trader/web/routes/copytrack.py +++ b/pm_trader/web/routes/copytrack.py @@ -70,29 +70,100 @@ def remove_target(address: str, account: str = Query("default")) -> dict: engine.close() +@router.get("/replays") +def list_replays(account: str = Query("default")) -> dict: + engine = get_engine(account) + try: + rows = engine.db.conn.execute( + """SELECT id, address, username, started_at, sizing_mode, sizing_param, + total_trades, replayed, skipped, original_stats, replay_stats, status + FROM copytrack_replays ORDER BY started_at DESC""" + ).fetchall() + result = [] + for row in rows: + d = dict(row) + for field in ("original_stats", "replay_stats"): + if d.get(field): + d[field] = _json.loads(d[field]) + result.append(d) + return {"ok": True, "data": result} + finally: + engine.close() + + +class ResolveRequest(BaseModel): + identifier: str + + +@router.post("/resolve") +def resolve_username(body: ResolveRequest) -> dict: + from pm_trader.data_api import PolymarketDataClient + + identifier = body.identifier.strip() + client = PolymarketDataClient() + try: + if identifier.startswith("0x") and len(identifier) > 10: + # Look up profile to get the real username + try: + profile = client.get_profile(identifier) + username = profile.get("name") or identifier[:10] + "..." + except Exception: + username = identifier[:10] + "..." + return {"ok": True, "data": {"address": identifier, "username": username}} + + username = identifier.lstrip("@") + address = client.resolve_username(username) + return {"ok": True, "data": {"address": address, "username": username}} + except Exception as e: + return {"ok": False, "error": str(e), "code": "RESOLVE_FAILED"} + finally: + client.close() + + +class FetchRequest(BaseModel): + address: str + max_trades: int = 10_000 + + +@router.post("/fetch") +def trigger_fetch(body: FetchRequest, account: str = Query("default")) -> dict: + """Fetch a target's trades and redeems from API, store locally.""" + from pm_trader.copytrack import fetch + from pm_trader.data_api import PolymarketDataClient + + engine = get_engine(account) + client = PolymarketDataClient() + try: + result = fetch(body.address, engine.db, client, + max_trades=body.max_trades) + return {"ok": True, "data": result} + finally: + engine.close() + client.close() + + class ReplayRequest(BaseModel): address: str sizing_mode: str = "equal" sizing_param: float | None = None delay_seconds: int = 0 - start: str | None = None - end: str | None = None + limit: int | None = None @router.post("/replay") def trigger_replay(body: ReplayRequest, account: str = Query("default")) -> dict: - """Trigger a copy-trade replay (synchronous for now).""" + """Trigger a copy-trade replay from locally cached data.""" import dataclasses - from datetime import datetime + import tempfile + from pathlib import Path from pm_trader.copytrack import replay - from pm_trader.data_api import PolymarketDataClient + from pm_trader.engine import Engine from pm_trader.models import SizingStrategy, Target - engine = get_engine(account) - client = PolymarketDataClient() + main_engine = get_engine(account) try: - row = engine.db.conn.execute( + row = main_engine.db.conn.execute( "SELECT * FROM copytrack_targets WHERE address = ?", (body.address,), ).fetchone() @@ -109,28 +180,33 @@ def trigger_replay(body: ReplayRequest, account: str = Query("default")) -> dict delay=timedelta(seconds=body.delay_seconds), ) - start_ts = int(datetime.strptime(body.start, "%Y-%m-%d").timestamp()) if body.start else None - end_ts = int(datetime.strptime(body.end, "%Y-%m-%d").timestamp()) if body.end else None - - result = replay(target, engine, client, start=start_ts, end=end_ts) + # Run replay in an isolated temp account (10k starting balance) + # but read raw data from main DB + with tempfile.TemporaryDirectory() as tmpdir: + tmp_engine = Engine(Path(tmpdir)) + tmp_engine.db.init_account(10_000.0) + try: + result = replay(target, tmp_engine, main_engine.db, limit=body.limit) + finally: + tmp_engine.close() per_trade_json = _json.dumps([dataclasses.asdict(tc) for tc in result.comparison.per_trade]) - cursor = engine.db.conn.execute( + cursor = main_engine.db.conn.execute( """INSERT INTO copytrack_replays - (address, username, started_at, finished_at, start_date, end_date, + (address, username, started_at, finished_at, sizing_mode, sizing_param, delay_seconds, total_trades, replayed, skipped, original_stats, replay_stats, per_trade, status) - VALUES (?, ?, datetime('now'), datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'done')""", - (body.address, username, body.start, body.end, + VALUES (?, ?, datetime('now'), datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, 'done')""", + (body.address, username, body.sizing_mode, body.sizing_param, body.delay_seconds, len(result.original_trades), len(result.replayed_trades), len(result.skipped), _json.dumps(result.comparison.original_stats), _json.dumps(result.comparison.replay_stats), per_trade_json), ) - engine.db.conn.commit() + main_engine.db.conn.commit() replay_id = cursor.lastrowid return {"ok": True, "data": { @@ -140,8 +216,7 @@ def trigger_replay(body: ReplayRequest, account: str = Query("default")) -> dict "comparison": result.comparison.summary, }} finally: - engine.close() - client.close() + main_engine.close() @router.get("/replay/{replay_id}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 55f9f92..601ba4f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1094,9 +1094,12 @@ def test_web_reload(self, mock_run): runner = click.testing.CliRunner() result = runner.invoke(main, ["web", "--port", "9999", "--reload"]) assert result.exit_code == 0 + import pathlib, importlib + pkg_dir = str(pathlib.Path(importlib.import_module("pm_trader").__file__).parent) mock_run.assert_called_once_with( "pm_trader.web.app:create_app", factory=True, host="127.0.0.1", port=9999, reload=True, + reload_dirs=[pkg_dir], ) @@ -1154,33 +1157,67 @@ def test_copytrack_trades(self, runner, data_dir, httpx_mock): assert data["ok"] is True assert len(data["data"]) == 1 - def test_copytrack_replay(self, runner, data_dir, httpx_mock): - # Init account first + def test_copytrack_fetch(self, runner, data_dir, httpx_mock): _invoke(runner, ["init"], data_dir) - # Add target - _invoke(runner, ["copytrack", "add", "0xabc"], data_dir) - # Mock trade fetch — get_all_trades calls get_activity with these params httpx_mock.add_response( url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", json=[ {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", - "title": "Market 1?", "outcome": "Yes", "side": "BUY", - "price": 0.50, "size": 100.0, "usdcSize": 50.0, "eventSlug": ""}, + "title": "Market?", "outcome": "Yes", "side": "BUY", + "price": 0.5, "size": 100, "usdcSize": 50, "eventSlug": "", + "transactionHash": "0xtx1", "type": "TRADE"}, ], ) + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=REDEEM&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=[], + ) + result = _invoke(runner, ["copytrack", "fetch", "0xabc"], data_dir) + data = _parse(result) + assert data["ok"] is True + assert data["data"]["trades_fetched"] == 1 + assert data["data"]["redeems_fetched"] == 0 + + def test_copytrack_replay(self, runner, data_dir): + # Init account and add target + _invoke(runner, ["init"], data_dir) + _invoke(runner, ["copytrack", "add", "0xabc"], data_dir) + # Store raw data directly in DB (simulating a prior fetch) + from pm_trader.db import Database + db = Database(data_dir / "default") + db.upsert_raw_trades("0xabc", [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market 1?", "outcome": "Yes", "side": "BUY", + "price": 0.50, "size": 100.0, "usdcSize": 50.0, "eventSlug": "", + "transactionHash": "0xtx1", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market 1?", "outcome": "Yes", "side": "SELL", + "price": 0.60, "size": 100.0, "usdcSize": 60.0, "eventSlug": "", + "transactionHash": "0xtx2", "type": "TRADE"}, + ]) + db.close() result = _invoke(runner, ["copytrack", "replay", "0xabc"], data_dir) data = _parse(result) assert data["ok"] is True - assert data["data"]["trades_replayed"] == 1 + assert data["data"]["trades_replayed"] == 2 - def test_copytrack_compare(self, runner, data_dir, httpx_mock): + def test_copytrack_compare(self, runner, data_dir): _invoke(runner, ["init"], data_dir) _invoke(runner, ["copytrack", "add", "0xabc"], data_dir) - httpx_mock.add_response(json=[ + # Store raw data directly in DB + from pm_trader.db import Database + db = Database(data_dir / "default") + db.upsert_raw_trades("0xabc", [ {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", "outcome": "Yes", "side": "BUY", - "price": 0.5, "size": 100, "usdcSize": 50, "eventSlug": ""}, + "price": 0.5, "size": 100, "usdcSize": 50, "eventSlug": "", + "transactionHash": "0xtx1", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "SELL", + "price": 0.6, "size": 100, "usdcSize": 60, "eventSlug": "", + "transactionHash": "0xtx2", "type": "TRADE"}, ]) + db.close() _invoke(runner, ["copytrack", "replay", "0xabc"], data_dir) result = _invoke(runner, ["copytrack", "compare", "0xabc"], data_dir) data = _parse(result) @@ -1233,39 +1270,55 @@ def test_copytrack_trades_error(self, runner, data_dir, httpx_mock): assert result.exit_code == 1 def test_copytrack_replay_username_resolution(self, runner, data_dir, httpx_mock): - """Cover username resolution path in copytrack_replay (lines 937-938).""" + """Cover username resolution path in copytrack_replay.""" _invoke(runner, ["init"], data_dir) - # Add target first (by raw address to avoid needing polymarket.com mock) _invoke(runner, ["copytrack", "add", "0xresolved"], data_dir) # Mock username resolution for the replay command httpx_mock.add_response( url="https://polymarket.com/@testuser", text='', ) - # Mock the activity fetch for replay - httpx_mock.add_response( - url="https://data-api.polymarket.com/activity?user=0xresolved&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", - json=[], - ) + # No raw data in DB — replay should still succeed with 0 trades result = _invoke(runner, ["copytrack", "replay", "@testuser"], data_dir) assert result.exit_code == 0 data = _parse(result) assert data["ok"] is True - def test_copytrack_replay_with_dates(self, runner, data_dir, httpx_mock): - """Cover start/end date parsing in copytrack_replay (lines 961, 963).""" + def test_copytrack_replay_with_limit(self, runner, data_dir): + """Limit selects the most recent N markets (by conditionId), including all their trades.""" _invoke(runner, ["init"], data_dir) _invoke(runner, ["copytrack", "add", "0xabc"], data_dir) - httpx_mock.add_response(json=[]) - result = _invoke(runner, [ - "copytrack", "replay", "0xabc", - "--start", "2026-01-01", "--end", "2026-03-01", - ], data_dir) + from pm_trader.db import Database + db = Database(data_dir / "default") + db.upsert_raw_trades("0xabc", [ + # Old market (cond1): BUY+SELL, last trade at ts=2000 + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Old Market?", "outcome": "Yes", "side": "BUY", + "price": 0.5, "size": 100, "usdcSize": 50, "eventSlug": "", + "transactionHash": "0xtx1", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", + "title": "Old Market?", "outcome": "Yes", "side": "SELL", + "price": 0.6, "size": 100, "usdcSize": 60, "eventSlug": "", + "transactionHash": "0xtx2", "type": "TRADE"}, + # New market (cond2): BUY+SELL, last trade at ts=4000 + {"timestamp": 3000, "conditionId": "0xcond2", "slug": "m2", + "title": "New Market?", "outcome": "Yes", "side": "BUY", + "price": 0.4, "size": 50, "usdcSize": 20, "eventSlug": "", + "transactionHash": "0xtx3", "type": "TRADE"}, + {"timestamp": 4000, "conditionId": "0xcond2", "slug": "m2", + "title": "New Market?", "outcome": "Yes", "side": "SELL", + "price": 0.5, "size": 50, "usdcSize": 25, "eventSlug": "", + "transactionHash": "0xtx4", "type": "TRADE"}, + ]) + db.close() + # limit=1 → most recent market (cond2), both BUY+SELL = 2 trades replayed + result = _invoke(runner, ["copytrack", "replay", "0xabc", "--limit", "1"], data_dir) data = _parse(result) assert data["ok"] is True + assert data["data"]["trades_replayed"] == 2 def test_copytrack_replay_error(self, runner, data_dir, httpx_mock): - """Cover the SimError catch in copytrack_replay (lines 996-998).""" + """Cover the SimError catch in copytrack_replay.""" httpx_mock.add_response( url="https://polymarket.com/@baduser", text="no data", diff --git a/tests/test_copytrack.py b/tests/test_copytrack.py index eeb400f..1510b97 100644 --- a/tests/test_copytrack.py +++ b/tests/test_copytrack.py @@ -5,8 +5,8 @@ import pytest -from pm_trader.copytrack import compute_original_stats, compute_replay_amount, replay -from pm_trader.data_api import PolymarketDataClient +from pm_trader.copytrack import compute_original_stats, compute_replay_amount, fetch, replay +from pm_trader.db import Database from pm_trader.engine import Engine from pm_trader.models import SizingStrategy, Target @@ -36,31 +36,94 @@ def test_fixed_default_amount(self): class TestComputeOriginalStats: def test_basic_stats(self): trades = [ - {"side": "BUY", "usdcSize": 100.0, "price": 0.5, "size": 200.0, "conditionId": "c1", "outcome": "Yes", "slug": "m1"}, - {"side": "SELL", "usdcSize": 150.0, "price": 0.75, "size": 200.0, "conditionId": "c1", "outcome": "Yes", "slug": "m1"}, + {"side": "BUY", "usdcSize": 100.0, "price": 0.5, "size": 200.0, "conditionId": "c1", "outcome": "Yes", "slug": "m1", "type": "TRADE"}, + {"side": "SELL", "usdcSize": 150.0, "price": 0.75, "size": 200.0, "conditionId": "c1", "outcome": "Yes", "slug": "m1", "type": "TRADE"}, ] stats = compute_original_stats(trades) assert stats["total_trades"] == 2 assert stats["buy_count"] == 1 assert stats["sell_count"] == 1 + assert stats["redeem_count"] == 0 assert stats["total_invested"] == 100.0 assert stats["total_returned"] == 150.0 assert stats["pnl"] == pytest.approx(50.0) + assert stats["roi_pct"] == pytest.approx(50.0) def test_empty_trades(self): stats = compute_original_stats([]) assert stats["total_trades"] == 0 assert stats["pnl"] == 0.0 + assert stats["roi_pct"] == 0.0 + assert stats["redeem_count"] == 0 - def test_win_rate(self): + def test_stats_with_redeem(self): trades = [ - {"side": "BUY", "usdcSize": 100.0, "price": 0.5, "size": 200.0, "conditionId": "c1", "outcome": "Yes", "slug": "m1"}, - {"side": "SELL", "usdcSize": 120.0, "price": 0.6, "size": 200.0, "conditionId": "c1", "outcome": "Yes", "slug": "m1"}, - {"side": "BUY", "usdcSize": 100.0, "price": 0.5, "size": 200.0, "conditionId": "c2", "outcome": "Yes", "slug": "m2"}, - {"side": "SELL", "usdcSize": 80.0, "price": 0.4, "size": 200.0, "conditionId": "c2", "outcome": "Yes", "slug": "m2"}, + {"side": "BUY", "usdcSize": 100.0, "type": "TRADE"}, + {"side": "", "usdcSize": 100.0, "type": "REDEEM"}, ] stats = compute_original_stats(trades) - assert stats["win_rate"] == pytest.approx(0.5) + assert stats["buy_count"] == 1 + assert stats["redeem_count"] == 1 + assert stats["total_invested"] == 100.0 + assert stats["total_returned"] == 100.0 + assert stats["pnl"] == pytest.approx(0.0) + + +class TestFetch: + def _mock_fetch_apis(self, httpx_mock, trades=None, redeems=None): + """Mock activity API for TRADE and REDEEM types.""" + if trades is None: + trades = [{"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", "title": "M?", + "eventSlug": "", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "transactionHash": "0xtx1", + "type": "TRADE"}] + if redeems is None: + redeems = [{"timestamp": 2000, "conditionId": "0xc1", "slug": "m1", "title": "M?", + "eventSlug": "", "outcome": "", "side": "", "price": 0, + "size": 100.0, "usdcSize": 100.0, "transactionHash": "0xtx2", + "type": "REDEEM"}] + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=trades, + ) + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=REDEEM&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=redeems, + ) + + def test_fetch_stores_trades_and_redeems(self, tmp_path, httpx_mock): + from pm_trader.data_api import PolymarketDataClient + + self._mock_fetch_apis(httpx_mock) + db = Database(tmp_path) + db.init_schema() + client = PolymarketDataClient(page_delay=0) + result = fetch("0xabc", db, client) + assert result["trades_fetched"] == 1 + assert result["trades_new"] == 1 + assert result["redeems_fetched"] == 1 + assert result["redeems_new"] == 1 + rows = db.get_raw_trades("0xabc") + assert len(rows) == 2 # 1 trade + 1 redeem + client.close() + db.close() + + def test_fetch_dedup_on_second_call(self, tmp_path, httpx_mock): + from pm_trader.data_api import PolymarketDataClient + + self._mock_fetch_apis(httpx_mock) + self._mock_fetch_apis(httpx_mock) # second call + + db = Database(tmp_path) + db.init_schema() + client = PolymarketDataClient(page_delay=0) + fetch("0xabc", db, client) + result2 = fetch("0xabc", db, client) + assert result2["trades_new"] == 0 + assert result2["redeems_new"] == 0 + assert len(db.get_raw_trades("0xabc")) == 2 # still 1 trade + 1 redeem + client.close() + db.close() class TestReplay: @@ -69,183 +132,344 @@ def _make_engine(self, tmp_path): engine.init_account(10_000.0) return engine - def test_replay_basic_buy(self, tmp_path, httpx_mock): + def _make_db(self, tmp_path): + db = Database(tmp_path) + db.init_schema() + return db + + def test_replay_buy_sell_redeem(self, tmp_path): engine = self._make_engine(tmp_path) - client = PolymarketDataClient() + db = self._make_db(tmp_path) target = Target(username="testuser", address="0xabc", sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) - original_trades = [{"timestamp": 1704067200, "conditionId": "0xcond1", "slug": "will-x-happen", - "title": "Will X happen?", "outcome": "Yes", "side": "BUY", "price": 0.65, - "size": 100.0, "usdcSize": 65.0, "eventSlug": "event-x"}] - result = replay(target, engine, client, original_trades=original_trades) - assert result.comparison.replay_stats["total_trades"] == 1 - assert len(result.replayed_trades) == 1 - assert result.replayed_trades[0].side == "buy" - assert result.replayed_trades[0].avg_price == pytest.approx(0.65) - assert len(result.skipped) == 0 - account = engine.get_account() - assert account.cash < 10_000.0 + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", "price": 0.50, + "size": 100.0, "usdcSize": 50.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "SELL", "price": 0.70, + "size": 50.0, "usdcSize": 35.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 3000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "", "side": "", "price": 0, + "size": 50.0, "usdcSize": 50.0, "eventSlug": "", "type": "REDEEM"}, + ] + result = replay(target, engine, db, original_trades=original_trades) + assert len(result.replayed_trades) == 3 + assert result.comparison.per_trade[2].side == "REDEEM" engine.close() - client.close() + db.close() - def test_replay_buy_and_sell(self, tmp_path, httpx_mock): + def test_replay_buy_and_sell(self, tmp_path): engine = self._make_engine(tmp_path) - client = PolymarketDataClient() + db = self._make_db(tmp_path) target = Target(username="testuser", address="0xabc", sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) original_trades = [ {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", "title": "Market 1?", - "outcome": "Yes", "side": "BUY", "price": 0.50, "size": 100.0, "usdcSize": 50.0, "eventSlug": ""}, + "outcome": "Yes", "side": "BUY", "price": 0.50, "size": 100.0, "usdcSize": 50.0, "eventSlug": "", "type": "TRADE"}, {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", "title": "Market 1?", - "outcome": "Yes", "side": "SELL", "price": 0.70, "size": 100.0, "usdcSize": 70.0, "eventSlug": ""}, + "outcome": "Yes", "side": "SELL", "price": 0.70, "size": 100.0, "usdcSize": 70.0, "eventSlug": "", "type": "TRADE"}, ] - result = replay(target, engine, client, original_trades=original_trades) + result = replay(target, engine, db, original_trades=original_trades) assert len(result.replayed_trades) == 2 assert result.comparison.per_trade[0].side == "BUY" assert result.comparison.per_trade[1].side == "SELL" engine.close() - client.close() + db.close() - def test_replay_proportional_sizing(self, tmp_path, httpx_mock): + def test_replay_proportional_sizing(self, tmp_path): engine = self._make_engine(tmp_path) - client = PolymarketDataClient() + db = self._make_db(tmp_path) target = Target(username="testuser", address="0xabc", sizing=SizingStrategy(mode="proportional", scale=0.5), delay=timedelta(seconds=0)) - original_trades = [{"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", - "title": "Market 1?", "outcome": "Yes", "side": "BUY", "price": 0.50, - "size": 200.0, "usdcSize": 100.0, "eventSlug": ""}] - result = replay(target, engine, client, original_trades=original_trades) + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market 1?", "outcome": "Yes", "side": "BUY", "price": 0.50, + "size": 200.0, "usdcSize": 100.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market 1?", "outcome": "Yes", "side": "SELL", "price": 0.60, + "size": 200.0, "usdcSize": 120.0, "eventSlug": "", "type": "TRADE"}, + ] + result = replay(target, engine, db, original_trades=original_trades) assert result.replayed_trades[0].amount_usd == pytest.approx(50.0) engine.close() - client.close() + db.close() - def test_replay_skips_insufficient_balance(self, tmp_path, httpx_mock): + def test_replay_skips_insufficient_balance(self, tmp_path): engine = self._make_engine(tmp_path) engine.db.update_cash(0.50) - client = PolymarketDataClient() + db = self._make_db(tmp_path) target = Target(username="testuser", address="0xabc", sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) - original_trades = [{"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", - "title": "Market 1?", "outcome": "Yes", "side": "BUY", "price": 0.50, - "size": 200.0, "usdcSize": 100.0, "eventSlug": ""}] - result = replay(target, engine, client, original_trades=original_trades) + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market 1?", "outcome": "Yes", "side": "BUY", "price": 0.50, + "size": 200.0, "usdcSize": 100.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market 1?", "outcome": "Yes", "side": "SELL", "price": 0.60, + "size": 200.0, "usdcSize": 120.0, "eventSlug": "", "type": "TRADE"}, + ] + result = replay(target, engine, db, original_trades=original_trades) assert len(result.replayed_trades) == 0 - assert len(result.skipped) == 1 + assert len(result.skipped) == 2 assert "insufficient" in result.skipped[0]["reason"].lower() engine.close() - client.close() + db.close() - def test_replay_empty_trades(self, tmp_path, httpx_mock): + def test_replay_empty_trades(self, tmp_path): engine = self._make_engine(tmp_path) - client = PolymarketDataClient() + db = self._make_db(tmp_path) target = Target(username="testuser", address="0xabc", sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) - result = replay(target, engine, client, original_trades=[]) + result = replay(target, engine, db, original_trades=[]) assert len(result.replayed_trades) == 0 assert result.comparison.original_stats["total_trades"] == 0 engine.close() - client.close() + db.close() - def test_replay_skips_zero_price(self, tmp_path, httpx_mock): + def test_replay_skips_zero_price(self, tmp_path): engine = self._make_engine(tmp_path) - client = PolymarketDataClient() + db = self._make_db(tmp_path) target = Target(username="testuser", address="0xabc", sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) - original_trades = [{"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", - "title": "Market?", "outcome": "Yes", "side": "BUY", "price": 0.0, - "size": 100.0, "usdcSize": 50.0, "eventSlug": ""}] - result = replay(target, engine, client, original_trades=original_trades) - assert len(result.skipped) == 1 + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", "price": 0.0, + "size": 100.0, "usdcSize": 50.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "SELL", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "eventSlug": "", "type": "TRADE"}, + ] + result = replay(target, engine, db, original_trades=original_trades) + assert len(result.skipped) == 2 assert "price is 0" in result.skipped[0]["reason"].lower() engine.close() - client.close() + db.close() - def test_replay_skips_sell_no_position(self, tmp_path, httpx_mock): + def test_replay_skips_unknown_side(self, tmp_path): engine = self._make_engine(tmp_path) - client = PolymarketDataClient() + db = self._make_db(tmp_path) target = Target(username="testuser", address="0xabc", sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) - original_trades = [{"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", - "title": "Market?", "outcome": "Yes", "side": "SELL", "price": 0.7, - "size": 100.0, "usdcSize": 70.0, "eventSlug": ""}] - result = replay(target, engine, client, original_trades=original_trades) - assert len(result.skipped) == 1 - assert "no position" in result.skipped[0]["reason"].lower() - engine.close() - client.close() - - def test_replay_skips_unknown_side(self, tmp_path, httpx_mock): - engine = self._make_engine(tmp_path) - client = PolymarketDataClient() - target = Target(username="testuser", address="0xabc", - sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) - original_trades = [{"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", - "title": "Market?", "outcome": "Yes", "side": "UNKNOWN", "price": 0.5, - "size": 100.0, "usdcSize": 50.0, "eventSlug": ""}] - result = replay(target, engine, client, original_trades=original_trades) - assert len(result.skipped) == 1 - assert "unknown side" in result.skipped[0]["reason"].lower() + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "UNKNOWN", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 3000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "SELL", "price": 0.6, + "size": 100.0, "usdcSize": 60.0, "eventSlug": "", "type": "TRADE"}, + ] + result = replay(target, engine, db, original_trades=original_trades) + assert any("unknown side" in s["reason"].lower() for s in result.skipped) engine.close() - client.close() + db.close() - def test_replay_buy_into_existing_position(self, tmp_path, httpx_mock): + def test_replay_buy_into_existing_position(self, tmp_path): """Buying into a market where we already have a position merges correctly.""" engine = self._make_engine(tmp_path) - client = PolymarketDataClient() + db = self._make_db(tmp_path) target = Target(username="testuser", address="0xabc", sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) original_trades = [ {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", - "outcome": "Yes", "side": "BUY", "price": 0.50, "size": 100.0, "usdcSize": 50.0, "eventSlug": ""}, + "outcome": "Yes", "side": "BUY", "price": 0.50, "size": 100.0, "usdcSize": 50.0, "eventSlug": "", "type": "TRADE"}, {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", - "outcome": "Yes", "side": "BUY", "price": 0.60, "size": 50.0, "usdcSize": 30.0, "eventSlug": ""}, + "outcome": "Yes", "side": "BUY", "price": 0.60, "size": 50.0, "usdcSize": 30.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 3000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", + "outcome": "Yes", "side": "SELL", "price": 0.70, "size": 150.0, "usdcSize": 105.0, "eventSlug": "", "type": "TRADE"}, ] - result = replay(target, engine, client, original_trades=original_trades) - assert len(result.replayed_trades) == 2 - pos = engine.db.get_position("0xcond1", "yes") - assert pos is not None - assert pos.shares == pytest.approx(100.0 + 50.0) - assert pos.total_cost == pytest.approx(50.0 + 30.0) + result = replay(target, engine, db, original_trades=original_trades) + assert len(result.replayed_trades) == 3 engine.close() - client.close() + db.close() - def test_replay_proportional_sell(self, tmp_path, httpx_mock): + def test_replay_proportional_sell(self, tmp_path): """Proportional sizing scales sell shares.""" engine = self._make_engine(tmp_path) - client = PolymarketDataClient() + db = self._make_db(tmp_path) target = Target(username="testuser", address="0xabc", sizing=SizingStrategy(mode="proportional", scale=0.5), delay=timedelta(seconds=0)) original_trades = [ {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", - "outcome": "Yes", "side": "BUY", "price": 0.50, "size": 200.0, "usdcSize": 100.0, "eventSlug": ""}, + "outcome": "Yes", "side": "BUY", "price": 0.50, "size": 200.0, "usdcSize": 100.0, "eventSlug": "", "type": "TRADE"}, {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", - "outcome": "Yes", "side": "SELL", "price": 0.70, "size": 200.0, "usdcSize": 140.0, "eventSlug": ""}, + "outcome": "Yes", "side": "SELL", "price": 0.70, "size": 200.0, "usdcSize": 140.0, "eventSlug": "", "type": "TRADE"}, ] - result = replay(target, engine, client, original_trades=original_trades) + result = replay(target, engine, db, original_trades=original_trades) assert len(result.replayed_trades) == 2 sell_trade = result.replayed_trades[1] assert sell_trade.shares == pytest.approx(100.0) engine.close() - client.close() + db.close() - def test_replay_fixed_sell_uses_all_shares(self, tmp_path, httpx_mock): + def test_replay_fixed_sell_uses_all_shares(self, tmp_path): """Fixed sizing sells all shares on sell.""" engine = self._make_engine(tmp_path) - client = PolymarketDataClient() + db = self._make_db(tmp_path) target = Target(username="testuser", address="0xabc", sizing=SizingStrategy(mode="fixed", fixed_amount=25.0), delay=timedelta(seconds=0)) original_trades = [ {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", - "outcome": "Yes", "side": "BUY", "price": 0.50, "size": 200.0, "usdcSize": 100.0, "eventSlug": ""}, + "outcome": "Yes", "side": "BUY", "price": 0.50, "size": 200.0, "usdcSize": 100.0, "eventSlug": "", "type": "TRADE"}, {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", - "outcome": "Yes", "side": "SELL", "price": 0.70, "size": 200.0, "usdcSize": 140.0, "eventSlug": ""}, + "outcome": "Yes", "side": "SELL", "price": 0.70, "size": 200.0, "usdcSize": 140.0, "eventSlug": "", "type": "TRADE"}, ] - result = replay(target, engine, client, original_trades=original_trades) + result = replay(target, engine, db, original_trades=original_trades) assert len(result.replayed_trades) == 2 sell_trade = result.replayed_trades[1] assert sell_trade.shares == pytest.approx(50.0) engine.close() - client.close() + db.close() + + def test_replay_reads_from_db_cache(self, tmp_path): + """When original_trades is None, replay reads from DB cache.""" + engine = self._make_engine(tmp_path) + db = self._make_db(tmp_path) + # Store raw trades in DB + db.upsert_raw_trades("0xabc", [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", "title": "Market?", + "eventSlug": "", "outcome": "Yes", "side": "BUY", "price": 0.50, + "size": 100.0, "usdcSize": 50.0, "transactionHash": "0xtx1", "type": "TRADE"}, + ]) + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + # No original_trades passed — should read from DB + result = replay(target, engine, db) + assert len(result.replayed_trades) == 1 + engine.close() + db.close() + + def test_replay_redeem_without_position(self, tmp_path): + engine = self._make_engine(tmp_path) + db = self._make_db(tmp_path) + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "", "side": "", "price": 0, + "size": 100.0, "usdcSize": 100.0, "eventSlug": "", "type": "REDEEM"}, + ] + result = replay(target, engine, db, original_trades=original_trades) + assert len(result.replayed_trades) == 0 + assert len(result.skipped) == 1 + assert "no position to redeem" in result.skipped[0]["reason"].lower() + engine.close() + db.close() + + def test_replay_losing_market(self, tmp_path): + """BUY but no SELL or REDEEM — shares stuck, loss reflected.""" + engine = self._make_engine(tmp_path) + db = self._make_db(tmp_path) + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", "price": 0.50, + "size": 100.0, "usdcSize": 50.0, "eventSlug": "", "type": "TRADE"}, + ] + result = replay(target, engine, db, original_trades=original_trades) + assert len(result.replayed_trades) == 1 + # Cash went down by $50, no return + assert result.comparison.replay_stats["pnl"] < 0 + engine.close() + db.close() + + def test_replay_losing_redeem(self, tmp_path): + """BUY + losing REDEEM (usdcSize=0) — shares zeroed out, no cash back.""" + engine = self._make_engine(tmp_path) + db = self._make_db(tmp_path) + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", "price": 0.90, + "size": 22.22, "usdcSize": 20.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "", "side": "", "price": 0, + "size": 0, "usdcSize": 0.0, "eventSlug": "", "type": "REDEEM"}, + ] + result = replay(target, engine, db, original_trades=original_trades) + assert len(result.replayed_trades) == 2 + # REDEEM with usdcSize=0 means lost — no cash back + redeem_trade = result.replayed_trades[1] + assert redeem_trade.amount_usd == pytest.approx(0.0) + # P&L should reflect the loss (spent $20, got $0 back) + assert result.comparison.replay_stats["pnl"] == pytest.approx(-20.0) + engine.close() + db.close() + + def test_replay_winning_vs_losing_redeem(self, tmp_path): + """Winning REDEEM (usdcSize>0) gives cash, losing (usdcSize=0) gives nothing.""" + engine = self._make_engine(tmp_path) + db = self._make_db(tmp_path) + target = Target(username="testuser", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + original_trades = [ + # Won market: BUY + winning REDEEM + {"timestamp": 1000, "conditionId": "0xwin", "slug": "win-market", + "title": "Win?", "outcome": "Yes", "side": "BUY", "price": 0.50, + "size": 100.0, "usdcSize": 50.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xwin", "slug": "win-market", + "title": "Win?", "outcome": "", "side": "", "price": 0, + "size": 100.0, "usdcSize": 100.0, "eventSlug": "", "type": "REDEEM"}, + # Lost market: BUY + losing REDEEM + {"timestamp": 3000, "conditionId": "0xlose", "slug": "lose-market", + "title": "Lose?", "outcome": "Yes", "side": "BUY", "price": 0.80, + "size": 50.0, "usdcSize": 40.0, "eventSlug": "", "type": "TRADE"}, + {"timestamp": 4000, "conditionId": "0xlose", "slug": "lose-market", + "title": "Lose?", "outcome": "", "side": "", "price": 0, + "size": 0, "usdcSize": 0.0, "eventSlug": "", "type": "REDEEM"}, + ] + result = replay(target, engine, db, original_trades=original_trades) + assert len(result.replayed_trades) == 4 + # Net: won $50 ($100 - $50), lost $40 → P&L = +$10 + assert result.comparison.replay_stats["pnl"] == pytest.approx(10.0) + engine.close() + db.close() + + def test_limit_1_aligns_with_last_trade(self, tmp_path): + """limit=1 selects the most recent market — replay should match user's last activity.""" + engine = self._make_engine(tmp_path) + db = self._make_db(tmp_path) + target = Target(username="dannyhqc", address="0xabc", + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + # Simulate multiple markets: old resolved market + recent active BUY + original_trades = [ + # Old market (cond1): BUY+SELL+REDEEM, fully resolved + {"timestamp": 1000, "conditionId": "0xcond_old", "slug": "old-market", + "title": "Old Market?", "outcome": "Yes", "side": "BUY", "price": 0.40, + "size": 50.0, "usdcSize": 20.0, "eventSlug": "old-event", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond_old", "slug": "old-market", + "title": "Old Market?", "outcome": "Yes", "side": "SELL", "price": 0.60, + "size": 50.0, "usdcSize": 30.0, "eventSlug": "old-event", "type": "TRADE"}, + # Recent market (cond_hok): BUY only — like dannyhqc's last trade + # BUY "Xianyou Gaming" at 0.75, 1.3 shares, $1.00 + {"timestamp": 5000, "conditionId": "0xcond_hok", + "slug": "honor-of-kings-xianyou-vs-sygaming-game4", + "title": "Honor of Kings: Xianyou Gaming vs SYGaming - Game 4 Winner", + "outcome": "Xianyou Gaming", "side": "BUY", "price": 0.75, + "size": 1.3, "usdcSize": 1.0, "eventSlug": "hok-event", "type": "TRADE"}, + ] + result = replay(target, engine, db, original_trades=original_trades, limit=1) + # limit=1 should select only the most recent market (cond_hok) + assert len(result.replayed_trades) == 1 + last_trade = result.replayed_trades[0] + assert last_trade.market_slug == "honor-of-kings-xianyou-vs-sygaming-game4" + assert last_trade.outcome == "xianyou gaming" + assert last_trade.side == "buy" + assert last_trade.avg_price == pytest.approx(0.75) + assert last_trade.amount_usd == pytest.approx(1.0) + # Per-trade comparison should also match + assert len(result.comparison.per_trade) == 1 + assert result.comparison.per_trade[0].side == "BUY" + assert result.comparison.per_trade[0].original_usd == pytest.approx(1.0) + # Old market should NOT appear + assert all(t.market_condition_id != "0xcond_old" for t in result.replayed_trades) + engine.close() + db.close() def test_unknown_sizing_mode_fallback(self): """Unknown sizing mode returns original amount.""" diff --git a/tests/test_data_api.py b/tests/test_data_api.py index b342fce..6a5dade 100644 --- a/tests/test_data_api.py +++ b/tests/test_data_api.py @@ -141,6 +141,63 @@ def test_pagination_stops_when_last_ts_none(self, httpx_mock): client.close() +class TestGetClosedPositions: + def test_single_page(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/v1/closed-positions?user=0xabc&limit=50&offset=0&sortBy=TIMESTAMP&sortDirection=DESC", + json=[{"conditionId": "0xcond1", "realizedPnl": 10.0}], + ) + client = PolymarketDataClient(page_delay=0) + positions = client.get_closed_positions("0xabc") + assert len(positions) == 1 + client.close() + + def test_multi_page(self, httpx_mock): + page1 = [{"conditionId": f"0xcond{i}", "realizedPnl": 1.0} for i in range(50)] + page2 = [{"conditionId": "0xlast", "realizedPnl": 5.0}] + httpx_mock.add_response( + url="https://data-api.polymarket.com/v1/closed-positions?user=0xabc&limit=50&offset=0&sortBy=TIMESTAMP&sortDirection=DESC", + json=page1, + ) + httpx_mock.add_response( + url="https://data-api.polymarket.com/v1/closed-positions?user=0xabc&limit=50&offset=50&sortBy=TIMESTAMP&sortDirection=DESC", + json=page2, + ) + client = PolymarketDataClient(page_delay=0) + positions = client.get_closed_positions("0xabc") + assert len(positions) == 51 + client.close() + + def test_max_positions_cap(self, httpx_mock): + page = [{"conditionId": f"0xcond{i}"} for i in range(50)] + httpx_mock.add_response(json=page) + client = PolymarketDataClient(page_delay=0) + positions = client.get_closed_positions("0xabc", max_positions=20) + assert len(positions) == 20 + client.close() + + def test_empty_first_page(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/v1/closed-positions?user=0xabc&limit=50&offset=0&sortBy=TIMESTAMP&sortDirection=DESC", + json=[], + ) + client = PolymarketDataClient(page_delay=0) + positions = client.get_closed_positions("0xabc") + assert positions == [] + client.close() + + def test_api_error(self, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/v1/closed-positions?user=0xabc&limit=50&offset=0&sortBy=TIMESTAMP&sortDirection=DESC", + status_code=500, + ) + client = PolymarketDataClient(page_delay=0) + with pytest.raises(ApiError) as exc_info: + client.get_closed_positions("0xabc") + assert exc_info.value.status_code == 500 + client.close() + + class TestGetPositions: def test_get_positions(self, httpx_mock): httpx_mock.add_response( @@ -247,3 +304,23 @@ def test_resolve_missing_address_key_raises_error(self, httpx_mock): with pytest.raises(UsernameResolutionError): client.resolve_username("testuser") client.close() + + def test_resolve_proxy_address_fallback(self, httpx_mock): + httpx_mock.add_response( + url="https://polymarket.com/@proxyuser", + text='', + ) + client = PolymarketDataClient() + address = client.resolve_username("proxyuser") + assert address == "0xproxy123" + client.close() + + def test_resolve_primary_address_fallback(self, httpx_mock): + httpx_mock.add_response( + url="https://polymarket.com/@primaryuser", + text='', + ) + client = PolymarketDataClient() + address = client.resolve_username("primaryuser") + assert address == "0xprimary456" + client.close() diff --git a/tests/test_db.py b/tests/test_db.py index e00c6ff..f6d9a08 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -817,3 +817,86 @@ def test_reset_drops_copytrack_tables(self, tmp_data_dir): ).fetchone() assert row["cnt"] == 0 db.close() + + +class TestCopytrackRawData: + def test_insert_and_get_raw_trades(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + trades = [ + {"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", "title": "M1?", + "eventSlug": "e1", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "transactionHash": "0xtx1"}, + ] + db.upsert_raw_trades("0xabc", trades) + result = db.get_raw_trades("0xabc") + assert len(result) == 1 + assert result[0]["condition_id"] == "0xc1" + db.close() + + def test_raw_trades_dedup(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + trade = {"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", "title": "M1?", + "eventSlug": "e1", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "transactionHash": "0xtx1"} + db.upsert_raw_trades("0xabc", [trade]) + db.upsert_raw_trades("0xabc", [trade]) + assert len(db.get_raw_trades("0xabc")) == 1 + db.close() + + def test_raw_trades_limit(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + trades = [{"timestamp": i, "conditionId": f"0xc{i}", "slug": "m", "title": "M?", + "eventSlug": "", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 10.0, "usdcSize": 5.0, "transactionHash": f"0xtx{i}"} + for i in range(20)] + db.upsert_raw_trades("0xabc", trades) + result = db.get_raw_trades("0xabc", limit=5) + assert len(result) == 5 + assert result[0]["timestamp"] == 19 + db.close() + + def test_insert_redeem_type(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + redeems = [{"timestamp": 5000, "conditionId": "0xc1", "slug": "m1", + "title": "M?", "eventSlug": "", "outcome": "", "side": "", + "price": 0, "size": 100.0, "usdcSize": 100.0, + "transactionHash": "0xtx5", "type": "REDEEM"}] + inserted = db.upsert_raw_trades("0xabc", redeems, type="REDEEM") + assert inserted == 1 + rows = db.get_raw_trades("0xabc") + assert len(rows) == 1 + assert rows[0]["type"] == "REDEEM" + db.close() + + def test_dedup_different_types(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + trade = [{"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", + "title": "M?", "eventSlug": "", "outcome": "Yes", "side": "BUY", + "price": 0.5, "size": 100, "usdcSize": 50, + "transactionHash": "0xtx1"}] + redeem = [{"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", + "title": "M?", "eventSlug": "", "outcome": "", "side": "", + "price": 0, "size": 100, "usdcSize": 100, + "transactionHash": "0xtx1", "type": "REDEEM"}] + db.upsert_raw_trades("0xabc", trade) + db.upsert_raw_trades("0xabc", redeem, type="REDEEM") + rows = db.get_raw_trades("0xabc") + assert len(rows) == 2 + db.close() + + def test_delete_raw_data(self, tmp_data_dir): + db = Database(tmp_data_dir) + db.init_schema() + db.upsert_raw_trades("0xabc", [ + {"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", "title": "M1?", + "eventSlug": "", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 10.0, "usdcSize": 5.0, "transactionHash": "0xtx1"}, + ]) + db.delete_raw_data("0xabc") + assert db.get_raw_trades("0xabc") == [] + db.close() diff --git a/tests/test_e2e_live.py b/tests/test_e2e_live.py index 3702c11..d263f40 100644 --- a/tests/test_e2e_live.py +++ b/tests/test_e2e_live.py @@ -774,3 +774,72 @@ def test_get_profile_real_user(self): assert "name" in profile or "pseudonym" in profile finally: client.close() + + +class TestCopytrackLive: + """Live tests for copytrack fetch + replay against real Polymarket data.""" + + @pytest.mark.live + def test_replay_limit1_matches_last_activity(self, tmp_path_factory): + """Fetch dannyhqc's real data, replay limit=1, verify it matches their latest market.""" + from datetime import timedelta + + from pm_trader.copytrack import fetch, replay + from pm_trader.data_api import PolymarketDataClient + from pm_trader.db import Database + from pm_trader.engine import Engine + from pm_trader.models import SizingStrategy, Target + + client = PolymarketDataClient() + try: + address = client.resolve_username("dannyhqc") + except Exception: + pytest.skip("Could not resolve @dannyhqc — user may have changed name") + + data_dir = tmp_path_factory.mktemp("copytrack-live") + db = Database(data_dir) + db.init_schema() + + try: + result = fetch(address, db, client, max_trades=10_000) + finally: + client.close() + + total = result["trades_fetched"] + result["redeems_fetched"] + if total == 0: + db.close() + pytest.skip("No activity data for @dannyhqc") + + # Replay limit=1: most recent market + engine = Engine(data_dir) + engine.db.init_account(10_000.0) + target = Target(username="dannyhqc", address=address, + sizing=SizingStrategy(mode="equal"), delay=timedelta(seconds=0)) + replay_result = replay(target, engine, db, limit=1) + + # Get the latest activity directly from API to compare + client2 = PolymarketDataClient() + try: + latest = client2.get_activity(address, limit=1, type="TRADE") + finally: + client2.close() + + if not latest: + engine.close() + db.close() + pytest.skip("No TRADE activity for @dannyhqc") + + last_cid = latest[0]["conditionId"] + + # The replayed trades should all be from the same market as the latest activity + assert len(replay_result.replayed_trades) >= 1, "limit=1 should replay at least 1 trade" + replayed_cids = {t.market_condition_id for t in replay_result.replayed_trades} + assert last_cid in replayed_cids, ( + f"limit=1 should include the latest market {last_cid}, " + f"but got {replayed_cids}" + ) + # All replayed trades should be from exactly one market + assert len(replayed_cids) == 1, f"limit=1 should replay exactly 1 market, got {replayed_cids}" + + engine.close() + db.close() diff --git a/tests/test_web/test_copytrack.py b/tests/test_web/test_copytrack.py index ce77242..3a6b6d0 100644 --- a/tests/test_web/test_copytrack.py +++ b/tests/test_web/test_copytrack.py @@ -27,6 +27,22 @@ def initialized_client(web_client, tmp_path, monkeypatch): return web_client +def _store_test_data(tmp_path, trades=None, redeems=None): + """Store raw data in DB to simulate a prior fetch.""" + from pm_trader.db import Database + db = Database(tmp_path / "default") + db.init_schema() + if trades is None: + trades = [{"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", + "price": 0.5, "size": 100.0, "usdcSize": 50.0, "eventSlug": "", + "transactionHash": "0xtx1", "type": "TRADE"}] + db.upsert_raw_trades("0xabc", trades, type="TRADE") + if redeems is not None: + db.upsert_raw_trades("0xabc", redeems, type="REDEEM") + db.close() + + class TestCopytrackTargets: def test_list_empty(self, initialized_client): resp = initialized_client.get("/api/copytrack/targets") @@ -66,31 +82,55 @@ def test_delete_target(self, initialized_client): assert resp2.json()["data"] == [] +class TestCopytrackFetch: + def test_fetch_endpoint(self, initialized_client, httpx_mock): + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=TRADE&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=[{"timestamp": 1000, "conditionId": "0xc1", "slug": "m1", "title": "M?", + "eventSlug": "", "outcome": "Yes", "side": "BUY", "price": 0.5, + "size": 100.0, "usdcSize": 50.0, "transactionHash": "0xtx1", + "type": "TRADE"}], + ) + httpx_mock.add_response( + url="https://data-api.polymarket.com/activity?user=0xabc&type=REDEEM&limit=500&sortBy=TIMESTAMP&sortDirection=DESC", + json=[], + ) + resp = initialized_client.post("/api/copytrack/fetch", json={"address": "0xabc"}) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["data"]["trades_fetched"] == 1 + assert data["data"]["redeems_fetched"] == 0 + + class TestCopytrackReplay: - def test_trigger_replay(self, initialized_client, httpx_mock): + def test_trigger_replay(self, initialized_client, tmp_path): initialized_client.post("/api/copytrack/targets", json={ "address": "0xabc", "username": "testuser", }) - httpx_mock.add_response( - json=[ - {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", - "title": "Market?", "outcome": "Yes", "side": "BUY", - "price": 0.5, "size": 100, "usdcSize": 50, "eventSlug": ""}, - ], - ) + _store_test_data(tmp_path, trades=[ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", + "price": 0.5, "size": 100, "usdcSize": 50, "eventSlug": "", + "transactionHash": "0xtx1", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "SELL", + "price": 0.6, "size": 100, "usdcSize": 60, "eventSlug": "", + "transactionHash": "0xtx2", "type": "TRADE"}, + ]) resp = initialized_client.post("/api/copytrack/replay", json={ "address": "0xabc", }) assert resp.status_code == 200 data = resp.json() assert data["ok"] is True - assert data["data"]["replayed"] == 1 + assert data["data"]["replayed"] == 2 - def test_get_replay_result(self, initialized_client, httpx_mock): + def test_get_replay_result(self, initialized_client, tmp_path): initialized_client.post("/api/copytrack/targets", json={ "address": "0xabc", "username": "testuser", }) - httpx_mock.add_response(json=[]) + _store_test_data(tmp_path) initialized_client.post("/api/copytrack/replay", json={ "address": "0xabc", }) @@ -107,17 +147,20 @@ def test_get_replay_not_found(self, initialized_client): assert data["ok"] is False assert data["code"] == "NOT_FOUND" - def test_chart_endpoint(self, initialized_client, httpx_mock): + def test_chart_endpoint(self, initialized_client, tmp_path): initialized_client.post("/api/copytrack/targets", json={ "address": "0xabc", "username": "testuser", }) - httpx_mock.add_response( - json=[ - {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", - "title": "Market?", "outcome": "Yes", "side": "BUY", - "price": 0.5, "size": 100, "usdcSize": 50, "eventSlug": ""}, - ], - ) + _store_test_data(tmp_path, trades=[ + {"timestamp": 1000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "BUY", + "price": 0.5, "size": 100, "usdcSize": 50, "eventSlug": "", + "transactionHash": "0xtx1", "type": "TRADE"}, + {"timestamp": 2000, "conditionId": "0xcond1", "slug": "m1", + "title": "Market?", "outcome": "Yes", "side": "SELL", + "price": 0.6, "size": 100, "usdcSize": 60, "eventSlug": "", + "transactionHash": "0xtx2", "type": "TRADE"}, + ]) initialized_client.post("/api/copytrack/replay", json={ "address": "0xabc", }) @@ -125,7 +168,7 @@ def test_chart_endpoint(self, initialized_client, httpx_mock): assert resp.status_code == 200 data = resp.json() assert data["ok"] is True - assert len(data["data"]) == 1 + assert len(data["data"]) == 2 assert "original_pnl" in data["data"][0] assert "replay_pnl" in data["data"][0] @@ -135,3 +178,60 @@ def test_chart_not_found(self, initialized_client): data = resp.json() assert data["ok"] is False assert data["code"] == "NOT_FOUND" + + +class TestCopytrackReplays: + def test_list_replays_empty(self, initialized_client): + resp = initialized_client.get("/api/copytrack/replays") + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["data"] == [] + + def test_list_replays_after_replay(self, initialized_client, tmp_path): + initialized_client.post("/api/copytrack/targets", json={ + "address": "0xabc", "username": "testuser", + }) + _store_test_data(tmp_path) + initialized_client.post("/api/copytrack/replay", json={ + "address": "0xabc", + }) + resp = initialized_client.get("/api/copytrack/replays") + data = resp.json() + assert data["ok"] is True + assert len(data["data"]) == 1 + assert data["data"][0]["address"] == "0xabc" + assert data["data"][0]["username"] == "testuser" + + +class TestCopytrackResolve: + def test_resolve_address(self, initialized_client): + resp = initialized_client.post("/api/copytrack/resolve", json={ + "identifier": "0xd8f8c13644ea84d62e1ec88c5d1215e436eb0f11", + }) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["data"]["address"] == "0xd8f8c13644ea84d62e1ec88c5d1215e436eb0f11" + + def test_resolve_username(self, initialized_client, httpx_mock): + httpx_mock.add_response( + url="https://polymarket.com/@testuser", + text='', + ) + resp = initialized_client.post("/api/copytrack/resolve", json={ + "identifier": "@testuser", + }) + data = resp.json() + assert data["ok"] is True + assert data["data"]["address"] == "0xabc123" + assert data["data"]["username"] == "testuser" + + def test_resolve_bad_username(self, initialized_client, httpx_mock): + httpx_mock.add_response(url="https://polymarket.com/@bad", status_code=404) + resp = initialized_client.post("/api/copytrack/resolve", json={ + "identifier": "@bad", + }) + data = resp.json() + assert data["ok"] is False + assert data["code"] == "RESOLVE_FAILED"