Files
Polymarket Skills Builder 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

1011 lines
35 KiB
Python

#!/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"(?<!\. )(?<!\A)([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)", question):
entity = match.group(1).lower()
if entity not in _STOP_WORDS:
entities.add(entity)
# Also capture single capitalized words that appear mid-sentence
# (likely proper nouns)
words = question.split()
for i, word in enumerate(words):
clean = re.sub(r"[^a-zA-Z]", "", word)
if (
len(clean) >= 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()