Files
polymarket-skills/polymarket-paper-trader/scripts/health_check.py
T
Polymarket Skills BuilderandClaude Opus 4.6 22fcb4355b Add health check, backtest, correlation tracker, wallet setup, and skills registry
New scripts:
- health_check.py: one-command session-start workflow (portfolio + live prices +
  drawdown + stop losses → GREEN/YELLOW/RED status)
- backtest.py: performance analysis with live-readiness assessment against
  CLAUDE.md prerequisites (20+ trades, >55% win rate, Sharpe >0.5)
- correlation_tracker.py: detects hidden correlated exposure in portfolio
  (e.g., 3 insider-trading bets = one cluster)
- setup_wallet.py: burner wallet creation, env var verification, on-chain
  balance check for live trading setup

Also adds:
- .env.example template for live trading configuration
- .well-known/skills/index.json for Agent Skills registry discovery
- Updated SKILL.md files documenting new scripts
- .gitignore entries for .env, .polymarket-live/, and key files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:05:24 +00:00

665 lines
24 KiB
Python
Executable File

#!/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()