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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6a03bbe2e5
commit
068b2adc75
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Paper Trade Executor
|
||||
|
||||
Higher-level wrapper around paper_engine that takes structured trade
|
||||
recommendations (e.g., from a strategy advisor) and executes them as
|
||||
paper trades with full validation.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Import from paper_engine (same directory)
|
||||
_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 (
|
||||
get_portfolio,
|
||||
place_order,
|
||||
close_position,
|
||||
fetch_midpoint,
|
||||
DEFAULT_FEE_RATE,
|
||||
)
|
||||
|
||||
|
||||
def execute_recommendation(
|
||||
recommendation: dict,
|
||||
portfolio_name: str = "default",
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
Execute a trade recommendation from a strategy advisor.
|
||||
|
||||
Expected recommendation format:
|
||||
{
|
||||
"token_id": "...",
|
||||
"side": "YES" or "NO",
|
||||
"action": "BUY" or "SELL" or "CLOSE",
|
||||
"size_usd": 50.0, # USD amount (for BUY)
|
||||
"size_pct": 0.05, # OR as % of portfolio (alternative to size_usd)
|
||||
"price": 0.45, # optional limit price
|
||||
"confidence": 0.75, # strategy confidence 0-1
|
||||
"reasoning": "...", # why this trade
|
||||
"strategy": "momentum", # which strategy generated this
|
||||
"fee_rate": 0.0, # optional fee override
|
||||
}
|
||||
|
||||
Returns execution result dict.
|
||||
"""
|
||||
token_id = recommendation.get("token_id")
|
||||
if not token_id:
|
||||
return {"status": "rejected", "reason": "Missing token_id"}
|
||||
|
||||
action = recommendation.get("action", "BUY").upper()
|
||||
side = recommendation.get("side", "YES").upper()
|
||||
confidence = recommendation.get("confidence", 0.5)
|
||||
reasoning = recommendation.get("reasoning", "")
|
||||
strategy = recommendation.get("strategy", "unknown")
|
||||
fee_rate = recommendation.get("fee_rate", DEFAULT_FEE_RATE)
|
||||
price = recommendation.get("price")
|
||||
|
||||
# Build reasoning string
|
||||
full_reasoning = f"[{strategy}] (conf={confidence:.0%}) {reasoning}"
|
||||
|
||||
# Get current portfolio state
|
||||
try:
|
||||
portfolio = get_portfolio(portfolio_name, refresh_prices=True)
|
||||
except RuntimeError as exc:
|
||||
return {"status": "rejected", "reason": str(exc)}
|
||||
|
||||
# Confidence gate
|
||||
min_confidence = 0.5
|
||||
if confidence < min_confidence:
|
||||
return {
|
||||
"status": "rejected",
|
||||
"reason": f"Confidence {confidence:.0%} below minimum {min_confidence:.0%}",
|
||||
"recommendation": recommendation,
|
||||
}
|
||||
|
||||
# Handle CLOSE action
|
||||
if action == "CLOSE":
|
||||
if dry_run:
|
||||
return {
|
||||
"status": "dry_run",
|
||||
"action": "CLOSE",
|
||||
"token_id": token_id,
|
||||
"side": side,
|
||||
"portfolio": _summary(portfolio),
|
||||
}
|
||||
try:
|
||||
result = close_position(
|
||||
token_id=token_id,
|
||||
side=side if side in ("YES", "NO") else None,
|
||||
portfolio_name=portfolio_name,
|
||||
fee_rate=fee_rate,
|
||||
reasoning=full_reasoning,
|
||||
)
|
||||
return {
|
||||
"status": "executed",
|
||||
"action": "CLOSE",
|
||||
"result": result,
|
||||
"portfolio": _summary(
|
||||
get_portfolio(portfolio_name, refresh_prices=False)
|
||||
),
|
||||
}
|
||||
except RuntimeError as exc:
|
||||
return {"status": "rejected", "reason": str(exc)}
|
||||
|
||||
# Determine size in USD
|
||||
size_usd = recommendation.get("size_usd")
|
||||
size_pct = recommendation.get("size_pct")
|
||||
|
||||
if size_usd is None and size_pct is not None:
|
||||
size_usd = portfolio["total_value"] * size_pct
|
||||
elif size_usd is None:
|
||||
# Default: Kelly-inspired sizing based on confidence
|
||||
# Half-Kelly: f = (2p - 1) where p = confidence, then halved
|
||||
kelly_fraction = max(0, (2 * confidence - 1)) * 0.5
|
||||
# Cap at 10% of portfolio
|
||||
kelly_fraction = min(kelly_fraction, 0.10)
|
||||
size_usd = portfolio["total_value"] * kelly_fraction
|
||||
|
||||
if size_usd <= 0:
|
||||
return {
|
||||
"status": "rejected",
|
||||
"reason": "Calculated trade size is zero (confidence too low for Kelly sizing)",
|
||||
}
|
||||
|
||||
# Round to 2 decimal places
|
||||
size_usd = round(size_usd, 2)
|
||||
|
||||
# Get current market price for context
|
||||
try:
|
||||
current_price = fetch_midpoint(token_id)
|
||||
except Exception:
|
||||
current_price = None
|
||||
|
||||
if dry_run:
|
||||
return {
|
||||
"status": "dry_run",
|
||||
"action": action,
|
||||
"side": side,
|
||||
"token_id": token_id,
|
||||
"size_usd": size_usd,
|
||||
"limit_price": price,
|
||||
"current_price": current_price,
|
||||
"confidence": confidence,
|
||||
"strategy": strategy,
|
||||
"reasoning": full_reasoning,
|
||||
"portfolio": _summary(portfolio),
|
||||
}
|
||||
|
||||
# Execute the trade
|
||||
try:
|
||||
result = place_order(
|
||||
token_id=token_id,
|
||||
side=side,
|
||||
size=size_usd,
|
||||
price=price,
|
||||
reasoning=full_reasoning,
|
||||
portfolio_name=portfolio_name,
|
||||
fee_rate=fee_rate,
|
||||
)
|
||||
# Get updated portfolio
|
||||
updated = get_portfolio(portfolio_name, refresh_prices=False)
|
||||
return {
|
||||
"status": "executed",
|
||||
"action": action,
|
||||
"result": result,
|
||||
"portfolio": _summary(updated),
|
||||
}
|
||||
except RuntimeError as exc:
|
||||
return {
|
||||
"status": "rejected",
|
||||
"reason": str(exc),
|
||||
"attempted": {
|
||||
"token_id": token_id,
|
||||
"side": side,
|
||||
"size_usd": size_usd,
|
||||
"price": price,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _summary(portfolio: dict) -> dict:
|
||||
"""Compact portfolio summary for trade results."""
|
||||
return {
|
||||
"total_value": portfolio["total_value"],
|
||||
"cash_balance": portfolio["cash_balance"],
|
||||
"pnl": portfolio["pnl"],
|
||||
"pnl_pct": portfolio["pnl_pct"],
|
||||
"num_positions": portfolio["num_open_positions"],
|
||||
}
|
||||
|
||||
|
||||
def execute_batch(
|
||||
recommendations: list[dict],
|
||||
portfolio_name: str = "default",
|
||||
dry_run: bool = False,
|
||||
) -> list[dict]:
|
||||
"""Execute a batch of recommendations sequentially."""
|
||||
results = []
|
||||
for rec in recommendations:
|
||||
result = execute_recommendation(rec, portfolio_name, dry_run)
|
||||
results.append(result)
|
||||
# Stop on risk limit errors
|
||||
if result["status"] == "rejected" and "drawdown" in result.get("reason", ""):
|
||||
for remaining in recommendations[len(results):]:
|
||||
results.append({
|
||||
"status": "skipped",
|
||||
"reason": "Trading halted due to drawdown limit",
|
||||
"recommendation": remaining,
|
||||
})
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Execute paper trade recommendations",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Execute a single recommendation from JSON string
|
||||
%(prog)s --recommendation '{"token_id":"ABC","side":"YES","size_usd":50,"confidence":0.8}'
|
||||
|
||||
# Execute from a JSON file
|
||||
%(prog)s --file recommendations.json
|
||||
|
||||
# Dry run (no actual trades)
|
||||
%(prog)s --recommendation '{"token_id":"ABC","side":"YES","size_usd":50}' --dry-run
|
||||
""",
|
||||
)
|
||||
parser.add_argument("--recommendation", help="JSON trade recommendation")
|
||||
parser.add_argument("--file", help="JSON file with recommendation(s)")
|
||||
parser.add_argument("--portfolio", default="default", help="Portfolio name")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Validate without executing")
|
||||
parser.add_argument("--json", action="store_true", help="JSON output")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.recommendation and not args.file:
|
||||
parser.error("Provide --recommendation or --file")
|
||||
|
||||
try:
|
||||
if args.file:
|
||||
with open(args.file) as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
results = execute_batch(data, args.portfolio, args.dry_run)
|
||||
else:
|
||||
results = [execute_recommendation(data, args.portfolio, args.dry_run)]
|
||||
else:
|
||||
rec = json.loads(args.recommendation)
|
||||
if isinstance(rec, list):
|
||||
results = execute_batch(rec, args.portfolio, args.dry_run)
|
||||
else:
|
||||
results = [execute_recommendation(rec, args.portfolio, args.dry_run)]
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(results if len(results) > 1 else results[0], indent=2))
|
||||
else:
|
||||
for r in results:
|
||||
status = r["status"].upper()
|
||||
if r["status"] == "executed":
|
||||
res = r["result"]
|
||||
if isinstance(res, dict):
|
||||
print(
|
||||
f"[{status}] {res.get('action','?')} {res.get('side','?')} "
|
||||
f"{res.get('shares', 0):.2f} shares @ "
|
||||
f"${res.get('avg_price', res.get('avg_sell_price', 0)):.4f}"
|
||||
)
|
||||
else:
|
||||
print(f"[{status}] {json.dumps(res)}")
|
||||
pf = r.get("portfolio", {})
|
||||
print(
|
||||
f" Portfolio: ${pf.get('total_value', 0):,.2f} "
|
||||
f"({pf.get('pnl_pct', 0):+.2f}%)"
|
||||
)
|
||||
elif r["status"] == "dry_run":
|
||||
print(
|
||||
f"[DRY RUN] Would {r.get('action','?')} "
|
||||
f"{r.get('side','?')} ${r.get('size_usd', 0):.2f} "
|
||||
f"(price: {r.get('current_price', '?')})"
|
||||
)
|
||||
else:
|
||||
print(f"[{status}] {r.get('reason', 'Unknown error')}")
|
||||
|
||||
except (json.JSONDecodeError, FileNotFoundError) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+1075
File diff suppressed because it is too large
Load Diff
+463
@@ -0,0 +1,463 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user