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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
81f0c9965f
commit
22fcb4355b
+664
@@ -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()
|
||||
Reference in New Issue
Block a user