Files
polymarket-skills/polymarket-paper-trader/scripts/portfolio_report.py
T
Polymarket Skills BuilderandClaude Opus 4.6 068b2adc75 Add 6 Polymarket trading skills with paper trading engine
Composable Agent Skills (SKILL.md format) for Polymarket prediction market
trading. Includes scanner, analyzer, monitor, paper trader, strategy advisor,
and live executor. All tested against live Polymarket APIs. Security audited
with all HIGH/MEDIUM findings resolved.

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

464 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Portfolio Performance Report
Generates detailed analytics for a paper trading portfolio:
- Total and annualized return
- Win rate, Sharpe ratio, Sortino ratio
- Max drawdown, average trade duration
- Best/worst trades
- Output as formatted text or JSON
"""
import argparse
import json
import math
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
import os
_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,
_get_db,
_active_portfolio,
get_portfolio,
)
def generate_report(portfolio_name: str = "default") -> dict:
"""Generate a full performance report for the portfolio."""
conn = _get_db()
try:
pf = _active_portfolio(conn, portfolio_name)
pid = pf["id"]
starting = pf["starting_balance"]
# Get current state with live prices
current = get_portfolio(portfolio_name, refresh_prices=True)
# ----- Trade analysis -----
trades = conn.execute(
"""SELECT * FROM trades WHERE portfolio_id = ?
ORDER BY executed_at ASC""",
(pid,),
).fetchall()
trades = [dict(t) for t in trades]
# Match buys to sells to compute per-trade P&L
closed_trades = _match_trades(trades)
open_positions = current["positions"]
# ----- Daily snapshots -----
snapshots = conn.execute(
"""SELECT * FROM daily_snapshots WHERE portfolio_id = ?
ORDER BY date ASC""",
(pid,),
).fetchall()
snapshots = [dict(s) for s in snapshots]
# ----- Core metrics -----
total_value = current["total_value"]
total_return = (total_value - starting) / starting if starting else 0
# Time-based calculations
created = datetime.fromisoformat(pf["created_at"].replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
days_active = max((now - created).days, 1)
years_active = days_active / 365.25
annualized_return = (
((1 + total_return) ** (1 / years_active) - 1)
if years_active > 0 and total_return > -1 else 0
)
# Win rate
winning = [t for t in closed_trades if t["pnl"] > 0]
losing = [t for t in closed_trades if t["pnl"] <= 0]
win_rate = len(winning) / len(closed_trades) if closed_trades else 0
# Average P&L
avg_win = (
sum(t["pnl"] for t in winning) / len(winning) if winning else 0
)
avg_loss = (
sum(t["pnl"] for t in losing) / len(losing) if losing else 0
)
# Profit factor
gross_profit = sum(t["pnl"] for t in winning)
gross_loss = abs(sum(t["pnl"] for t in losing))
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf")
# ----- Drawdown from snapshots -----
equity_curve = [starting]
if snapshots:
equity_curve = [s["total_value"] for s in snapshots]
max_drawdown, max_dd_duration = _compute_drawdown(equity_curve)
# ----- Sharpe & Sortino from daily returns -----
daily_returns = _daily_returns(snapshots, starting)
sharpe = _sharpe_ratio(daily_returns)
sortino = _sortino_ratio(daily_returns)
# ----- Average trade duration -----
durations = []
for ct in closed_trades:
if ct.get("open_time") and ct.get("close_time"):
try:
t_open = datetime.fromisoformat(
ct["open_time"].replace("Z", "+00:00")
)
t_close = datetime.fromisoformat(
ct["close_time"].replace("Z", "+00:00")
)
durations.append((t_close - t_open).total_seconds() / 3600)
except (ValueError, TypeError):
pass
avg_duration_hours = (
sum(durations) / len(durations) if durations else 0
)
# ----- Best / Worst trades -----
sorted_by_pnl = sorted(closed_trades, key=lambda t: t["pnl"], reverse=True)
best_trades = sorted_by_pnl[:3] if sorted_by_pnl else []
worst_trades = sorted_by_pnl[-3:][::-1] if sorted_by_pnl else []
# ----- Fees -----
total_fees = sum(t.get("fee", 0) for t in trades)
report = {
"portfolio_name": portfolio_name,
"generated_at": now.isoformat(),
"days_active": days_active,
"summary": {
"starting_balance": starting,
"current_value": total_value,
"cash_balance": current["cash_balance"],
"positions_value": current["positions_value"],
"total_return_usd": round(total_value - starting, 2),
"total_return_pct": round(total_return * 100, 2),
"annualized_return_pct": round(annualized_return * 100, 2),
},
"risk_metrics": {
"sharpe_ratio": round(sharpe, 3),
"sortino_ratio": round(sortino, 3),
"max_drawdown_pct": round(max_drawdown * 100, 2),
"max_drawdown_duration_days": max_dd_duration,
"current_drawdown_pct": current["drawdown_pct"],
},
"trade_metrics": {
"total_trades": len(trades),
"closed_trades": len(closed_trades),
"open_positions": len(open_positions),
"win_rate_pct": round(win_rate * 100, 1),
"avg_win_usd": round(avg_win, 2),
"avg_loss_usd": round(avg_loss, 2),
"profit_factor": round(profit_factor, 2),
"total_fees_usd": round(total_fees, 2),
"avg_trade_duration_hours": round(avg_duration_hours, 1),
},
"best_trades": [
_trade_summary(t) for t in best_trades
],
"worst_trades": [
_trade_summary(t) for t in worst_trades
],
"open_positions": [
{
"market": p["market_question"],
"side": p["side"],
"shares": p["shares"],
"entry": p["avg_entry"],
"current": p["current_price"],
"unrealized_pnl": p["unrealized_pnl"],
}
for p in open_positions
],
}
return report
finally:
conn.close()
# ---------------------------------------------------------------------------
# Analytics helpers
# ---------------------------------------------------------------------------
def _match_trades(trades: list[dict]) -> list[dict]:
"""
Match BUY and SELL trades on the same token/side to compute
per-round-trip P&L.
"""
# Group buys by (token_id, side)
open_lots: dict[tuple, list] = {}
closed: list[dict] = []
for t in trades:
key = (t["token_id"], t["side"])
if t["action"] == "BUY":
if key not in open_lots:
open_lots[key] = []
open_lots[key].append({
"shares": t["shares"],
"price": t["price"],
"fee": t["fee"],
"time": t["executed_at"],
"market": t.get("market_question", ""),
"reasoning": t.get("reasoning", ""),
})
elif t["action"] == "SELL":
lots = open_lots.get(key, [])
remaining = t["shares"]
sell_price = t["price"]
sell_fee = t["fee"]
sell_time = t["executed_at"]
while remaining > 0.0001 and lots:
lot = lots[0]
matched = min(remaining, lot["shares"])
pnl = (sell_price - lot["price"]) * matched - (
lot["fee"] * (matched / lot["shares"]) if lot["shares"] > 0 else 0
) - (
sell_fee * (matched / t["shares"]) if t["shares"] > 0 else 0
)
closed.append({
"token_id": t["token_id"],
"side": t["side"],
"market": lot["market"],
"shares": round(matched, 4),
"entry_price": lot["price"],
"exit_price": sell_price,
"pnl": round(pnl, 4),
"pnl_pct": round(
(sell_price - lot["price"]) / lot["price"] * 100, 2
) if lot["price"] > 0 else 0,
"open_time": lot["time"],
"close_time": sell_time,
"reasoning": lot["reasoning"],
})
lot["shares"] -= matched
remaining -= matched
if lot["shares"] < 0.0001:
lots.pop(0)
return closed
def _compute_drawdown(equity_curve: list[float]) -> tuple[float, int]:
"""Compute max drawdown and its duration in days."""
if not equity_curve or len(equity_curve) < 2:
return 0.0, 0
peak = equity_curve[0]
max_dd = 0.0
dd_start = 0
max_dd_duration = 0
current_dd_start = 0
for i, value in enumerate(equity_curve):
if value >= peak:
peak = value
duration = i - current_dd_start
max_dd_duration = max(max_dd_duration, duration)
current_dd_start = i
else:
dd = (peak - value) / peak
if dd > max_dd:
max_dd = dd
dd_start = current_dd_start
# Check if still in drawdown
if equity_curve[-1] < peak:
duration = len(equity_curve) - 1 - current_dd_start
max_dd_duration = max(max_dd_duration, duration)
return max_dd, max_dd_duration
def _daily_returns(
snapshots: list[dict],
starting_balance: float,
) -> list[float]:
"""Extract daily return series from snapshots."""
if not snapshots:
return []
values = [starting_balance] + [s["total_value"] for s in snapshots]
returns = []
for i in range(1, len(values)):
if values[i - 1] > 0:
returns.append((values[i] - values[i - 1]) / values[i - 1])
return returns
def _sharpe_ratio(
daily_returns: list[float],
risk_free_daily: float = 0.0001, # ~3.7% annual
) -> float:
"""Annualized Sharpe ratio from daily returns."""
if len(daily_returns) < 2:
return 0.0
excess = [r - risk_free_daily for r in daily_returns]
mean_excess = sum(excess) / len(excess)
variance = sum((r - mean_excess) ** 2 for r in excess) / (len(excess) - 1)
std = math.sqrt(variance) if variance > 0 else 0
if std == 0:
return 0.0
return (mean_excess / std) * math.sqrt(252)
def _sortino_ratio(
daily_returns: list[float],
risk_free_daily: float = 0.0001,
) -> float:
"""Annualized Sortino ratio (uses downside deviation only)."""
if len(daily_returns) < 2:
return 0.0
excess = [r - risk_free_daily for r in daily_returns]
mean_excess = sum(excess) / len(excess)
downside = [min(0, r) ** 2 for r in excess]
downside_dev = math.sqrt(sum(downside) / len(downside)) if downside else 0
if downside_dev == 0:
return 0.0
return (mean_excess / downside_dev) * math.sqrt(252)
def _trade_summary(trade: dict) -> dict:
"""Compact summary of a closed trade for reporting."""
return {
"market": trade.get("market", "")[:70],
"side": trade["side"],
"shares": trade["shares"],
"entry": trade["entry_price"],
"exit": trade["exit_price"],
"pnl_usd": trade["pnl"],
"pnl_pct": trade["pnl_pct"],
"duration": trade.get("close_time", ""),
}
# ---------------------------------------------------------------------------
# Text formatting
# ---------------------------------------------------------------------------
def format_report(report: dict) -> str:
"""Format the report as human-readable text."""
s = report["summary"]
r = report["risk_metrics"]
t = report["trade_metrics"]
lines = [
"=" * 60,
f" PORTFOLIO REPORT: {report['portfolio_name']}",
f" Generated: {report['generated_at'][:19]}",
f" Active for: {report['days_active']} days",
"=" * 60,
"",
"--- Performance Summary ---",
f" Starting Balance: ${s['starting_balance']:>12,.2f}",
f" Current Value: ${s['current_value']:>12,.2f}",
f" Total Return: ${s['total_return_usd']:>12,.2f} "
f"({s['total_return_pct']:+.2f}%)",
f" Annualized Return: {s['annualized_return_pct']:>12.2f}%",
"",
"--- Risk Metrics ---",
f" Sharpe Ratio: {r['sharpe_ratio']:>12.3f}",
f" Sortino Ratio: {r['sortino_ratio']:>12.3f}",
f" Max Drawdown: {r['max_drawdown_pct']:>12.2f}%",
f" Max DD Duration: {r['max_drawdown_duration_days']:>12d} days",
f" Current Drawdown: {r['current_drawdown_pct']:>12.2f}%",
"",
"--- Trade Metrics ---",
f" Total Trades: {t['total_trades']:>12d}",
f" Closed Trades: {t['closed_trades']:>12d}",
f" Open Positions: {t['open_positions']:>12d}",
f" Win Rate: {t['win_rate_pct']:>12.1f}%",
f" Avg Win: ${t['avg_win_usd']:>12,.2f}",
f" Avg Loss: ${t['avg_loss_usd']:>12,.2f}",
f" Profit Factor: {t['profit_factor']:>12.2f}",
f" Total Fees: ${t['total_fees_usd']:>12,.2f}",
f" Avg Trade Duration: {t['avg_trade_duration_hours']:>12.1f} hours",
]
if report["best_trades"]:
lines += ["", "--- Best Trades ---"]
for i, bt in enumerate(report["best_trades"], 1):
lines.append(
f" {i}. {bt['side']} {bt['shares']:.1f}sh "
f"${bt['entry']:.4f}->${bt['exit']:.4f} "
f"P&L: ${bt['pnl_usd']:+,.2f} ({bt['pnl_pct']:+.1f}%)"
)
if bt.get("market"):
lines.append(f" {bt['market']}")
if report["worst_trades"]:
lines += ["", "--- Worst Trades ---"]
for i, wt in enumerate(report["worst_trades"], 1):
lines.append(
f" {i}. {wt['side']} {wt['shares']:.1f}sh "
f"${wt['entry']:.4f}->${wt['exit']:.4f} "
f"P&L: ${wt['pnl_usd']:+,.2f} ({wt['pnl_pct']:+.1f}%)"
)
if wt.get("market"):
lines.append(f" {wt['market']}")
if report["open_positions"]:
lines += ["", "--- Open Positions ---"]
for p in report["open_positions"]:
lines.append(
f" {p['side']} {p['shares']:.1f}sh "
f"@ ${p['entry']:.4f} -> ${p['current']:.4f} "
f"P&L: ${p['unrealized_pnl']:+,.2f}"
)
if p.get("market"):
lines.append(f" {p['market']}")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Generate portfolio performance report",
)
parser.add_argument("--portfolio", default="default", help="Portfolio name")
parser.add_argument("--json", action="store_true", help="JSON output")
args = parser.parse_args()
try:
report = generate_report(args.portfolio)
if args.json:
print(json.dumps(report, indent=2))
else:
print(format_report(report))
except RuntimeError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()