diff --git a/.gitignore b/.gitignore index b393667..54072a3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ __pycache__/ *.pyc .polymarket-paper/ +.polymarket-live/ +.env +.env.* +!.env.example +*.key diff --git a/.well-known/skills/index.json b/.well-known/skills/index.json new file mode 100644 index 0000000..243ec70 --- /dev/null +++ b/.well-known/skills/index.json @@ -0,0 +1,77 @@ +{ + "skills": [ + { + "name": "polymarket-scanner", + "description": "Use this skill whenever the user wants to browse, search, scan, or explore Polymarket prediction markets. Includes finding markets by topic, checking prices and order books, viewing volumes, and fetching live data.", + "files": [ + "SKILL.md", + "references/api-guide.md", + "references/market-types.md", + "scripts/get_orderbook.py", + "scripts/get_prices.py", + "scripts/scan_markets.py" + ] + }, + { + "name": "polymarket-analyzer", + "description": "Use this skill whenever the user wants to find trading opportunities, detect arbitrage, analyze a market, perform edge detection, find mispricing, do probability analysis, evaluate orderbook depth, or find momentum signals.", + "files": [ + "SKILL.md", + "references/fee-model.md", + "references/viable-strategies.md", + "scripts/analyze_orderbook.py", + "scripts/correlation_tracker.py", + "scripts/find_edges.py", + "scripts/momentum_scanner.py" + ] + }, + { + "name": "polymarket-monitor", + "description": "Use this skill whenever the user wants to monitor, watch, or track Polymarket prediction market prices over time. Includes setting up price alerts, watching for significant price movements, tracking spread changes, and monitoring volume spikes.", + "files": [ + "SKILL.md", + "references/monitoring-guide.md", + "scripts/monitor_prices.py", + "scripts/watch_market.py" + ] + }, + { + "name": "polymarket-paper-trader", + "description": "Use this skill whenever the user wants to paper trade, simulate trades, practice trading, backtest strategies, manage a virtual portfolio, or track simulated P&L on Polymarket prediction markets. This is the core trading engine with zero financial risk.", + "files": [ + "SKILL.md", + "references/paper-trading-guide.md", + "references/risk-rules.md", + "scripts/execute_paper.py", + "scripts/health_check.py", + "scripts/paper_engine.py", + "scripts/portfolio_report.py" + ] + }, + { + "name": "polymarket-strategy-advisor", + "description": "Use this skill whenever the user wants trading strategy advice, trade recommendations, portfolio guidance, or prediction market analysis that leads to actionable trades. Covers position sizing, Kelly criterion, risk management, and daily review.", + "files": [ + "SKILL.md", + "references/decision-framework.md", + "references/viable-strategies.md", + "scripts/advisor.py", + "scripts/backtest.py", + "scripts/daily_review.py" + ] + }, + { + "name": "polymarket-live-executor", + "description": "Use this skill when the user wants to execute a real trade on Polymarket, place a live order, go live, buy or sell on Polymarket, check real positions, or manage a live trading wallet. CRITICAL: Executes REAL trades with REAL money requiring explicit human confirmation.", + "files": [ + "SKILL.md", + ".env.example", + "references/live-trading-checklist.md", + "references/security.md", + "scripts/check_positions.py", + "scripts/execute_live.py", + "scripts/setup_wallet.py" + ] + } + ] +} diff --git a/polymarket-analyzer/SKILL.md b/polymarket-analyzer/SKILL.md index 58ef754..25c65b2 100644 --- a/polymarket-analyzer/SKILL.md +++ b/polymarket-analyzer/SKILL.md @@ -59,6 +59,21 @@ python scripts/momentum_scanner.py python scripts/momentum_scanner.py --min-volume 10000 --limit 300 ``` +### 4. Correlation Tracker (`scripts/correlation_tracker.py`) + +Detect hidden correlated exposure in your portfolio: + +- Groups positions by topic (crypto, politics, sports, geopolitics, etc.) +- Detects shared qualifiers ("insider trading", "FIFA World Cup", etc.) +- Warns when correlated clusters exceed concentration limits +- Outputs diversification score (0-100) + +```bash +python scripts/correlation_tracker.py +python scripts/correlation_tracker.py --json +python scripts/correlation_tracker.py --threshold 0.10 +``` + ## Workflow 1. Run `find_edges.py` to scan for arbitrage across all active markets diff --git a/polymarket-analyzer/scripts/correlation_tracker.py b/polymarket-analyzer/scripts/correlation_tracker.py new file mode 100644 index 0000000..f11ce5b --- /dev/null +++ b/polymarket-analyzer/scripts/correlation_tracker.py @@ -0,0 +1,1010 @@ +#!/usr/bin/env python3 +"""Detect correlated exposure in the paper trading portfolio. + +Positions that look diversified by token_id can actually be concentrated +on a single topic. For example, three separate "insider trading" markets +on different crypto exchanges are effectively one bet on whether exchanges +will face insider-trading accusations. + +This script: + 1. Loads open positions from the paper-trading SQLite database. + 2. Clusters positions by topic using keyword extraction from + market_question (no ML, no external dependencies). + 3. Calculates combined cluster exposure as a fraction of portfolio. + 4. Generates INFO / WARN / ALERT messages based on thresholds. + 5. Computes a diversification score (0-100). + +Uses the same patterns as find_edges.py and momentum_scanner.py: + - argparse CLI, sqlite3 for DB, re for keyword extraction + - JSON output to stdout when --json, human-readable tables by default + - No external dependencies beyond the standard library +""" + +import argparse +import json +import math +import os +import re +import sqlite3 +import sys +from collections import defaultdict +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DB_DIR = Path.home() / ".polymarket-paper" +DB_PATH = DB_DIR / "portfolio.db" + +# Risk limit from CLAUDE.md -- max single-market exposure +MAX_SINGLE_MARKET_PCT = 0.20 + +# --------------------------------------------------------------------------- +# Topic categories and keyword rules +# --------------------------------------------------------------------------- + +# Broad category definitions: category name -> list of keyword patterns. +# Each pattern is matched case-insensitively against the full market question. +# Order matters: first match wins for category assignment. +CATEGORY_RULES: list[tuple[str, list[str]]] = [ + ("US Politics / Elections", [ + r"\bpresident\b", r"\belection\b", r"\bsenate\b", r"\bcongress\b", + r"\brepublican\b", r"\bdemocrat\b", r"\btrump\b", r"\bbiden\b", + r"\bgop\b", r"\bwhite\s+house\b", r"\bprimary\b", r"\bgovernor\b", + r"\bmidterm\b", r"\belectoral\b", r"\bcandidate\b", r"\bnomination\b", + r"\bvote\b", r"\bballot\b", r"\bimpeach\b", + ]), + ("Geopolitics / War", [ + r"\bwar\b", r"\bstrike\b", r"\binvasion\b", r"\bmilitary\b", + r"\bnato\b", r"\bsanction\b", r"\bnuclear\b", r"\bceasefire\b", + r"\brussia\b", r"\bukraine\b", r"\bchina\b", r"\btaiwan\b", + r"\biran\b", r"\bisrael\b", r"\bgaza\b", r"\bnorth\s+korea\b", + r"\bconflict\b", r"\bweapon\b", r"\bairstr\w*\b", r"\bbomb\b", + ]), + ("Crypto / Blockchain", [ + r"\bcrypto\b", r"\bbitcoin\b", r"\bbtc\b", r"\bethereum\b", + r"\beth\b", r"\bsolana\b", r"\bsol\b", r"\btoken\b", + r"\bblockchain\b", r"\bdefi\b", r"\bnft\b", r"\bstablecoin\b", + r"\bexchange\b", r"\bbinance\b", r"\bcoinbase\b", r"\bkraken\b", + r"\brobinhood\b", r"\baxiom\b", r"\bmexc\b", r"\bbybit\b", + r"\bftx\b", r"\bweb3\b", r"\bwallet\b", + ]), + ("Sports / NBA", [ + r"\bnba\b", r"\bbasketball\b", r"\blakers\b", r"\bceltics\b", + r"\bwarriors\b", r"\bmvp\b.*\b(?:season|award)\b", + r"\bplayoff\b", r"\bfinals\b.*\bnba\b", + ]), + ("Sports / Football", [ + r"\bnfl\b", r"\bsuper\s+bowl\b", r"\bfootball\b", + r"\btouchdown\b", r"\bquarterback\b", + ]), + ("Sports / Soccer", [ + r"\bfifa\b", r"\bworld\s+cup\b", r"\bsoccer\b", r"\bpremier\s+league\b", + r"\bchampions\s+league\b", r"\bla\s+liga\b", r"\bbundesliga\b", + ]), + ("Sports / Other", [ + r"\bmlb\b", r"\bnhl\b", r"\bmma\b", r"\bufc\b", r"\bboxing\b", + r"\btennis\b", r"\bgolf\b", r"\bolympic\b", r"\bf1\b", + r"\bformula\s+1\b", r"\brace\b.*\bgrand\s+prix\b", + ]), + ("Entertainment", [ + r"\boscar\b", r"\bacademy\s+award\b", r"\bgrammy\b", r"\bemmy\b", + r"\bbox\s+office\b", r"\bmovie\b", r"\bfilm\b", r"\bnetflix\b", + r"\bdisney\b", r"\bmusic\b", r"\balbum\b", r"\bconcert\b", + r"\bcelebrity\b", + ]), + ("Technology", [ + r"\bai\b", r"\bartificial\s+intelligence\b", r"\bopenai\b", + r"\bgoogle\b", r"\bapple\b", r"\bmicrosoft\b", r"\btesla\b", + r"\bspacex\b", r"\bmeta\b", r"\bamazon\b", r"\bnvidia\b", + r"\bchip\b", r"\bsemiconductor\b", r"\brobot\b", + ]), + ("Economy / Finance", [ + r"\bfed\b", r"\binterest\s+rate\b", r"\binflation\b", + r"\brecession\b", r"\bgdp\b", r"\bstock\s+market\b", + r"\bs&p\b", r"\bnasdaq\b", r"\bipo\b", r"\btariff\b", + r"\btrade\s+war\b", r"\bdebt\s+ceiling\b", + ]), + ("Weather / Climate", [ + r"\bhurricane\b", r"\btemperature\b", r"\bweather\b", + r"\bclimate\b", r"\bflood\b", r"\bwildfire\b", r"\bdrought\b", + r"\btornado\b", r"\bsnow\b", r"\bheat\s+wave\b", + ]), + ("Legal / Regulatory", [ + r"\bcourt\b", r"\blawsuit\b", r"\btrial\b", r"\bindict\b", + r"\bsec\b", r"\bregulat\w*\b", r"\bban\b", r"\blegislat\b", + r"\bbill\b.*\bpass\b", r"\bsupreme\s+court\b", r"\binsider\s+trading\b", + r"\bfraud\b", r"\bconvict\b", r"\bguilty\b", + ]), + ("Science / Health", [ + r"\bcovid\b", r"\bvaccine\b", r"\bpandemic\b", r"\bvirus\b", + r"\bfda\b", r"\bdrug\b", r"\bclinical\s+trial\b", r"\bspace\b", + r"\bmars\b", r"\bmoon\b", r"\bnasa\b", + ]), +] + +# "Shared qualifier" phrases that create tight correlation regardless of +# broad category. If two positions share one of these phrases, they belong +# to the same fine-grained cluster even if their categories differ. +QUALIFIER_PATTERNS: list[tuple[str, re.Pattern]] = [ + ("insider trading", re.compile(r"insider\s+trading", re.IGNORECASE)), + ("win the 2024", re.compile(r"win\s+(?:the\s+)?2024", re.IGNORECASE)), + ("win the 2025", re.compile(r"win\s+(?:the\s+)?2025", re.IGNORECASE)), + ("win the 2026", re.compile(r"win\s+(?:the\s+)?2026", re.IGNORECASE)), + ("win the 2027", re.compile(r"win\s+(?:the\s+)?2027", re.IGNORECASE)), + ("win the 2028", re.compile(r"win\s+(?:the\s+)?2028", re.IGNORECASE)), + ("FIFA World Cup", re.compile(r"fifa\s+world\s+cup", re.IGNORECASE)), + ("Super Bowl", re.compile(r"super\s+bowl", re.IGNORECASE)), + ("NBA Finals", re.compile(r"nba\s+finals", re.IGNORECASE)), + ("NBA MVP", re.compile(r"nba\s+mvp", re.IGNORECASE)), + ("Academy Award", re.compile(r"academy\s+award|oscar", re.IGNORECASE)), + ("interest rate cut", re.compile(r"interest\s+rate\s+cut", re.IGNORECASE)), + ("interest rate hike", re.compile(r"interest\s+rate\s+(?:hike|raise|increase)", re.IGNORECASE)), + ("government shutdown", re.compile(r"government\s+shutdown", re.IGNORECASE)), + ("debt ceiling", re.compile(r"debt\s+ceiling", re.IGNORECASE)), + ("TikTok ban", re.compile(r"tiktok\s+ban", re.IGNORECASE)), + ("recession", re.compile(r"\brecession\b", re.IGNORECASE)), + ("nuclear", re.compile(r"\bnuclear\b", re.IGNORECASE)), + ("ceasefire", re.compile(r"\bceasefire\b", re.IGNORECASE)), +] + +# Stop words removed before extracting significant keywords for overlap. +_STOP_WORDS = frozenset({ + "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", + "will", "would", "could", "should", "shall", "may", "might", "can", + "do", "does", "did", "has", "have", "had", "having", "in", "on", "at", + "to", "for", "of", "with", "by", "from", "as", "into", "about", + "between", "through", "during", "before", "after", "above", "below", + "and", "or", "but", "if", "then", "than", "that", "this", "these", + "those", "it", "its", "not", "no", "yes", "any", "all", "each", + "every", "both", "few", "more", "most", "other", "some", "such", + "only", "own", "so", "very", "just", "also", "how", "what", "which", + "who", "whom", "when", "where", "why", "there", "here", "up", "out", + "over", "under", "again", "further", "once", "market", "price", + "end", "date", "by", "before", "february", "march", "april", "may", + "june", "july", "august", "september", "october", "november", + "december", "january", "2024", "2025", "2026", "2027", "2028", + "2029", "2030", +}) + +# Minimum keyword length to consider significant +_MIN_KEYWORD_LEN = 3 + + +# --------------------------------------------------------------------------- +# Database access +# --------------------------------------------------------------------------- + +def _open_db(db_path: str) -> sqlite3.Connection: + """Open the paper-trading database read-only.""" + if not os.path.isfile(db_path): + print(f"ERROR: Database not found at {db_path}", file=sys.stderr) + print( + "Initialize a portfolio first:\n" + " python polymarket-paper-trader/scripts/paper_engine.py --action init", + file=sys.stderr, + ) + sys.exit(1) + + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + return conn + + +def load_portfolio(conn: sqlite3.Connection, portfolio_name: str) -> dict: + """Load the active portfolio and its open positions. + + Returns a dict with keys: portfolio (row dict), positions (list of row + dicts), cash_balance, total_value. + """ + pf = conn.execute( + "SELECT * FROM portfolios WHERE name = ? ORDER BY id DESC LIMIT 1", + (portfolio_name,), + ).fetchone() + + if not pf: + print( + f"ERROR: No portfolio named '{portfolio_name}' found.", + file=sys.stderr, + ) + sys.exit(1) + + pf = dict(pf) + pid = pf["id"] + + rows = conn.execute( + "SELECT * FROM positions WHERE portfolio_id = ? AND closed = 0", + (pid,), + ).fetchall() + + positions = [dict(r) for r in rows] + + # Compute total portfolio value + positions_value = sum( + p["shares"] * p["current_price"] for p in positions + ) + total_value = pf["cash_balance"] + positions_value + + return { + "portfolio": pf, + "positions": positions, + "cash_balance": pf["cash_balance"], + "positions_value": round(positions_value, 4), + "total_value": round(total_value, 4), + } + + +# --------------------------------------------------------------------------- +# Keyword extraction and categorization +# --------------------------------------------------------------------------- + +def _extract_keywords(question: str) -> set[str]: + """Extract significant lowercased keywords from a market question.""" + # Tokenize: split on non-alphanumeric, keep apostrophes inside words + tokens = re.findall(r"[a-zA-Z][a-zA-Z']*[a-zA-Z]|[a-zA-Z]", question) + keywords = set() + for tok in tokens: + low = tok.lower().strip("'") + if low not in _STOP_WORDS and len(low) >= _MIN_KEYWORD_LEN: + keywords.add(low) + return keywords + + +def _extract_entities(question: str) -> set[str]: + """Extract capitalized multi-word entities (proper nouns, company names). + + Looks for sequences of capitalized words (2+ words) or single + capitalized words that are likely names (not sentence-initial). + Returns lowercased entity strings for comparison. + """ + entities = set() + + # Multi-word capitalized sequences (e.g., "Insider Trading", "North Korea") + for match in re.finditer(r"(?= 3 + and clean[0].isupper() + and i > 0 # skip sentence-initial + and clean.lower() not in _STOP_WORDS + ): + entities.add(clean.lower()) + + return entities + + +def _detect_qualifiers(question: str) -> list[str]: + """Detect shared qualifier phrases in a market question.""" + found = [] + for label, pattern in QUALIFIER_PATTERNS: + if pattern.search(question): + found.append(label) + return found + + +def categorize_position(question: str) -> dict: + """Categorize a single position by its market question. + + Returns: { + "category": str, # broad category name + "qualifiers": list[str], # shared qualifier phrases detected + "keywords": list[str], # significant keywords + "entities": list[str], # extracted entity names + } + """ + if not question: + return { + "category": "Uncategorized", + "qualifiers": [], + "keywords": [], + "entities": [], + } + + # Broad category (first match wins) + category = "Uncategorized" + for cat_name, patterns in CATEGORY_RULES: + for pat in patterns: + if re.search(pat, question, re.IGNORECASE): + category = cat_name + break + if category != "Uncategorized": + break + + qualifiers = _detect_qualifiers(question) + keywords = sorted(_extract_keywords(question)) + entities = sorted(_extract_entities(question)) + + return { + "category": category, + "qualifiers": qualifiers, + "keywords": keywords, + "entities": entities, + } + + +# --------------------------------------------------------------------------- +# Correlation clustering +# --------------------------------------------------------------------------- + +def _keyword_overlap(kw_a: set[str], kw_b: set[str]) -> float: + """Jaccard similarity between two keyword sets.""" + if not kw_a or not kw_b: + return 0.0 + intersection = kw_a & kw_b + union = kw_a | kw_b + return len(intersection) / len(union) + + +def build_clusters( + positions: list[dict], + categorizations: list[dict], +) -> list[dict]: + """Build correlation clusters from categorized positions. + + Clustering rules (in priority order): + 1. Positions sharing ANY qualifier phrase -> same cluster. + 2. Positions in the same broad category AND sharing 2+ significant + keywords or 1+ entity -> same cluster. + 3. Positions sharing 40%+ keyword overlap (Jaccard) -> same cluster. + + Uses union-find to merge transitive connections. + + Returns a list of cluster dicts, each with: + - cluster_id: int + - label: str (human-readable cluster name) + - reason: str (why these are correlated) + - positions: list[dict] (position records with categorization) + - total_exposure: float + - exposure_pct: float (of total portfolio) + """ + n = len(positions) + if n == 0: + return [] + + # Union-find structure + parent = list(range(n)) + rank = [0] * n + + def find(x: int) -> int: + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def union(a: int, b: int) -> None: + ra, rb = find(a), find(b) + if ra == rb: + return + if rank[ra] < rank[rb]: + ra, rb = rb, ra + parent[rb] = ra + if rank[ra] == rank[rb]: + rank[ra] += 1 + + # Build keyword sets for each position + kw_sets = [set(c["keywords"]) for c in categorizations] + ent_sets = [set(c["entities"]) for c in categorizations] + + # Build qualifier sets + qual_sets = [set(c["qualifiers"]) for c in categorizations] + + # Merge reasons tracking: (i, j) -> reason string + merge_reasons: dict[tuple[int, int], str] = {} + + # Pass 1: qualifier overlap + for i in range(n): + for j in range(i + 1, n): + shared_quals = qual_sets[i] & qual_sets[j] + if shared_quals: + key = (min(i, j), max(i, j)) + merge_reasons[key] = ( + f"shared qualifier: {', '.join(sorted(shared_quals))}" + ) + union(i, j) + + # Pass 2: same category + keyword/entity overlap + for i in range(n): + for j in range(i + 1, n): + if find(i) == find(j): + continue # already merged + + if categorizations[i]["category"] == categorizations[j]["category"]: + cat = categorizations[i]["category"] + if cat == "Uncategorized": + continue + + shared_kw = kw_sets[i] & kw_sets[j] + shared_ent = ent_sets[i] & ent_sets[j] + + if len(shared_kw) >= 2 or len(shared_ent) >= 1: + key = (min(i, j), max(i, j)) + if shared_ent: + reason = ( + f"same category ({cat}), " + f"shared entities: {', '.join(sorted(shared_ent))}" + ) + else: + reason = ( + f"same category ({cat}), " + f"shared keywords: {', '.join(sorted(shared_kw))}" + ) + merge_reasons[key] = reason + union(i, j) + + # Pass 3: high keyword overlap regardless of category + for i in range(n): + for j in range(i + 1, n): + if find(i) == find(j): + continue # already merged + + overlap = _keyword_overlap(kw_sets[i], kw_sets[j]) + if overlap >= 0.40: + key = (min(i, j), max(i, j)) + merge_reasons[key] = ( + f"keyword overlap {overlap:.0%}" + ) + union(i, j) + + # Collect clusters + groups: dict[int, list[int]] = defaultdict(list) + for i in range(n): + groups[find(i)].append(i) + + clusters = [] + for cluster_id, (_, members) in enumerate(sorted(groups.items())): + # Compute cluster exposure + cluster_positions = [] + total_exposure = 0.0 + + for idx in members: + pos = positions[idx] + cat = categorizations[idx] + exposure = pos["shares"] * pos["current_price"] + total_exposure += exposure + cluster_positions.append({ + **pos, + "exposure": round(exposure, 4), + "category": cat["category"], + "qualifiers": cat["qualifiers"], + }) + + # Determine cluster label + if len(members) == 1: + label = categorizations[members[0]]["category"] + else: + # Use the most specific reason available + reasons = [] + for i in members: + for j in members: + if i < j: + key = (i, j) + if key in merge_reasons: + reasons.append(merge_reasons[key]) + + # Prefer qualifier-based labels + qual_reasons = [r for r in reasons if "qualifier" in r] + cat_reasons = [r for r in reasons if "category" in r] + + if qual_reasons: + label = qual_reasons[0].replace("shared qualifier: ", "").title() + elif cat_reasons: + label = categorizations[members[0]]["category"] + else: + label = categorizations[members[0]]["category"] + + reason_summary = "; ".join(sorted(set( + merge_reasons.get((min(i, j), max(i, j)), "") + for i in members for j in members if i < j + ))) or "single position" + + clusters.append({ + "cluster_id": cluster_id, + "label": label, + "reason": reason_summary, + "num_positions": len(members), + "positions": cluster_positions, + "total_exposure": round(total_exposure, 4), + }) + + # Sort clusters by exposure descending + clusters.sort(key=lambda c: c["total_exposure"], reverse=True) + return clusters + + +# --------------------------------------------------------------------------- +# Risk analysis +# --------------------------------------------------------------------------- + +def analyze_risk( + clusters: list[dict], + total_value: float, + warn_threshold: float, + alert_threshold: float, +) -> dict: + """Analyze correlation risk and generate warnings. + + Returns: { + "warnings": list[dict], # {level, cluster_label, exposure_pct, message} + "diversification_score": int, # 0-100 + "max_cluster_pct": float, + "num_clusters": int, + "num_multi_position_clusters": int, + } + """ + warnings = [] + + if total_value <= 0: + return { + "warnings": [{ + "level": "ALERT", + "cluster_label": "PORTFOLIO", + "exposure_pct": 0.0, + "message": "Portfolio value is zero or negative.", + }], + "diversification_score": 0, + "max_cluster_pct": 0.0, + "num_clusters": 0, + "num_multi_position_clusters": 0, + } + + # Add exposure_pct to each cluster + for cluster in clusters: + cluster["exposure_pct"] = round( + cluster["total_exposure"] / total_value * 100, 2 + ) + + max_cluster_pct = 0.0 + multi_pos_clusters = 0 + + for cluster in clusters: + pct = cluster["exposure_pct"] + frac = cluster["total_exposure"] / total_value + + if pct > max_cluster_pct: + max_cluster_pct = pct + + if cluster["num_positions"] > 1: + multi_pos_clusters += 1 + + # Only warn about clusters with multiple positions (single positions + # are checked by the per-market risk limit already) + if cluster["num_positions"] > 1: + if frac > alert_threshold: + warnings.append({ + "level": "ALERT", + "cluster_label": cluster["label"], + "exposure_pct": pct, + "message": ( + f"Correlated cluster '{cluster['label']}' has " + f"{cluster['num_positions']} positions totaling " + f"{pct:.1f}% of portfolio (>{alert_threshold*100:.0f}% limit). " + f"Reason: {cluster['reason']}" + ), + }) + elif frac > warn_threshold: + warnings.append({ + "level": "WARN", + "cluster_label": cluster["label"], + "exposure_pct": pct, + "message": ( + f"Correlated cluster '{cluster['label']}' has " + f"{cluster['num_positions']} positions totaling " + f"{pct:.1f}% of portfolio (>{warn_threshold*100:.0f}% threshold). " + f"Reason: {cluster['reason']}" + ), + }) + + # INFO for all clusters (including single-position) is handled in output + + # Diversification score: 0-100 + # + # Scoring method: + # - Start at 100 (perfectly diversified) + # - Penalize for concentration: subtract based on HHI + # (Herfindahl-Hirschman Index) of cluster exposures + # - Penalize for multi-position clusters (hidden correlation) + # + # HHI ranges from 1/N (perfectly equal) to 1.0 (single cluster). + # We normalize so that equal-weight positions across N clusters = 100, + # and single-cluster = 0. + + num_positions = sum(c["num_positions"] for c in clusters) + num_clusters = len(clusters) + + if num_positions <= 1: + # 0 or 1 position: diversification is not applicable + div_score = 100 if num_positions == 0 else 50 + else: + total_exposure = sum(c["total_exposure"] for c in clusters) + if total_exposure <= 0: + div_score = 100 + else: + # Calculate HHI over cluster weights + weights = [c["total_exposure"] / total_exposure for c in clusters] + hhi = sum(w * w for w in weights) + + # Perfect diversification: HHI = 1/N_clusters + # Full concentration: HHI = 1.0 + if num_clusters > 1: + min_hhi = 1.0 / num_clusters + # Normalize: 0 (concentrated) to 1 (diversified) + normalized = (1.0 - hhi) / (1.0 - min_hhi) if min_hhi < 1.0 else 0.0 + else: + # All in one cluster + normalized = 0.0 + + # Penalty for having multi-position clusters (hidden correlation) + # Each multi-position cluster reduces score by up to 10 points + correlation_penalty = min( + multi_pos_clusters * 10, + 30, # cap penalty at 30 points + ) + + div_score = max(0, min(100, int(normalized * 100 - correlation_penalty))) + + return { + "warnings": warnings, + "diversification_score": div_score, + "max_cluster_pct": round(max_cluster_pct, 2), + "num_clusters": num_clusters, + "num_multi_position_clusters": multi_pos_clusters, + } + + +# --------------------------------------------------------------------------- +# Output formatting +# --------------------------------------------------------------------------- + +def format_human( + positions: list[dict], + categorizations: list[dict], + clusters: list[dict], + risk: dict, + portfolio_data: dict, +) -> str: + """Format results as a human-readable report.""" + lines = [] + + # Header + lines.append("=" * 72) + lines.append(" CORRELATION TRACKER -- Portfolio Exposure Analysis") + lines.append("=" * 72) + lines.append("") + lines.append( + f" Portfolio value: ${portfolio_data['total_value']:>10,.2f}" + ) + lines.append( + f" Cash balance: ${portfolio_data['cash_balance']:>10,.2f}" + ) + lines.append( + f" Positions value: ${portfolio_data['positions_value']:>10,.2f}" + ) + lines.append( + f" Open positions: {len(positions):>10d}" + ) + lines.append("") + + # Position list with categories + lines.append("-" * 72) + lines.append(" POSITION CATEGORIZATION") + lines.append("-" * 72) + lines.append("") + + if not positions: + lines.append(" No open positions.") + else: + for i, (pos, cat) in enumerate(zip(positions, categorizations)): + exposure = pos["shares"] * pos["current_price"] + pct = ( + exposure / portfolio_data["total_value"] * 100 + if portfolio_data["total_value"] > 0 else 0 + ) + question = pos.get("market_question") or "Unknown" + lines.append(f" [{i+1}] {question}") + lines.append( + f" Side: {pos['side']} | " + f"Shares: {pos['shares']:.2f} | " + f"Price: ${pos['current_price']:.4f} | " + f"Exposure: ${exposure:,.2f} ({pct:.1f}%)" + ) + lines.append(f" Category: {cat['category']}") + if cat["qualifiers"]: + lines.append( + f" Qualifiers: {', '.join(cat['qualifiers'])}" + ) + lines.append("") + + # Correlation clusters + lines.append("-" * 72) + lines.append(" CORRELATION CLUSTERS") + lines.append("-" * 72) + lines.append("") + + if not clusters: + lines.append(" No clusters to analyze.") + else: + for cluster in clusters: + num = cluster["num_positions"] + pct = cluster.get("exposure_pct", 0) + icon = " " if num == 1 else ">" + + lines.append( + f" {icon} Cluster: {cluster['label']} " + f"({num} position{'s' if num != 1 else ''}) " + f"Exposure: ${cluster['total_exposure']:,.2f} ({pct:.1f}%)" + ) + if num > 1: + lines.append(f" Correlation reason: {cluster['reason']}") + + for cp in cluster["positions"]: + question = cp.get("market_question") or "Unknown" + lines.append( + f" - {cp['side']} ${cp['exposure']:,.2f} " + f"{question[:55]}" + ) + lines.append("") + + # Risk warnings + lines.append("-" * 72) + lines.append(" RISK WARNINGS") + lines.append("-" * 72) + lines.append("") + + if not risk["warnings"]: + lines.append(" No correlation risk warnings. All clusters within limits.") + else: + for w in risk["warnings"]: + lines.append(f" [{w['level']}] {w['message']}") + lines.append("") + + # Diversification score + lines.append("") + lines.append("-" * 72) + lines.append(" DIVERSIFICATION SUMMARY") + lines.append("-" * 72) + lines.append("") + score = risk["diversification_score"] + + if score >= 80: + grade = "EXCELLENT" + elif score >= 60: + grade = "GOOD" + elif score >= 40: + grade = "MODERATE" + elif score >= 20: + grade = "POOR" + else: + grade = "CONCENTRATED" + + bar_filled = score // 5 + bar_empty = 20 - bar_filled + bar = "#" * bar_filled + "-" * bar_empty + + lines.append(f" Diversification Score: {score}/100 [{bar}] {grade}") + lines.append(f" Unique clusters: {risk['num_clusters']}") + lines.append( + f" Correlated clusters: {risk['num_multi_position_clusters']}" + ) + lines.append( + f" Largest cluster: {risk['max_cluster_pct']:.1f}% of portfolio" + ) + lines.append("") + lines.append( + " Score = 100 (perfect diversification) to 0 (fully concentrated)." + ) + lines.append( + " Based on HHI of cluster exposures with penalties for hidden correlation." + ) + lines.append("") + + return "\n".join(lines) + + +def build_json_output( + positions: list[dict], + categorizations: list[dict], + clusters: list[dict], + risk: dict, + portfolio_data: dict, +) -> dict: + """Build the complete JSON output structure.""" + categorized_positions = [] + for pos, cat in zip(positions, categorizations): + exposure = pos["shares"] * pos["current_price"] + pct = ( + exposure / portfolio_data["total_value"] * 100 + if portfolio_data["total_value"] > 0 else 0 + ) + categorized_positions.append({ + "token_id": pos["token_id"], + "market_question": pos.get("market_question") or "Unknown", + "side": pos["side"], + "shares": pos["shares"], + "avg_entry": pos["avg_entry"], + "current_price": pos["current_price"], + "exposure": round(exposure, 4), + "exposure_pct": round(pct, 2), + "category": cat["category"], + "qualifiers": cat["qualifiers"], + "keywords": cat["keywords"], + "entities": cat["entities"], + }) + + # Clean up cluster positions for JSON (remove sqlite Row artifacts) + json_clusters = [] + for cluster in clusters: + json_cluster = { + "cluster_id": cluster["cluster_id"], + "label": cluster["label"], + "reason": cluster["reason"], + "num_positions": cluster["num_positions"], + "total_exposure": cluster["total_exposure"], + "exposure_pct": cluster.get("exposure_pct", 0), + "positions": [ + { + "token_id": p["token_id"], + "market_question": p.get("market_question") or "Unknown", + "side": p["side"], + "exposure": p["exposure"], + "category": p["category"], + "qualifiers": p.get("qualifiers", []), + } + for p in cluster["positions"] + ], + } + json_clusters.append(json_cluster) + + return { + "portfolio": { + "total_value": portfolio_data["total_value"], + "cash_balance": portfolio_data["cash_balance"], + "positions_value": portfolio_data["positions_value"], + "num_open_positions": len(positions), + }, + "positions": categorized_positions, + "clusters": json_clusters, + "risk": { + "warnings": risk["warnings"], + "diversification_score": risk["diversification_score"], + "max_cluster_pct": risk["max_cluster_pct"], + "num_clusters": risk["num_clusters"], + "num_multi_position_clusters": risk["num_multi_position_clusters"], + }, + } + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description=( + "Detect correlated exposure in the paper trading portfolio. " + "Groups positions by topic and warns when hidden concentration " + "exceeds risk thresholds." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s + %(prog)s --json + %(prog)s --threshold 0.10 --portfolio aggressive + %(prog)s --portfolio-db /path/to/portfolio.db --json + """, + ) + parser.add_argument( + "--portfolio-db", + type=str, + default=str(DB_PATH), + help=f"Path to the portfolio SQLite database (default: {DB_PATH})", + ) + parser.add_argument( + "--portfolio", + type=str, + default="default", + help="Portfolio name (default: 'default')", + ) + parser.add_argument( + "--json", + action="store_true", + help="Output results as JSON", + ) + parser.add_argument( + "--threshold", + type=float, + default=0.15, + help=( + "Correlation warning threshold as fraction of portfolio " + "(default: 0.15 = 15%%). ALERT triggers at the max-single-market " + "limit of 20%%." + ), + ) + + args = parser.parse_args() + + warn_threshold = args.threshold + alert_threshold = MAX_SINGLE_MARKET_PCT # 0.20 from CLAUDE.md + + # Validate thresholds + if not (0.0 < warn_threshold <= 1.0): + print( + "ERROR: --threshold must be between 0 and 1.", + file=sys.stderr, + ) + sys.exit(1) + + # Load portfolio + conn = _open_db(args.portfolio_db) + try: + portfolio_data = load_portfolio(conn, args.portfolio) + finally: + conn.close() + + positions = portfolio_data["positions"] + + if not positions: + if args.json: + print(json.dumps({ + "portfolio": { + "total_value": portfolio_data["total_value"], + "cash_balance": portfolio_data["cash_balance"], + "positions_value": 0, + "num_open_positions": 0, + }, + "positions": [], + "clusters": [], + "risk": { + "warnings": [], + "diversification_score": 100, + "max_cluster_pct": 0, + "num_clusters": 0, + "num_multi_position_clusters": 0, + }, + }, indent=2)) + else: + print("No open positions in portfolio. Nothing to analyze.") + return + + # Categorize each position + categorizations = [ + categorize_position(pos.get("market_question") or "") + for pos in positions + ] + + # Build correlation clusters + clusters = build_clusters(positions, categorizations) + + # Analyze risk + risk = analyze_risk( + clusters, + portfolio_data["total_value"], + warn_threshold, + alert_threshold, + ) + + # Output + if args.json: + output = build_json_output( + positions, categorizations, clusters, risk, portfolio_data + ) + print(json.dumps(output, indent=2)) + else: + report = format_human( + positions, categorizations, clusters, risk, portfolio_data + ) + print(report) + + +if __name__ == "__main__": + main() diff --git a/polymarket-live-executor/.env.example b/polymarket-live-executor/.env.example new file mode 100644 index 0000000..7bbcad2 --- /dev/null +++ b/polymarket-live-executor/.env.example @@ -0,0 +1,21 @@ +# Polymarket Live Trading — Environment Configuration +# Copy this file to .env and fill in your values: +# cp .env.example .env && chmod 600 .env +# +# NEVER commit .env to version control. +# NEVER use your main wallet. Create a dedicated burner wallet. + +# Required: Burner wallet private key (with 0x prefix) +# Create one with: cast wallet new OR python -c "from eth_account import Account; a=Account.create(); print(f'Address: {a.address}\nKey: {a.key.hex()}')" +POLYMARKET_PRIVATE_KEY=0x_YOUR_BURNER_WALLET_PRIVATE_KEY_HERE + +# Required: Safety gate — must be "true" for any trade to execute +POLYMARKET_CONFIRM=true + +# Max dollars per trade (start small!) +# First time: $5 | Learning: $10 | Experienced: $50 | Advanced: $200 +POLYMARKET_MAX_SIZE=5 + +# Max daily loss before all trading halts +# First time: $10 | Learning: $25 | Experienced: $100 | Advanced: $500 +POLYMARKET_DAILY_LOSS_LIMIT=10 diff --git a/polymarket-live-executor/SKILL.md b/polymarket-live-executor/SKILL.md index 658fcd6..4640343 100644 --- a/polymarket-live-executor/SKILL.md +++ b/polymarket-live-executor/SKILL.md @@ -33,18 +33,34 @@ safety controls on every operation. ## Setup -Before using this skill, the user must: +Use the setup wizard to configure everything: -1. Create a burner wallet (see `references/security.md`) -2. Fund it with a small amount of USDC on Polygon -3. Set environment variables: - ```bash - export POLYMARKET_PRIVATE_KEY="0x..." # Burner wallet only! - export POLYMARKET_CONFIRM=true # Safety gate - export POLYMARKET_MAX_SIZE=10 # Max $ per trade (default: 10) - export POLYMARKET_DAILY_LOSS_LIMIT=50 # Max daily loss (default: 50) - ``` -4. Review the `references/live-trading-checklist.md` before any live trade +```bash +# Step 1: Create a burner wallet +python scripts/setup_wallet.py --create + +# Step 2: Fund wallet with USDC on Polygon (manually via MetaMask/bridge) + +# Step 3: Copy and fill in .env +cp .env.example .env && chmod 600 .env +# Edit .env with your private key and limits + +# Step 4: Verify everything is configured +python scripts/setup_wallet.py --verify + +# Step 5: Check on-chain balance +python scripts/setup_wallet.py --check-balance +``` + +Or set environment variables manually: +```bash +export POLYMARKET_PRIVATE_KEY="0x..." # Burner wallet only! +export POLYMARKET_CONFIRM=true # Safety gate +export POLYMARKET_MAX_SIZE=10 # Max $ per trade (default: 10) +export POLYMARKET_DAILY_LOSS_LIMIT=50 # Max daily loss (default: 50) +``` + +Review the `references/live-trading-checklist.md` before any live trade. ## Available Scripts diff --git a/polymarket-live-executor/scripts/setup_wallet.py b/polymarket-live-executor/scripts/setup_wallet.py new file mode 100644 index 0000000..9cbbff2 --- /dev/null +++ b/polymarket-live-executor/scripts/setup_wallet.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +""" +Polymarket Live Trading — Wallet Setup & Verification + +Creates a burner wallet OR verifies an existing configuration. +Run this BEFORE your first live trade. + +Usage: + python setup_wallet.py --create # Generate new burner wallet + python setup_wallet.py --verify # Check existing env vars + python setup_wallet.py --check-balance # Verify on-chain USDC balance +""" + +import argparse +import json +import os +import sys +import stat +from pathlib import Path + +# Polygon USDC contract +USDC_CONTRACT = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" +POLYGON_RPC = "https://polygon-rpc.com" + + +def create_wallet(): + """Generate a new burner wallet and output setup instructions.""" + try: + from eth_account import Account + except ImportError: + print("ERROR: eth_account not installed.") + print("Install with: pip install eth-account") + print("") + print("Alternative: use 'cast wallet new' (Foundry) or MetaMask.") + sys.exit(1) + + acct = Account.create() + address = acct.address + private_key = acct.key.hex() + + print("=" * 60) + print(" NEW BURNER WALLET CREATED") + print("=" * 60) + print(f" Address: {address}") + print(f" Private Key: {private_key}") + print("=" * 60) + print("") + print("SAVE THE PRIVATE KEY NOW — it cannot be recovered.") + print("") + print("Next steps:") + print(f" 1. Fund {address} with USDC on Polygon") + print(" - Start with $25 or less (First Time tier)") + print(" - Send ~0.1 MATIC for gas fees") + print(" 2. Set up your .env file:") + print("") + + env_path = Path(__file__).parent.parent / ".env" + example_path = Path(__file__).parent.parent / ".env.example" + + if not env_path.exists() and example_path.exists(): + print(f" cp {example_path} {env_path}") + print(f" chmod 600 {env_path}") + print(f" # Edit {env_path} and paste your private key") + else: + print(f" export POLYMARKET_PRIVATE_KEY={private_key}") + print(" export POLYMARKET_CONFIRM=true") + print(" export POLYMARKET_MAX_SIZE=5") + print(" export POLYMARKET_DAILY_LOSS_LIMIT=10") + + print("") + print(" 3. Verify setup:") + print(" python setup_wallet.py --verify") + print("") + print("SECURITY REMINDERS:") + print(" - This is a BURNER wallet — only fund what you can lose") + print(" - NEVER use your main wallet's private key") + print(" - NEVER commit .env to git") + print(" - NEVER paste the private key into chat") + + +def verify_config(): + """Check that all required env vars are set and valid.""" + print("=" * 60) + print(" LIVE TRADING CONFIGURATION CHECK") + print("=" * 60) + print("") + + checks = [] + + # Check POLYMARKET_PRIVATE_KEY + pk = os.environ.get("POLYMARKET_PRIVATE_KEY", "") + if not pk: + checks.append(("POLYMARKET_PRIVATE_KEY", "MISSING", "Not set")) + elif not pk.startswith("0x") or len(pk) != 66: + checks.append(("POLYMARKET_PRIVATE_KEY", "INVALID", "Must be 0x + 64 hex chars")) + else: + masked = pk[:6] + "..." + pk[-4:] + checks.append(("POLYMARKET_PRIVATE_KEY", "OK", f"Set ({masked})")) + + # Check POLYMARKET_CONFIRM + confirm = os.environ.get("POLYMARKET_CONFIRM", "") + if confirm != "true": + checks.append(("POLYMARKET_CONFIRM", "MISSING", f"Got '{confirm}', need 'true'")) + else: + checks.append(("POLYMARKET_CONFIRM", "OK", "Safety gate enabled")) + + # Check POLYMARKET_MAX_SIZE + max_size = os.environ.get("POLYMARKET_MAX_SIZE", "") + if not max_size: + checks.append(("POLYMARKET_MAX_SIZE", "DEFAULT", "Not set, will use $10 default")) + else: + try: + val = float(max_size) + tier = "First time" if val <= 5 else "Learning" if val <= 10 else "Experienced" if val <= 50 else "Advanced" + checks.append(("POLYMARKET_MAX_SIZE", "OK", f"${val:.0f} per trade ({tier} tier)")) + except ValueError: + checks.append(("POLYMARKET_MAX_SIZE", "INVALID", f"Got '{max_size}', need a number")) + + # Check POLYMARKET_DAILY_LOSS_LIMIT + dll = os.environ.get("POLYMARKET_DAILY_LOSS_LIMIT", "") + if not dll: + checks.append(("POLYMARKET_DAILY_LOSS_LIMIT", "DEFAULT", "Not set, will use $50 default")) + else: + try: + val = float(dll) + checks.append(("POLYMARKET_DAILY_LOSS_LIMIT", "OK", f"${val:.0f} daily limit")) + except ValueError: + checks.append(("POLYMARKET_DAILY_LOSS_LIMIT", "INVALID", f"Got '{dll}', need a number")) + + # Check .env file permissions + env_path = Path(__file__).parent.parent / ".env" + if env_path.exists(): + mode = oct(stat.S_IMODE(env_path.stat().st_mode)) + if mode == "0o600": + checks.append((".env permissions", "OK", f"{mode} (owner read/write only)")) + else: + checks.append((".env permissions", "WARN", f"{mode} — should be 0o600. Run: chmod 600 {env_path}")) + else: + checks.append((".env file", "INFO", "No .env file found (using shell env vars)")) + + # Check .gitignore + gitignore_path = Path(__file__).parent.parent.parent / ".gitignore" + if gitignore_path.exists(): + content = gitignore_path.read_text() + if ".env" in content: + checks.append((".gitignore", "OK", ".env is gitignored")) + else: + checks.append((".gitignore", "WARN", ".env NOT in .gitignore — add it!")) + + # Print results + all_ok = True + for name, status, detail in checks: + icon = {"OK": "+", "MISSING": "X", "INVALID": "X", "WARN": "!", "DEFAULT": "~", "INFO": "-"} + print(f" [{icon.get(status, '?')}] {name}: {detail}") + if status in ("MISSING", "INVALID"): + all_ok = False + + print("") + if all_ok: + print(" STATUS: READY for live trading") + print("") + print(" Before your first trade, also run:") + print(" python setup_wallet.py --check-balance") + print(" python check_positions.py --balance") + else: + print(" STATUS: NOT READY — fix the issues above") + + return all_ok + + +def check_balance(): + """Query on-chain USDC balance for the configured wallet.""" + pk = os.environ.get("POLYMARKET_PRIVATE_KEY", "") + if not pk or not pk.startswith("0x") or len(pk) != 66: + print("ERROR: POLYMARKET_PRIVATE_KEY not set or invalid.") + print("Run: python setup_wallet.py --verify") + sys.exit(1) + + try: + from eth_account import Account + acct = Account.from_key(pk) + address = acct.address + except ImportError: + print("ERROR: eth_account not installed. pip install eth-account") + sys.exit(1) + + try: + import requests + except ImportError: + print("ERROR: requests not installed. pip install requests") + sys.exit(1) + + print(f"Checking balance for: {address}") + print("") + + # Check MATIC balance + payload = { + "jsonrpc": "2.0", + "method": "eth_getBalance", + "params": [address, "latest"], + "id": 1, + } + try: + resp = requests.post(POLYGON_RPC, json=payload, timeout=10) + result = resp.json().get("result", "0x0") + matic_wei = int(result, 16) + matic = matic_wei / 1e18 + matic_status = "OK" if matic >= 0.01 else "LOW — need ~0.1 MATIC for gas" + print(f" MATIC: {matic:.4f} ({matic_status})") + except Exception as e: + print(f" MATIC: ERROR fetching — {e}") + + # Check USDC balance (ERC20 balanceOf) + # balanceOf(address) selector = 0x70a08231 + padded_addr = address[2:].lower().zfill(64) + payload = { + "jsonrpc": "2.0", + "method": "eth_call", + "params": [ + {"to": USDC_CONTRACT, "data": f"0x70a08231{padded_addr}"}, + "latest", + ], + "id": 2, + } + try: + resp = requests.post(POLYGON_RPC, json=payload, timeout=10) + result = resp.json().get("result", "0x0") + usdc_raw = int(result, 16) + usdc = usdc_raw / 1e6 # USDC has 6 decimals + tier = "First time" if usdc <= 25 else "Learning" if usdc <= 100 else "Experienced" if usdc <= 500 else "Advanced" + print(f" USDC: ${usdc:.2f} ({tier} tier)") + except Exception as e: + print(f" USDC: ERROR fetching — {e}") + + print("") + print("If balance is zero, fund the wallet:") + print(f" Send USDC (Polygon) to: {address}") + print(" Send ~0.1 MATIC for gas fees") + + +def main(): + parser = argparse.ArgumentParser( + description="Polymarket live trading wallet setup and verification" + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--create", action="store_true", help="Generate a new burner wallet" + ) + group.add_argument( + "--verify", action="store_true", help="Verify existing env var configuration" + ) + group.add_argument( + "--check-balance", + action="store_true", + help="Check on-chain USDC and MATIC balance", + ) + args = parser.parse_args() + + if args.create: + create_wallet() + elif args.verify: + verify_config() + elif args.check_balance: + check_balance() + + +if __name__ == "__main__": + main() diff --git a/polymarket-paper-trader/SKILL.md b/polymarket-paper-trader/SKILL.md index db2c579..7261866 100644 --- a/polymarket-paper-trader/SKILL.md +++ b/polymarket-paper-trader/SKILL.md @@ -52,6 +52,13 @@ python ~/.agents/skills/polymarket-paper-trader/scripts/portfolio_report.py python ~/.agents/skills/polymarket-paper-trader/scripts/portfolio_report.py --json ``` +### Portfolio Health Check (Session Start) +```bash +python ~/.agents/skills/polymarket-paper-trader/scripts/health_check.py +python ~/.agents/skills/polymarket-paper-trader/scripts/health_check.py --json +``` +Runs the full session-start workflow in one command: loads portfolio, fetches live prices, updates DB, calculates drawdown, checks stop losses, evaluates all risk limits. Returns GREEN/YELLOW/RED status. + ## Finding Token IDs Token IDs come from the Polymarket Gamma API. To find them for a market: diff --git a/polymarket-paper-trader/scripts/health_check.py b/polymarket-paper-trader/scripts/health_check.py new file mode 100755 index 0000000..9a0a3f5 --- /dev/null +++ b/polymarket-paper-trader/scripts/health_check.py @@ -0,0 +1,664 @@ +#!/usr/bin/env python3 +""" +Portfolio Health Check — Automated Session Start Workflow + +Performs the complete CLAUDE.md Session Start sequence in one command: +1. Load portfolio from SQLite +2. Fetch LIVE prices from CLOB API for every open position +3. Update current_price in DB +4. Calculate per-position P&L and stop-loss status +5. Calculate portfolio-level metrics (value, drawdown, daily P&L, concentration) +6. Check graduated drawdown thresholds (10% / 15% / 20%) +7. Check daily loss limit (5%) and weekly loss limit (10%) +8. Output a clean summary with overall status: GREEN / YELLOW / RED +""" + +import argparse +import json +import os +import sqlite3 +import sys +from datetime import datetime, timezone, timedelta +from pathlib import Path +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError + +# --------------------------------------------------------------------------- +# Import paper_engine for DB access and API helpers +# --------------------------------------------------------------------------- + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +if _THIS_DIR not in sys.path: + sys.path.append(_THIS_DIR) +from paper_engine import ( + DB_PATH, + CLOB_API, + _get_db, + _active_portfolio, + _validate_token_id, + _api_get, + fetch_midpoint, +) + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DEFAULT_PORTFOLIO_NAME = "default" +MAX_CONCURRENT_POSITIONS = 5 +MAX_SINGLE_MARKET_PCT = 0.20 # 20% +DAILY_LOSS_LIMIT_PCT = 0.05 # 5% +WEEKLY_LOSS_LIMIT_PCT = 0.10 # 10% +DEFAULT_TRAILING_STOP_PCT = 0.15 # 15% trailing stop from entry + +# Graduated drawdown thresholds from CLAUDE.md Section 2 +DRAWDOWN_THRESHOLDS = [ + { + "level": 0.10, + "tier": "WARN", + "label": "10% drawdown", + "action": "Reduce ALL position sizes by 50%", + "restrictions": None, + }, + { + "level": 0.15, + "tier": "ALERT", + "label": "15% drawdown", + "action": "Reduce ALL position sizes by 75%; no new momentum or news trades", + "restrictions": ["no_momentum", "no_news"], + }, + { + "level": 0.20, + "tier": "CRITICAL", + "label": "20% drawdown", + "action": "Close ALL positions; halt all trading; full strategy review required", + "restrictions": ["halt_all"], + }, +] + + +# --------------------------------------------------------------------------- +# Live price fetching +# --------------------------------------------------------------------------- + +def fetch_live_price(token_id: str) -> float | None: + """ + Fetch the midpoint price for a token from the CLOB API. + Returns None on failure instead of raising. + """ + try: + return fetch_midpoint(token_id) + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Core health check logic +# --------------------------------------------------------------------------- + +def run_health_check( + db_path: str | Path = DB_PATH, + portfolio_name: str = DEFAULT_PORTFOLIO_NAME, +) -> dict: + """ + Execute the full Session Start health check workflow. + + Returns a structured dict containing: + - portfolio overview + - per-position details with live prices and stop-loss status + - risk utilization metrics + - alerts/warnings + - overall status (GREEN / YELLOW / RED) + """ + db_path = Path(db_path).expanduser() + if not db_path.exists(): + raise RuntimeError( + f"Portfolio database not found: {db_path}\n" + f"Run: python paper_engine.py --action init" + ) + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + + try: + # --------------------------------------------------------------- + # 1. Load portfolio + # --------------------------------------------------------------- + pf = _active_portfolio(conn, portfolio_name) + pid = pf["id"] + starting_balance = pf["starting_balance"] + cash_balance = pf["cash_balance"] + peak_value = pf["peak_value"] + + # --------------------------------------------------------------- + # 2-3. Fetch live prices and update DB for each open position + # --------------------------------------------------------------- + positions_rows = conn.execute( + "SELECT * FROM positions WHERE portfolio_id = ? AND closed = 0", + (pid,), + ).fetchall() + + now_iso = datetime.now(timezone.utc).isoformat() + positions = [] + price_errors = [] + + for row in positions_rows: + p = dict(row) + token_id = p["token_id"] + + live_price = fetch_live_price(token_id) + if live_price is not None: + p["current_price"] = live_price + conn.execute( + "UPDATE positions SET current_price = ?, updated_at = ? WHERE id = ?", + (live_price, now_iso, p["id"]), + ) + else: + price_errors.append(token_id) + # Keep stale price from DB + + positions.append(p) + + conn.commit() + + # --------------------------------------------------------------- + # 4. Per-position P&L and stop-loss status + # --------------------------------------------------------------- + position_details = [] + positions_value = 0.0 + + for p in positions: + shares = p["shares"] + entry = p["avg_entry"] + current = p["current_price"] + value = shares * current + unrealized_pnl = (current - entry) * shares + pnl_pct = ((current - entry) / entry * 100) if entry > 0 else 0.0 + + # Stop-loss: default 15% trailing from entry + # (CLAUDE.md says stop_loss = entry_price - edge/2, but we + # don't store edge, so use 15% trailing stop from entry) + stop_price = entry * (1 - DEFAULT_TRAILING_STOP_PCT) + stop_triggered = current <= stop_price + + position_details.append({ + "id": p["id"], + "token_id": p["token_id"], + "market_question": p["market_question"] or "Unknown", + "side": p["side"], + "shares": round(shares, 4), + "avg_entry": round(entry, 6), + "current_price": round(current, 6), + "value": round(value, 4), + "unrealized_pnl": round(unrealized_pnl, 4), + "pnl_pct": round(pnl_pct, 2), + "stop_price": round(stop_price, 6), + "stop_triggered": stop_triggered, + "price_stale": p["token_id"] in price_errors, + "opened_at": p["opened_at"], + }) + + positions_value += value + + # --------------------------------------------------------------- + # 5. Portfolio-level metrics + # --------------------------------------------------------------- + total_value = cash_balance + positions_value + overall_pnl = total_value - starting_balance + overall_pnl_pct = (overall_pnl / starting_balance * 100) if starting_balance > 0 else 0.0 + + # Update peak if we have a new high + if total_value > peak_value: + peak_value = total_value + conn.execute( + "UPDATE portfolios SET peak_value = ?, updated_at = ? WHERE id = ?", + (peak_value, now_iso, pid), + ) + conn.commit() + + # Drawdown from peak + drawdown_usd = peak_value - total_value + drawdown_pct = (drawdown_usd / peak_value * 100) if peak_value > 0 else 0.0 + + # Daily P&L: compare to yesterday's snapshot or starting balance + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + prev_snapshot = conn.execute( + """SELECT total_value FROM daily_snapshots + WHERE portfolio_id = ? AND date < ? + ORDER BY date DESC LIMIT 1""", + (pid, today), + ).fetchone() + prev_value = prev_snapshot["total_value"] if prev_snapshot else starting_balance + daily_pnl = total_value - prev_value + daily_pnl_pct = (daily_pnl / prev_value * 100) if prev_value > 0 else 0.0 + + # Position count + num_positions = len(position_details) + + # Single market concentration: max exposure to any one token_id + exposure_by_token = {} + for p in position_details: + tid = p["token_id"] + exposure_by_token[tid] = exposure_by_token.get(tid, 0.0) + p["value"] + + max_concentration = 0.0 + max_concentration_market = "" + for tid, exp in exposure_by_token.items(): + pct = (exp / total_value) if total_value > 0 else 0.0 + if pct > max_concentration: + max_concentration = pct + # Look up market name + for p in position_details: + if p["token_id"] == tid: + max_concentration_market = p["market_question"] + break + + # --------------------------------------------------------------- + # 6. Graduated drawdown thresholds + # --------------------------------------------------------------- + drawdown_fraction = drawdown_pct / 100.0 + drawdown_tier = "NONE" + drawdown_action = None + + for threshold in DRAWDOWN_THRESHOLDS: + if drawdown_fraction >= threshold["level"]: + drawdown_tier = threshold["tier"] + drawdown_action = threshold["action"] + + # --------------------------------------------------------------- + # 7. Daily and weekly loss limits + # --------------------------------------------------------------- + + # Daily realized losses from SELL/CLOSE trades today + daily_realized_row = conn.execute( + """SELECT COALESCE(SUM( + CASE WHEN action IN ('SELL','CLOSE') AND entry_avg IS NOT NULL + THEN (price - entry_avg) * shares + ELSE 0 END + ), 0) as realized + FROM trades + WHERE portfolio_id = ? AND date(executed_at) = ?""", + (pid, today), + ).fetchone() + daily_realized = daily_realized_row["realized"] if daily_realized_row else 0.0 + daily_loss = abs(min(0, daily_realized)) + daily_loss_limit = starting_balance * DAILY_LOSS_LIMIT_PCT + daily_loss_breached = daily_loss >= daily_loss_limit + + # Weekly realized losses (since last Monday) + now_dt = datetime.now(timezone.utc) + days_since_monday = now_dt.weekday() # Monday=0 + last_monday = (now_dt - timedelta(days=days_since_monday)).strftime("%Y-%m-%d") + + weekly_realized_row = conn.execute( + """SELECT COALESCE(SUM( + CASE WHEN action IN ('SELL','CLOSE') AND entry_avg IS NOT NULL + THEN (price - entry_avg) * shares + ELSE 0 END + ), 0) as realized + FROM trades + WHERE portfolio_id = ? AND date(executed_at) >= ?""", + (pid, last_monday), + ).fetchone() + weekly_realized = weekly_realized_row["realized"] if weekly_realized_row else 0.0 + weekly_loss = abs(min(0, weekly_realized)) + weekly_loss_limit = starting_balance * WEEKLY_LOSS_LIMIT_PCT + weekly_loss_breached = weekly_loss >= weekly_loss_limit + + # --------------------------------------------------------------- + # 8. Build alerts and determine overall status + # --------------------------------------------------------------- + alerts = [] + overall_status = "GREEN" + + # Stop-loss alerts + stops_triggered = [p for p in position_details if p["stop_triggered"]] + for p in stops_triggered: + alerts.append({ + "severity": "HIGH", + "type": "STOP_LOSS", + "message": ( + f"Stop-loss triggered for {p['side']} position in " + f"'{p['market_question'][:60]}': " + f"current ${p['current_price']:.4f} <= stop ${p['stop_price']:.4f}" + ), + }) + overall_status = "RED" + + # Stale price warnings + if price_errors: + alerts.append({ + "severity": "MEDIUM", + "type": "STALE_PRICE", + "message": ( + f"Failed to fetch live prices for {len(price_errors)} position(s). " + f"Using stale cached prices." + ), + }) + if overall_status == "GREEN": + overall_status = "YELLOW" + + # Drawdown alerts + if drawdown_tier == "WARN": + alerts.append({ + "severity": "MEDIUM", + "type": "DRAWDOWN_WARN", + "message": ( + f"Drawdown at {drawdown_pct:.1f}% from peak. " + f"Recommendation: {drawdown_action}" + ), + }) + if overall_status == "GREEN": + overall_status = "YELLOW" + elif drawdown_tier == "ALERT": + alerts.append({ + "severity": "HIGH", + "type": "DRAWDOWN_ALERT", + "message": ( + f"Drawdown at {drawdown_pct:.1f}% from peak. " + f"Recommendation: {drawdown_action}" + ), + }) + overall_status = "RED" + elif drawdown_tier == "CRITICAL": + alerts.append({ + "severity": "CRITICAL", + "type": "DRAWDOWN_CRITICAL", + "message": ( + f"Drawdown at {drawdown_pct:.1f}% from peak. " + f"REQUIRED ACTION: {drawdown_action}" + ), + }) + overall_status = "RED" + + # Daily loss limit + if daily_loss_breached: + alerts.append({ + "severity": "HIGH", + "type": "DAILY_LOSS_LIMIT", + "message": ( + f"Daily loss limit breached: ${daily_loss:.2f} realized losses " + f"(limit: ${daily_loss_limit:.2f} = {DAILY_LOSS_LIMIT_PCT*100:.0f}% " + f"of starting balance). All new entries blocked until next UTC day." + ), + }) + overall_status = "RED" + + # Weekly loss limit + if weekly_loss_breached: + alerts.append({ + "severity": "HIGH", + "type": "WEEKLY_LOSS_LIMIT", + "message": ( + f"Weekly loss limit breached: ${weekly_loss:.2f} realized losses " + f"(limit: ${weekly_loss_limit:.2f} = {WEEKLY_LOSS_LIMIT_PCT*100:.0f}% " + f"of starting balance). All new entries blocked until next Monday." + ), + }) + overall_status = "RED" + + # Concentration warning + if max_concentration > MAX_SINGLE_MARKET_PCT: + alerts.append({ + "severity": "MEDIUM", + "type": "CONCENTRATION", + "message": ( + f"Single market concentration at {max_concentration*100:.1f}% " + f"(limit: {MAX_SINGLE_MARKET_PCT*100:.0f}%) in " + f"'{max_concentration_market[:60]}'" + ), + }) + if overall_status == "GREEN": + overall_status = "YELLOW" + + # Position count warning + if num_positions >= MAX_CONCURRENT_POSITIONS: + alerts.append({ + "severity": "MEDIUM", + "type": "MAX_POSITIONS", + "message": ( + f"At maximum concurrent positions: " + f"{num_positions}/{MAX_CONCURRENT_POSITIONS}. " + f"No new positions allowed." + ), + }) + if overall_status == "GREEN": + overall_status = "YELLOW" + + # --------------------------------------------------------------- + # Assemble result + # --------------------------------------------------------------- + result = { + "status": overall_status, + "checked_at": now_iso, + "portfolio": { + "name": portfolio_name, + "starting_balance": round(starting_balance, 2), + "cash_balance": round(cash_balance, 2), + "positions_value": round(positions_value, 2), + "total_value": round(total_value, 2), + "pnl": round(overall_pnl, 2), + "pnl_pct": round(overall_pnl_pct, 2), + "peak_value": round(peak_value, 2), + "drawdown_usd": round(drawdown_usd, 2), + "drawdown_pct": round(drawdown_pct, 2), + "daily_pnl": round(daily_pnl, 2), + "daily_pnl_pct": round(daily_pnl_pct, 2), + }, + "positions": position_details, + "risk": { + "position_count": num_positions, + "position_limit": MAX_CONCURRENT_POSITIONS, + "position_utilization": f"{num_positions}/{MAX_CONCURRENT_POSITIONS}", + "max_concentration_pct": round(max_concentration * 100, 1), + "max_concentration_market": max_concentration_market[:70] if max_concentration_market else "N/A", + "concentration_limit_pct": MAX_SINGLE_MARKET_PCT * 100, + "drawdown_tier": drawdown_tier, + "drawdown_action": drawdown_action, + "daily_loss": round(daily_loss, 2), + "daily_loss_limit": round(daily_loss_limit, 2), + "daily_loss_pct": round(daily_loss / starting_balance * 100, 2) if starting_balance > 0 else 0.0, + "daily_loss_breached": daily_loss_breached, + "weekly_loss": round(weekly_loss, 2), + "weekly_loss_limit": round(weekly_loss_limit, 2), + "weekly_loss_pct": round(weekly_loss / starting_balance * 100, 2) if starting_balance > 0 else 0.0, + "weekly_loss_breached": weekly_loss_breached, + "stops_triggered": len(stops_triggered), + }, + "alerts": alerts, + } + + return result + + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Text formatting +# --------------------------------------------------------------------------- + +def format_human_readable(result: dict) -> str: + """Format the health check result as a human-readable report.""" + pf = result["portfolio"] + risk = result["risk"] + status = result["status"] + + # Status indicator + status_bar = { + "GREEN": "[GREEN] All systems nominal", + "YELLOW": "[YELLOW] Caution -- review warnings below", + "RED": "[RED] Action required -- review alerts below", + } + + lines = [ + "=" * 64, + f" PORTFOLIO HEALTH CHECK", + f" {result['checked_at'][:19]} UTC", + f" Status: {status_bar.get(status, status)}", + "=" * 64, + "", + "--- Portfolio Overview ---", + f" Starting Balance: ${pf['starting_balance']:>12,.2f}", + f" Cash: ${pf['cash_balance']:>12,.2f}", + f" Positions Value: ${pf['positions_value']:>12,.2f}", + f" Total Value: ${pf['total_value']:>12,.2f}", + f" P&L: ${pf['pnl']:>12,.2f} ({pf['pnl_pct']:+.2f}%)", + f" Peak Value: ${pf['peak_value']:>12,.2f}", + f" Drawdown: ${pf['drawdown_usd']:>12,.2f} ({pf['drawdown_pct']:.2f}%)", + f" Daily P&L: ${pf['daily_pnl']:>12,.2f} ({pf['daily_pnl_pct']:+.2f}%)", + ] + + # --- Positions --- + if result["positions"]: + lines.append("") + lines.append("--- Open Positions ---") + lines.append( + f" {'Side':>4} {'Shares':>8} {'Entry':>8} {'Current':>8} " + f"{'P&L':>10} {'P&L%':>7} {'Stop':>8} {'Status':>8}" + ) + lines.append(" " + "-" * 74) + + for p in result["positions"]: + stop_status = "STOP!" if p["stop_triggered"] else "OK" + stale = " [stale]" if p["price_stale"] else "" + lines.append( + f" {p['side']:>4} {p['shares']:>8.2f} " + f"${p['avg_entry']:>.4f} ${p['current_price']:>.4f} " + f"${p['unrealized_pnl']:>+9,.2f} {p['pnl_pct']:>+6.1f}% " + f"${p['stop_price']:>.4f} {stop_status:>8}{stale}" + ) + # Market question on its own line + lines.append(f" {p['market_question'][:60]}") + else: + lines.append("") + lines.append("--- Open Positions ---") + lines.append(" No open positions.") + + # --- Risk Utilization --- + lines.append("") + lines.append("--- Risk Utilization ---") + lines.append( + f" Positions: {risk['position_utilization']:>12}" + ) + lines.append( + f" Max Concentration: {risk['max_concentration_pct']:>11.1f}% " + f"(limit: {risk['concentration_limit_pct']:.0f}%)" + ) + if risk["max_concentration_market"] != "N/A": + lines.append( + f" in: {risk['max_concentration_market']}" + ) + lines.append( + f" Drawdown Tier: {risk['drawdown_tier']:>12}" + ) + if risk["drawdown_action"]: + lines.append(f" Action: {risk['drawdown_action']}") + lines.append( + f" Daily Loss: ${risk['daily_loss']:>11,.2f} / " + f"${risk['daily_loss_limit']:,.2f} " + f"({risk['daily_loss_pct']:.1f}% / {DAILY_LOSS_LIMIT_PCT*100:.0f}%)" + ) + lines.append( + f" Weekly Loss: ${risk['weekly_loss']:>11,.2f} / " + f"${risk['weekly_loss_limit']:,.2f} " + f"({risk['weekly_loss_pct']:.1f}% / {WEEKLY_LOSS_LIMIT_PCT*100:.0f}%)" + ) + lines.append( + f" Stops Triggered: {risk['stops_triggered']:>12}" + ) + + # --- Alerts --- + if result["alerts"]: + lines.append("") + lines.append("--- Alerts ---") + for alert in result["alerts"]: + severity = alert["severity"] + lines.append(f" [{severity}] {alert['message']}") + else: + lines.append("") + lines.append("--- Alerts ---") + lines.append(" None. All risk checks passed.") + + lines.append("") + lines.append("=" * 64) + lines.append( + f" OVERALL STATUS: {status}" + ) + lines.append("=" * 64) + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description="Portfolio health check — automated Session Start workflow", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Performs the complete CLAUDE.md Session Start sequence: + 1. Load portfolio from SQLite + 2. Fetch live prices from CLOB API for every open position + 3. Update current_price in DB + 4. Calculate per-position P&L and stop-loss status + 5. Check graduated drawdown thresholds (10/15/20%%) + 6. Check daily loss (5%%) and weekly loss (10%%) limits + 7. Output status: GREEN / YELLOW / RED + +Examples: + %(prog)s + %(prog)s --portfolio my_portfolio + %(prog)s --json + %(prog)s --portfolio-db /path/to/portfolio.db --json + """, + ) + parser.add_argument( + "--portfolio-db", + type=str, + default=str(DB_PATH), + help=f"Path to portfolio SQLite database (default: {DB_PATH})", + ) + parser.add_argument( + "--portfolio", + type=str, + default=DEFAULT_PORTFOLIO_NAME, + help="Portfolio name (default: 'default')", + ) + parser.add_argument( + "--json", + action="store_true", + help="Output as JSON instead of human-readable format", + ) + + args = parser.parse_args() + + try: + result = run_health_check( + db_path=args.portfolio_db, + portfolio_name=args.portfolio, + ) + + if args.json: + print(json.dumps(result, indent=2)) + else: + print(format_human_readable(result)) + + # Exit code reflects status: 0=GREEN, 1=YELLOW, 2=RED + exit_codes = {"GREEN": 0, "YELLOW": 1, "RED": 2} + sys.exit(exit_codes.get(result["status"], 1)) + + except RuntimeError as exc: + if args.json: + print(json.dumps({"error": str(exc)}), file=sys.stderr) + else: + print(f"ERROR: {exc}", file=sys.stderr) + sys.exit(3) + + +if __name__ == "__main__": + main() diff --git a/polymarket-strategy-advisor/SKILL.md b/polymarket-strategy-advisor/SKILL.md index ba97c3a..61159b6 100644 --- a/polymarket-strategy-advisor/SKILL.md +++ b/polymarket-strategy-advisor/SKILL.md @@ -179,6 +179,23 @@ source /home/verticalclaw/.venv/bin/activate && python polymarket-strategy-advis Output: JSON array of trade recommendations sorted by expected value. +### Backtest Engine (`scripts/backtest.py`) + +Comprehensive performance analysis and live-readiness assessment: + +```bash +source /home/verticalclaw/.venv/bin/activate && python polymarket-strategy-advisor/scripts/backtest.py +``` + +Live-readiness check only: +```bash +source /home/verticalclaw/.venv/bin/activate && python polymarket-strategy-advisor/scripts/backtest.py --live-check +``` + +Output: total return, win rate, Sharpe ratio, max drawdown, profit factor, +per-strategy breakdown, and READY/NOT READY assessment against CLAUDE.md +prerequisites (20+ trades, >55% win rate, Sharpe >0.5, drawdown <15%). + ### Daily Performance Review (`scripts/daily_review.py`) Analyzes paper trading history and suggests improvements: diff --git a/polymarket-strategy-advisor/scripts/backtest.py b/polymarket-strategy-advisor/scripts/backtest.py new file mode 100644 index 0000000..d65c273 --- /dev/null +++ b/polymarket-strategy-advisor/scripts/backtest.py @@ -0,0 +1,1137 @@ +#!/usr/bin/env python3 +"""Backtesting engine for Polymarket paper trading strategies. + +Reads historical trades from the paper trading database, replays closed +positions, marks open positions to market, and computes performance metrics +including Sharpe ratio, drawdown, profit factor, and per-strategy breakdowns. + +Produces a live-readiness assessment against the CLAUDE.md prerequisites +(20+ closed trades, >55% win rate, >0.5 Sharpe, <15% max drawdown). + +Usage: + python backtest.py + python backtest.py --portfolio-db ~/.polymarket-paper/portfolio.db + python backtest.py --days 7 --json + python backtest.py --live-check +""" + +import argparse +import json +import math +import os +import sqlite3 +import statistics +import sys +from datetime import datetime, timedelta, timezone +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DEFAULT_DB_PATH = os.path.expanduser("~/.polymarket-paper/portfolio.db") +CLOB_API = "https://clob.polymarket.com" +RISK_FREE_RATE = 0.045 # 4.5% annualized +TRADING_DAYS_PER_YEAR = 365 # Prediction markets trade every day + +# Live-readiness thresholds (from CLAUDE.md Section 4) +LIVE_MIN_CLOSED_TRADES = 20 +LIVE_MIN_WIN_RATE = 0.55 +LIVE_MIN_SHARPE = 0.5 +LIVE_MAX_DRAWDOWN = 0.15 + +# CLAUDE.md risk limits (authoritative source of truth) +RISK_LIMITS = { + "max_position_pct": 0.10, + "max_position_pct_low_confidence": 0.05, + "max_position_pct_news": 0.02, + "max_position_pct_new_strategy": 0.01, + "max_position_pct_arbitrage": 0.20, + "min_trade_size_usd": 10.0, + "max_concurrent_positions": 5, + "max_single_market_pct": 0.20, + "max_new_trades_per_day": 10, + "daily_loss_limit_pct": 0.05, + "weekly_loss_limit_pct": 0.10, + "drawdown_reduce_50_pct": 0.10, + "drawdown_reduce_75_pct": 0.15, + "drawdown_halt_pct": 0.20, +} + +# Strategy keywords to look for in the reasoning field +STRATEGY_KEYWORDS = { + "arbitrage": ["arbitrage", "arb", "gabagool", "yes+no", "underpriced pair"], + "momentum": ["momentum", "imbalance", "vol/liq", "volume/liquidity"], + "mean-reversion": ["mean-reversion", "mean reversion", "spread", "midpoint", + "deviates", "revert"], + "news": ["news", "breaking", "announcement", "event-driven", "headline"], +} + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + +def _api_get(url, timeout=15): + """GET JSON from a URL. Returns parsed JSON or None on failure.""" + req = Request(url, headers={"User-Agent": "polymarket-backtest/1.0"}) + try: + with urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except (URLError, HTTPError, ValueError, OSError): + return None + + +def fetch_midpoint(token_id): + """Fetch the current midpoint price for a CLOB token. + + Returns the midpoint as a float, or None if the request fails. + """ + data = _api_get(f"{CLOB_API}/midpoint?token_id={token_id}") + if data and "mid" in data: + try: + return float(data["mid"]) + except (ValueError, TypeError): + return None + return None + + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- + +def connect_db(db_path): + """Open a read-only connection to the paper trader database. + + Returns None if the file does not exist. + """ + if not os.path.exists(db_path): + return None + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + return conn + + +def get_portfolio_id(conn, portfolio_name): + """Return the active portfolio ID for the given name, or None.""" + row = conn.execute( + "SELECT id, starting_balance, cash_balance, peak_value " + "FROM portfolios WHERE name = ? AND active = 1 " + "ORDER BY id DESC LIMIT 1", + (portfolio_name,), + ).fetchone() + return dict(row) if row else None + + +def get_all_trades(conn, portfolio_id, since=None): + """Fetch all trades for a portfolio, optionally filtered by date. + + Returns a list of dicts sorted by executed_at ascending. + """ + if since: + rows = conn.execute( + "SELECT * FROM trades WHERE portfolio_id = ? AND executed_at >= ? " + "ORDER BY executed_at ASC", + (portfolio_id, since.isoformat()), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM trades WHERE portfolio_id = ? " + "ORDER BY executed_at ASC", + (portfolio_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def get_open_positions(conn, portfolio_id): + """Fetch all open positions for a portfolio.""" + rows = conn.execute( + "SELECT * FROM positions WHERE portfolio_id = ? AND closed = 0 " + "ORDER BY opened_at ASC", + (portfolio_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def get_closed_positions(conn, portfolio_id, since=None): + """Fetch all closed positions for a portfolio.""" + if since: + rows = conn.execute( + "SELECT * FROM positions WHERE portfolio_id = ? AND closed = 1 " + "AND closed_at >= ? ORDER BY closed_at ASC", + (portfolio_id, since.isoformat()), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM positions WHERE portfolio_id = ? AND closed = 1 " + "ORDER BY closed_at ASC", + (portfolio_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def get_daily_snapshots(conn, portfolio_id, since=None): + """Fetch daily portfolio snapshots.""" + if since: + rows = conn.execute( + "SELECT * FROM daily_snapshots WHERE portfolio_id = ? " + "AND date >= ? ORDER BY date ASC", + (portfolio_id, since.strftime("%Y-%m-%d")), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM daily_snapshots WHERE portfolio_id = ? " + "ORDER BY date ASC", + (portfolio_id,), + ).fetchall() + return [dict(r) for r in rows] + + +# --------------------------------------------------------------------------- +# Trade pairing — match BUY entries with SELL/CLOSE exits +# --------------------------------------------------------------------------- + +def classify_strategy(reasoning): + """Classify a trade's strategy type from its reasoning text. + + Returns one of: 'arbitrage', 'momentum', 'mean-reversion', 'news', + or 'unclassified'. + """ + if not reasoning: + return "unclassified" + text = reasoning.lower() + for strategy, keywords in STRATEGY_KEYWORDS.items(): + for kw in keywords: + if kw in text: + return strategy + return "unclassified" + + +def pair_trades(trades): + """Match BUY trades with corresponding SELL trades to form round trips. + + A round trip is a sequence of BUY(s) followed by SELL(s) for the same + (token_id, side) pair. We use FIFO matching. + + Returns: + closed_trips: list of dicts with entry/exit details and P&L + open_entries: list of dicts for positions that were bought but never sold + """ + # Accumulate buys per (token_id, side) key + buy_queues = {} # key -> list of {shares_remaining, price, reasoning, time} + closed_trips = [] + + for t in trades: + key = (t["token_id"], t["side"]) + action = t["action"] + + if action == "BUY": + if key not in buy_queues: + buy_queues[key] = [] + buy_queues[key].append({ + "shares_remaining": t["shares"], + "price": t["price"], + "fee": t.get("fee", 0), + "total_cost": t.get("total_cost", t["shares"] * t["price"]), + "reasoning": t.get("reasoning", ""), + "executed_at": t["executed_at"], + "market_question": t.get("market_question", ""), + }) + + elif action in ("SELL", "CLOSE"): + if key not in buy_queues or not buy_queues[key]: + # Sell without a preceding buy — skip orphaned sell + continue + + sell_shares = t["shares"] + sell_price = t["price"] + sell_fee = t.get("fee", 0) + sell_time = t["executed_at"] + + # FIFO match against queued buys + while sell_shares > 0.0001 and buy_queues[key]: + buy = buy_queues[key][0] + matched = min(sell_shares, buy["shares_remaining"]) + + entry_price = buy["price"] + # Proportional entry fee + entry_fee = buy["fee"] * (matched / (buy["shares_remaining"] + + (buy["total_cost"] / buy["price"] + - buy["shares_remaining"]) + if buy["shares_remaining"] > 0 else 1)) + # Simplified: attribute fees proportionally + if buy["shares_remaining"] > 0: + buy_fee_portion = buy["fee"] * (matched / buy["shares_remaining"]) + else: + buy_fee_portion = 0 + if t["shares"] > 0: + sell_fee_portion = sell_fee * (matched / t["shares"]) + else: + sell_fee_portion = 0 + + pnl = (sell_price - entry_price) * matched - buy_fee_portion - sell_fee_portion + cost_basis = entry_price * matched + buy_fee_portion + + # Calculate hold time + hold_hours = 0 + try: + entry_dt = datetime.fromisoformat(buy["executed_at"]) + exit_dt = datetime.fromisoformat(sell_time) + hold_hours = (exit_dt - entry_dt).total_seconds() / 3600 + except (ValueError, TypeError): + pass + + strategy = classify_strategy(buy["reasoning"]) + + closed_trips.append({ + "token_id": key[0], + "side": key[1], + "market_question": buy["market_question"], + "entry_price": entry_price, + "exit_price": sell_price, + "shares": round(matched, 4), + "cost_basis": round(cost_basis, 4), + "proceeds": round(sell_price * matched - sell_fee_portion, 4), + "pnl": round(pnl, 4), + "return_pct": round(pnl / cost_basis * 100, 2) if cost_basis > 0 else 0, + "entry_fee": round(buy_fee_portion, 4), + "exit_fee": round(sell_fee_portion, 4), + "entry_time": buy["executed_at"], + "exit_time": sell_time, + "hold_hours": round(hold_hours, 1), + "strategy": strategy, + "reasoning": buy["reasoning"], + }) + + buy["shares_remaining"] -= matched + sell_shares -= matched + + if buy["shares_remaining"] < 0.0001: + buy_queues[key].pop(0) + + # Collect remaining open entries + open_entries = [] + for key, buys in buy_queues.items(): + for buy in buys: + if buy["shares_remaining"] > 0.0001: + open_entries.append({ + "token_id": key[0], + "side": key[1], + "market_question": buy["market_question"], + "entry_price": buy["price"], + "shares": round(buy["shares_remaining"], 4), + "cost_basis": round(buy["price"] * buy["shares_remaining"], 4), + "entry_time": buy["executed_at"], + "strategy": classify_strategy(buy["reasoning"]), + "reasoning": buy["reasoning"], + }) + + return closed_trips, open_entries + + +# --------------------------------------------------------------------------- +# Metrics computation +# --------------------------------------------------------------------------- + +def compute_core_metrics(closed_trips): + """Compute core performance metrics from closed round trips. + + Returns a dict of metrics. Returns safe defaults for empty input. + """ + if not closed_trips: + return { + "total_closed_trades": 0, + "winners": 0, + "losers": 0, + "breakeven": 0, + "win_rate": 0.0, + "total_pnl": 0.0, + "avg_pnl": 0.0, + "avg_winner": 0.0, + "avg_loser": 0.0, + "largest_winner": 0.0, + "largest_loser": 0.0, + "gross_profit": 0.0, + "gross_loss": 0.0, + "profit_factor": 0.0, + "avg_return_pct": 0.0, + "avg_hold_hours": 0.0, + "total_fees": 0.0, + } + + pnls = [t["pnl"] for t in closed_trips] + winners = [p for p in pnls if p > 0] + losers = [p for p in pnls if p < 0] + breakevens = [p for p in pnls if p == 0] + hold_hours = [t["hold_hours"] for t in closed_trips if t["hold_hours"] > 0] + returns = [t["return_pct"] for t in closed_trips] + fees = [t["entry_fee"] + t["exit_fee"] for t in closed_trips] + + gross_profit = sum(winners) + gross_loss = abs(sum(losers)) + + return { + "total_closed_trades": len(closed_trips), + "winners": len(winners), + "losers": len(losers), + "breakeven": len(breakevens), + "win_rate": round(len(winners) / len(closed_trips), 4) if closed_trips else 0, + "total_pnl": round(sum(pnls), 2), + "avg_pnl": round(sum(pnls) / len(pnls), 2), + "avg_winner": round(gross_profit / len(winners), 2) if winners else 0, + "avg_loser": round(sum(losers) / len(losers), 2) if losers else 0, + "largest_winner": round(max(winners), 2) if winners else 0, + "largest_loser": round(min(losers), 2) if losers else 0, + "gross_profit": round(gross_profit, 2), + "gross_loss": round(gross_loss, 2), + "profit_factor": round(gross_profit / gross_loss, 4) if gross_loss > 0 else float("inf"), + "avg_return_pct": round(sum(returns) / len(returns), 2) if returns else 0, + "avg_hold_hours": round(sum(hold_hours) / len(hold_hours), 1) if hold_hours else 0, + "total_fees": round(sum(fees), 4), + } + + +def compute_drawdown(snapshots, starting_balance): + """Compute max drawdown and current drawdown from daily snapshots. + + If snapshots are empty, falls back to starting_balance as the only + data point and reports zero drawdown. + """ + if not snapshots: + return { + "max_drawdown_pct": 0.0, + "max_drawdown_usd": 0.0, + "current_drawdown_pct": 0.0, + "current_drawdown_usd": 0.0, + "peak_value": starting_balance, + "trough_value": starting_balance, + "peak_date": None, + "trough_date": None, + } + + values = [(s["date"], float(s["total_value"])) for s in snapshots] + peak = values[0][1] + peak_date = values[0][0] + max_dd = 0.0 + max_dd_usd = 0.0 + trough_value = peak + trough_date = peak_date + + for date, v in values: + if v > peak: + peak = v + peak_date = date + dd = (peak - v) / peak if peak > 0 else 0 + dd_usd = peak - v + if dd > max_dd: + max_dd = dd + max_dd_usd = dd_usd + trough_value = v + trough_date = date + + current_value = values[-1][1] + overall_peak = max(v for _, v in values) + current_dd = (overall_peak - current_value) / overall_peak if overall_peak > 0 else 0 + current_dd_usd = overall_peak - current_value + + return { + "max_drawdown_pct": round(max_dd * 100, 2), + "max_drawdown_usd": round(max_dd_usd, 2), + "current_drawdown_pct": round(current_dd * 100, 2), + "current_drawdown_usd": round(current_dd_usd, 2), + "peak_value": round(overall_peak, 2), + "trough_value": round(trough_value, 2), + "peak_date": peak_date, + "trough_date": trough_date, + } + + +def compute_sharpe_ratio(snapshots, starting_balance): + """Compute annualized Sharpe ratio from daily portfolio values. + + Uses daily returns derived from consecutive snapshots. Falls back to + using starting_balance + single snapshot if only one data point exists. + + Risk-free rate: 4.5% annualized (US Treasury). + """ + if len(snapshots) < 2: + return 0.0 + + values = [float(s["total_value"]) for s in snapshots] + daily_returns = [] + for i in range(1, len(values)): + if values[i - 1] > 0: + daily_returns.append((values[i] - values[i - 1]) / values[i - 1]) + + if not daily_returns: + return 0.0 + + daily_rf = RISK_FREE_RATE / TRADING_DAYS_PER_YEAR + excess_returns = [r - daily_rf for r in daily_returns] + + mean_excess = statistics.mean(excess_returns) + if len(excess_returns) < 2: + return 0.0 + + std_dev = statistics.stdev(excess_returns) + if std_dev == 0: + return float("inf") if mean_excess > 0 else 0.0 + + sharpe = (mean_excess / std_dev) * math.sqrt(TRADING_DAYS_PER_YEAR) + return round(sharpe, 4) + + +def compute_total_return(starting_balance, current_value): + """Compute total return as a percentage.""" + if starting_balance <= 0: + return 0.0 + return round((current_value - starting_balance) / starting_balance * 100, 2) + + +# --------------------------------------------------------------------------- +# Mark-to-market for open positions +# --------------------------------------------------------------------------- + +def mark_to_market(open_positions): + """Fetch live prices for open positions and calculate unrealized P&L. + + Returns a list of position dicts with current_price and unrealized_pnl + added, plus a summary dict. + """ + marked = [] + total_unrealized = 0.0 + total_market_value = 0.0 + fetch_errors = 0 + + for pos in open_positions: + token_id = pos["token_id"] + entry_price = pos["avg_entry"] + shares = pos["shares"] + + live_price = fetch_midpoint(token_id) + if live_price is not None: + current_price = live_price + else: + # Fall back to the stored current_price from the DB + current_price = pos.get("current_price", entry_price) + fetch_errors += 1 + + market_value = shares * current_price + unrealized_pnl = (current_price - entry_price) * shares + return_pct = ((current_price - entry_price) / entry_price * 100 + if entry_price > 0 else 0) + + # Calculate hold time + hold_hours = 0 + try: + opened = datetime.fromisoformat(pos["opened_at"]) + now = datetime.now(timezone.utc) + hold_hours = (now - opened).total_seconds() / 3600 + except (ValueError, TypeError): + pass + + marked.append({ + "token_id": token_id, + "side": pos["side"], + "market_question": pos.get("market_question", ""), + "shares": shares, + "entry_price": entry_price, + "current_price": round(current_price, 6), + "market_value": round(market_value, 2), + "unrealized_pnl": round(unrealized_pnl, 2), + "return_pct": round(return_pct, 2), + "hold_hours": round(hold_hours, 1), + "price_source": "live" if live_price is not None else "cached", + }) + total_unrealized += unrealized_pnl + total_market_value += market_value + + summary = { + "num_open": len(marked), + "total_market_value": round(total_market_value, 2), + "total_unrealized_pnl": round(total_unrealized, 2), + "fetch_errors": fetch_errors, + } + + return marked, summary + + +# --------------------------------------------------------------------------- +# Strategy breakdown +# --------------------------------------------------------------------------- + +def compute_strategy_breakdown(closed_trips): + """Break down metrics by strategy type. + + Returns a dict mapping strategy name to metrics dict. + """ + by_strategy = {} + for trip in closed_trips: + strategy = trip["strategy"] + if strategy not in by_strategy: + by_strategy[strategy] = [] + by_strategy[strategy].append(trip) + + breakdown = {} + for strategy, trips in sorted(by_strategy.items()): + metrics = compute_core_metrics(trips) + breakdown[strategy] = metrics + + return breakdown + + +# --------------------------------------------------------------------------- +# Risk rule compliance check +# --------------------------------------------------------------------------- + +def check_risk_compliance(closed_trips, open_positions, snapshots, + starting_balance, portfolio_value): + """Check historical trades against CLAUDE.md risk rules. + + Returns a list of violations found. + """ + violations = [] + + # Check max concurrent positions (from trades timeline) + # This is a simplification — we check current open positions + if len(open_positions) > RISK_LIMITS["max_concurrent_positions"]: + violations.append({ + "rule": "max_concurrent_positions", + "limit": RISK_LIMITS["max_concurrent_positions"], + "actual": len(open_positions), + "severity": "HIGH", + "detail": ( + f"Currently {len(open_positions)} open positions, " + f"limit is {RISK_LIMITS['max_concurrent_positions']}" + ), + }) + + # Check drawdown thresholds + if snapshots: + values = [float(s["total_value"]) for s in snapshots] + peak = max(values) + current = values[-1] + if peak > 0: + dd = (peak - current) / peak + if dd >= RISK_LIMITS["drawdown_halt_pct"]: + violations.append({ + "rule": "drawdown_halt", + "limit": f"{RISK_LIMITS['drawdown_halt_pct'] * 100:.0f}%", + "actual": f"{dd * 100:.1f}%", + "severity": "CRITICAL", + "detail": "Drawdown exceeds halt threshold. All trading should stop.", + }) + elif dd >= RISK_LIMITS["drawdown_reduce_75_pct"]: + violations.append({ + "rule": "drawdown_reduce_75", + "limit": f"{RISK_LIMITS['drawdown_reduce_75_pct'] * 100:.0f}%", + "actual": f"{dd * 100:.1f}%", + "severity": "HIGH", + "detail": ( + "Drawdown exceeds 15%. Position sizes should be reduced " + "by 75%. No new momentum or news trades." + ), + }) + elif dd >= RISK_LIMITS["drawdown_reduce_50_pct"]: + violations.append({ + "rule": "drawdown_reduce_50", + "limit": f"{RISK_LIMITS['drawdown_reduce_50_pct'] * 100:.0f}%", + "actual": f"{dd * 100:.1f}%", + "severity": "MEDIUM", + "detail": "Drawdown exceeds 10%. All position sizes should be reduced by 50%.", + }) + + # Check per-trade size violations in historical trades + oversized_count = 0 + for trip in closed_trips: + if portfolio_value > 0: + size_pct = trip["cost_basis"] / portfolio_value + if size_pct > RISK_LIMITS["max_position_pct_arbitrage"]: + oversized_count += 1 + + if oversized_count > 0: + violations.append({ + "rule": "position_sizing", + "limit": f"{RISK_LIMITS['max_position_pct'] * 100:.0f}% default", + "actual": f"{oversized_count} oversized trades", + "severity": "MEDIUM", + "detail": f"{oversized_count} trades exceeded the maximum position size cap.", + }) + + return violations + + +# --------------------------------------------------------------------------- +# Live-readiness assessment +# --------------------------------------------------------------------------- + +def assess_live_readiness(core_metrics, sharpe, drawdown): + """Check paper trading record against CLAUDE.md live prerequisites. + + Returns a dict with overall verdict and per-criterion results. + """ + criteria = [] + + # 1. Minimum closed trades + closed_count = core_metrics["total_closed_trades"] + passed_trades = closed_count >= LIVE_MIN_CLOSED_TRADES + criteria.append({ + "criterion": "Closed trades >= 20", + "required": LIVE_MIN_CLOSED_TRADES, + "actual": closed_count, + "passed": passed_trades, + "gap": max(0, LIVE_MIN_CLOSED_TRADES - closed_count) if not passed_trades else 0, + }) + + # 2. Win rate + win_rate = core_metrics["win_rate"] + passed_wr = win_rate >= LIVE_MIN_WIN_RATE + criteria.append({ + "criterion": "Win rate > 55%", + "required": f"{LIVE_MIN_WIN_RATE * 100:.0f}%", + "actual": f"{win_rate * 100:.1f}%", + "passed": passed_wr, + "gap": ( + f"{(LIVE_MIN_WIN_RATE - win_rate) * 100:.1f}pp short" + if not passed_wr else "0" + ), + }) + + # 3. Sharpe ratio + passed_sharpe = sharpe >= LIVE_MIN_SHARPE + criteria.append({ + "criterion": "Sharpe ratio > 0.5", + "required": LIVE_MIN_SHARPE, + "actual": round(sharpe, 4), + "passed": passed_sharpe, + "gap": round(LIVE_MIN_SHARPE - sharpe, 4) if not passed_sharpe else 0, + }) + + # 4. Max drawdown + max_dd = drawdown["max_drawdown_pct"] / 100 # Convert from percentage + passed_dd = max_dd < LIVE_MAX_DRAWDOWN + criteria.append({ + "criterion": "Max drawdown < 15%", + "required": f"{LIVE_MAX_DRAWDOWN * 100:.0f}%", + "actual": f"{max_dd * 100:.1f}%", + "passed": passed_dd, + "gap": ( + f"{(max_dd - LIVE_MAX_DRAWDOWN) * 100:.1f}pp over" + if not passed_dd else "0" + ), + }) + + all_passed = all(c["passed"] for c in criteria) + passed_count = sum(1 for c in criteria if c["passed"]) + + verdict = "READY" if all_passed else "NOT READY" + + # Build specific gap descriptions + gaps = [] + if not passed_trades: + gaps.append( + f"Need {LIVE_MIN_CLOSED_TRADES - closed_count} more closed trades " + f"(have {closed_count}, need {LIVE_MIN_CLOSED_TRADES})" + ) + if not passed_wr: + gaps.append( + f"Win rate {win_rate * 100:.1f}% is below {LIVE_MIN_WIN_RATE * 100:.0f}% " + f"threshold. Review entry criteria and stop-loss discipline." + ) + if not passed_sharpe: + gaps.append( + f"Sharpe ratio {sharpe:.2f} is below {LIVE_MIN_SHARPE} minimum. " + f"Improve consistency of returns or reduce variance." + ) + if not passed_dd: + gaps.append( + f"Max drawdown {max_dd * 100:.1f}% exceeds {LIVE_MAX_DRAWDOWN * 100:.0f}% " + f"limit. Tighten position sizing and stop-loss rules." + ) + + return { + "verdict": verdict, + "criteria_passed": passed_count, + "criteria_total": len(criteria), + "criteria": criteria, + "gaps": gaps, + "recommendation": ( + "Paper trading record meets all CLAUDE.md prerequisites for live " + "trading. Start with First-Time tier: $25 max wallet, $5 max per " + "trade, $10 daily loss limit." + if all_passed else + "Continue paper trading until all criteria are met. " + "Do not go live with unmet prerequisites." + ), + } + + +# --------------------------------------------------------------------------- +# Output formatting +# --------------------------------------------------------------------------- + +def format_human_readable(result): + """Format the full backtest result as human-readable text.""" + lines = [] + + lines.append("=" * 70) + lines.append(" POLYMARKET PAPER TRADING BACKTEST REPORT") + lines.append("=" * 70) + lines.append("") + lines.append(f" Generated: {result['generated_at']}") + lines.append(f" Portfolio: {result['portfolio_name']}") + if result.get("period_days"): + lines.append(f" Period: Last {result['period_days']} days") + else: + lines.append(f" Period: All time") + lines.append("") + + # --- Portfolio Summary --- + ps = result["portfolio_summary"] + lines.append("-" * 70) + lines.append(" PORTFOLIO SUMMARY") + lines.append("-" * 70) + lines.append(f" Starting Balance: ${ps['starting_balance']:>12,.2f}") + lines.append(f" Current Value: ${ps['current_value']:>12,.2f}") + lines.append(f" Cash Balance: ${ps['cash_balance']:>12,.2f}") + lines.append(f" Positions Value: ${ps['positions_value']:>12,.2f}") + lines.append(f" Total Return: {ps['total_return_pct']:>12.2f}%") + lines.append("") + + # --- Core Metrics --- + m = result["core_metrics"] + lines.append("-" * 70) + lines.append(" PERFORMANCE METRICS") + lines.append("-" * 70) + lines.append(f" Total Closed Trades: {m['total_closed_trades']:>8d}") + lines.append(f" Open Positions: {result['open_positions_summary']['num_open']:>8d}") + lines.append(f" Winners: {m['winners']:>8d}") + lines.append(f" Losers: {m['losers']:>8d}") + lines.append(f" Breakeven: {m['breakeven']:>8d}") + lines.append(f" Win Rate: {m['win_rate'] * 100:>8.1f}%") + lines.append(f" Total P&L: ${m['total_pnl']:>12,.2f}") + lines.append(f" Avg P&L/Trade: ${m['avg_pnl']:>12,.2f}") + lines.append(f" Avg Winner: ${m['avg_winner']:>12,.2f}") + lines.append(f" Avg Loser: ${m['avg_loser']:>12,.2f}") + lines.append(f" Largest Winner: ${m['largest_winner']:>12,.2f}") + lines.append(f" Largest Loser: ${m['largest_loser']:>12,.2f}") + pf_str = f"{m['profit_factor']:.4f}" if m['profit_factor'] != float("inf") else "INF" + lines.append(f" Profit Factor: {pf_str:>12s}") + lines.append(f" Total Fees: ${m['total_fees']:>12,.4f}") + lines.append(f" Avg Hold Time: {m['avg_hold_hours']:>8.1f} hours") + lines.append("") + + # --- Risk Metrics --- + dd = result["drawdown"] + lines.append("-" * 70) + lines.append(" RISK METRICS") + lines.append("-" * 70) + lines.append(f" Max Drawdown: {dd['max_drawdown_pct']:>8.2f}% (${dd['max_drawdown_usd']:>,.2f})") + lines.append(f" Current Drawdown: {dd['current_drawdown_pct']:>8.2f}% (${dd['current_drawdown_usd']:>,.2f})") + lines.append(f" Peak Value: ${dd['peak_value']:>12,.2f}") + sharpe = result["sharpe_ratio"] + sharpe_str = f"{sharpe:.4f}" if sharpe != float("inf") else "INF" + lines.append(f" Sharpe Ratio: {sharpe_str:>12s} (annualized, rf=4.5%)") + lines.append("") + + # --- Open Positions --- + ops = result["open_positions_summary"] + if ops["num_open"] > 0: + lines.append("-" * 70) + lines.append(" OPEN POSITIONS (marked to market)") + lines.append("-" * 70) + for p in result.get("open_positions_detail", []): + pnl_sign = "+" if p["unrealized_pnl"] >= 0 else "" + lines.append( + f" {p['side']:>3} {p['shares']:>10.2f} sh @ " + f"${p['entry_price']:.4f} -> ${p['current_price']:.4f} " + f"P&L: {pnl_sign}${p['unrealized_pnl']:,.2f} " + f"({pnl_sign}{p['return_pct']:.1f}%) " + f"[{p['price_source']}]" + ) + q = p.get("market_question", "") + if q: + lines.append(f" {q[:65]}") + lines.append( + f"\n Total Unrealized P&L: ${ops['total_unrealized_pnl']:+,.2f} " + f"Market Value: ${ops['total_market_value']:,.2f}" + ) + if ops["fetch_errors"] > 0: + lines.append( + f" ({ops['fetch_errors']} price fetch errors, using cached prices)" + ) + lines.append("") + + # --- Strategy Breakdown --- + sb = result.get("strategy_breakdown", {}) + if sb: + lines.append("-" * 70) + lines.append(" STRATEGY BREAKDOWN") + lines.append("-" * 70) + header = ( + f" {'Strategy':<18s} {'Trades':>6s} {'Win%':>7s} " + f"{'P&L':>10s} {'Avg P&L':>10s} {'PF':>8s}" + ) + lines.append(header) + lines.append(" " + "-" * 61) + for strategy, sm in sb.items(): + pf_str = f"{sm['profit_factor']:.2f}" if sm['profit_factor'] != float("inf") else "INF" + lines.append( + f" {strategy:<18s} {sm['total_closed_trades']:>6d} " + f"{sm['win_rate'] * 100:>6.1f}% " + f"${sm['total_pnl']:>9,.2f} " + f"${sm['avg_pnl']:>9,.2f} " + f"{pf_str:>8s}" + ) + lines.append("") + + # --- Risk Violations --- + violations = result.get("risk_violations", []) + if violations: + lines.append("-" * 70) + lines.append(" RISK RULE VIOLATIONS") + lines.append("-" * 70) + for v in violations: + lines.append(f" [{v['severity']}] {v['rule']}: {v['detail']}") + lines.append("") + + # --- Live Readiness --- + lr = result["live_readiness"] + lines.append("-" * 70) + verdict_label = lr["verdict"] + lines.append(f" LIVE-READINESS ASSESSMENT: {verdict_label}") + lines.append(f" ({lr['criteria_passed']}/{lr['criteria_total']} criteria met)") + lines.append("-" * 70) + for c in lr["criteria"]: + status = "PASS" if c["passed"] else "FAIL" + lines.append( + f" [{status}] {c['criterion']:<25s} " + f"required: {str(c['required']):<8s} " + f"actual: {str(c['actual']):<10s}" + ) + if lr["gaps"]: + lines.append("") + lines.append(" Gaps to close:") + for g in lr["gaps"]: + lines.append(f" - {g}") + lines.append("") + lines.append(f" {lr['recommendation']}") + lines.append("") + lines.append("=" * 70) + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Main backtest pipeline +# --------------------------------------------------------------------------- + +def run_backtest(db_path, portfolio_name="default", days=None): + """Execute the full backtest pipeline. + + Returns a structured dict with all metrics and assessments. + """ + conn = connect_db(db_path) + if conn is None: + return { + "error": f"Database not found: {db_path}", + "suggestion": ( + "Run paper trades first using the polymarket-paper-trader skill. " + "The database will be created at ~/.polymarket-paper/portfolio.db" + ), + } + + # Get portfolio metadata + pf = get_portfolio_id(conn, portfolio_name) + if pf is None: + conn.close() + return { + "error": f"No active portfolio named '{portfolio_name}'", + "suggestion": ( + "Initialize a portfolio with: python paper_engine.py --action init" + ), + } + + portfolio_id = pf["id"] + starting_balance = pf["starting_balance"] + cash_balance = pf["cash_balance"] + + # Determine date range + since = None + if days is not None: + since = datetime.now(timezone.utc) - timedelta(days=days) + + # Load data + all_trades = get_all_trades(conn, portfolio_id, since) + open_positions = get_open_positions(conn, portfolio_id) + snapshots = get_daily_snapshots(conn, portfolio_id, since) + + conn.close() + + # Pair trades into round trips + closed_trips, open_entries = pair_trades(all_trades) + + # Core metrics on closed trades + core_metrics = compute_core_metrics(closed_trips) + + # Mark open positions to market + marked_positions, open_summary = mark_to_market(open_positions) + + # Current portfolio value + positions_value = open_summary["total_market_value"] + current_value = cash_balance + positions_value + + # Total return + total_return_pct = compute_total_return(starting_balance, current_value) + + # Drawdown + drawdown = compute_drawdown(snapshots, starting_balance) + + # Sharpe ratio + sharpe = compute_sharpe_ratio(snapshots, starting_balance) + + # Strategy breakdown + strategy_breakdown = compute_strategy_breakdown(closed_trips) + + # Risk compliance check + risk_violations = check_risk_compliance( + closed_trips, open_positions, snapshots, + starting_balance, current_value, + ) + + # Live readiness assessment + live_readiness = assess_live_readiness(core_metrics, sharpe, drawdown) + + result = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "portfolio_name": portfolio_name, + "period_days": days, + "portfolio_summary": { + "starting_balance": starting_balance, + "cash_balance": round(cash_balance, 2), + "positions_value": round(positions_value, 2), + "current_value": round(current_value, 2), + "total_return_pct": total_return_pct, + }, + "core_metrics": core_metrics, + "open_positions_summary": open_summary, + "open_positions_detail": marked_positions, + "drawdown": drawdown, + "sharpe_ratio": sharpe, + "strategy_breakdown": strategy_breakdown, + "risk_violations": risk_violations, + "live_readiness": live_readiness, + "trade_counts": { + "total_trades": len(all_trades), + "closed_round_trips": len(closed_trips), + "open_entries": len(open_entries), + "open_positions": len(open_positions), + }, + } + + return result + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description="Backtest Polymarket paper trading strategies", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s + %(prog)s --portfolio-db ~/.polymarket-paper/portfolio.db + %(prog)s --days 7 + %(prog)s --json + %(prog)s --live-check + %(prog)s --portfolio mytest --days 30 --json + """, + ) + parser.add_argument( + "--portfolio-db", type=str, default=DEFAULT_DB_PATH, + help=f"Path to paper trader SQLite database (default: {DEFAULT_DB_PATH})", + ) + parser.add_argument( + "--portfolio", type=str, default="default", + help="Portfolio name to analyze (default: 'default')", + ) + parser.add_argument( + "--days", type=int, default=None, + help="Analyze only the last N days (default: all time)", + ) + parser.add_argument( + "--json", action="store_true", + help="Output as JSON instead of human-readable text", + ) + parser.add_argument( + "--live-check", action="store_true", + help="Only show the live-readiness assessment", + ) + + args = parser.parse_args() + + result = run_backtest( + db_path=args.portfolio_db, + portfolio_name=args.portfolio, + days=args.days, + ) + + # Handle errors + if "error" in result: + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f"ERROR: {result['error']}", file=sys.stderr) + print(f"Suggestion: {result['suggestion']}", file=sys.stderr) + sys.exit(1) + + # Live-check only mode + if args.live_check: + lr = result["live_readiness"] + if args.json: + print(json.dumps(lr, indent=2)) + else: + verdict = lr["verdict"] + print(f"\nLive-Readiness Assessment: {verdict}") + print(f"({lr['criteria_passed']}/{lr['criteria_total']} criteria met)\n") + for c in lr["criteria"]: + status = "PASS" if c["passed"] else "FAIL" + print( + f" [{status}] {c['criterion']:<25s} " + f"required: {str(c['required']):<8s} " + f"actual: {str(c['actual'])}" + ) + if lr["gaps"]: + print("\nGaps:") + for g in lr["gaps"]: + print(f" - {g}") + print(f"\n{lr['recommendation']}") + return + + # Full output + if args.json: + # Convert inf to string for JSON serialization + def sanitize(obj): + if isinstance(obj, float): + if math.isinf(obj): + return "Infinity" if obj > 0 else "-Infinity" + if math.isnan(obj): + return "NaN" + if isinstance(obj, dict): + return {k: sanitize(v) for k, v in obj.items()} + if isinstance(obj, list): + return [sanitize(v) for v in obj] + return obj + + print(json.dumps(sanitize(result), indent=2)) + else: + print(format_human_readable(result)) + + +if __name__ == "__main__": + main()