
Risk Management
- 288 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
risk-management is a Claude Code skill that applies portfolio-level drawdown limits, exposure caps, and circuit breakers to crypto trading.
About
risk-management is a Claude Code skill that applies portfolio-level risk controls to crypto trading. It defines maximum drawdown, daily/weekly loss limits, concentration and exposure caps, correlation handling, and time-, loss-, and volatility-based circuit breakers, plus VaR. A developer uses it to protect an account from ruin and manage drawdowns across positions and strategies.
- Drawdown limits, daily/weekly loss limits, and concentration/exposure caps by token type
- Time-, loss-, and volatility-based circuit breakers to halt trading automatically
- Ships drawdown_analyzer.py and risk_dashboard.py with limit checking and status
Risk Management by the numbers
- 288 all-time installs (skills.sh)
- Ranked #338 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
risk-management capabilities & compatibility
Free; runs in Python/numpy with no API keys.
- Capabilities
- risk management · drawdown analysis · exposure limits · circuit breakers · value at risk
- Use cases
- data analysis
- Pricing
- Free
What risk-management says it does
Portfolio-level risk controls for crypto and Solana trading. This skill provides frameworks for drawdown management, exposure limits, circuit breakers, and crypto-specific risk considerations.
Violating this hierarchy (chasing growth at the expense of survival) is the primary cause of account blowups.
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill risk-managementAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 288 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Apply portfolio-level risk controls, drawdown limits, exposure caps, and circuit breakers to crypto trading.
Who is it for?
Enforcing account-level risk limits and drawdown response rules across a crypto portfolio.
Skip if: Sizing individual trades (see position-sizing) or generating signals.
When should I use this skill?
You need portfolio drawdown, exposure, concentration, or circuit-breaker rules for a crypto trading account.
What you get
A set of enforced risk limits and a dashboard showing exposure, concentration, and drawdown status.
- Risk limit configuration
- Drawdown response recommendations
- Portfolio risk dashboard
By the numbers
- Drawdown limits -15% to -25% by account type
- Recovery table (-20% loss needs +25% gain)
- 3 circuit-breaker classes (time, loss, volatility)
Files
Risk Management
Portfolio-level risk controls for crypto and Solana trading. This skill provides frameworks for drawdown management, exposure limits, circuit breakers, and crypto-specific risk considerations.
Risk Management Hierarchy
Every decision must respect this priority order:
1. Survival — Never risk account ruin. No single trade, day, or week should threaten your ability to continue trading. 2. Capital preservation — Protect what you have. Losses compound geometrically; recovery requires outsized gains. 3. Growth — Only after survival and preservation are secured, pursue returns.
Violating this hierarchy (chasing growth at the expense of survival) is the primary cause of account blowups.
Portfolio-Level Controls
1. Maximum Drawdown Limits
Halt trading when portfolio drawdown from equity peak reaches a threshold:
| Account Type | Max Drawdown | Action |
|---|---|---|
| Conservative | -15% | Full stop, review all strategies |
| Moderate | -20% | Full stop, reduce to minimum size on recovery |
| Aggressive | -25% | Full stop, mandatory cooling period |
Recovery math makes this critical: a -20% drawdown requires +25% to recover. A -50% drawdown requires +100%. See references/drawdown_management.md for the full recovery table.
2. Daily Loss Limits
Stop opening new positions after daily P&L (realized + unrealized) hits:
- Conservative: -3% of account
- Moderate: -4% of account
- Aggressive: -5% of account
Reset at midnight UTC. Three consecutive days hitting the daily limit triggers a weekly halt.
3. Weekly Loss Limits
Reduce size or halt after weekly P&L reaches:
- Reduce size by 50%: -5% weekly loss
- Minimum size only: -7% weekly loss
- Full halt: -10% weekly loss
4. Concentration Limits
Maximum allocation to any single dimension:
| Dimension | Max Concentration |
|---|---|
| Single token (blue chip) | 10% of account |
| Single token (mid-cap) | 5% |
| Single token (small-cap) | 2% |
| Single token (PumpFun/micro) | 0.5% |
| Single sector/narrative | 30% |
| Single strategy | 40% |
5. Exposure Limits
Total deployed capital constraints:
- Normal conditions: 50–80% deployed, 20–50% cash reserve
- Elevated risk: 30–50% deployed
- Drawdown >10%: 20–30% deployed
- Max concurrent positions: 5–10 depending on account size
6. Correlation Management
Crypto assets correlate >0.7 during sell-offs. Effective diversification requires:
- Treat all meme tokens as a single correlated bucket
- Limit total meme exposure to one position-size equivalent
- Diversify across strategies (trend, mean-reversion, scalp), not just tokens
- Monitor rolling correlation and reduce when correlations spike
See references/exposure_limits.md for detailed limits by token type and strategy.
Drawdown Management
Response Framework
| Drawdown | Status | Response |
|---|---|---|
| 0–5% | Normal | Continue trading at full size |
| 5–10% | Caution | Reduce position sizes by 25–50% |
| 10–15% | Warning | Minimum position sizes only |
| 15–20% | Critical | Halt new trades, manage existing positions only |
| >20% | Emergency | Full stop, review everything before resuming |
Recovery Requirements
| Loss | Required Gain to Recover |
|---|---|
| -5% | +5.3% |
| -10% | +11.1% |
| -15% | +17.6% |
| -20% | +25.0% |
| -30% | +42.9% |
| -40% | +66.7% |
| -50% | +100.0% |
The asymmetry accelerates rapidly. Managing small drawdowns prevents them from becoming catastrophic. See references/drawdown_management.md for the full framework.
Circuit Breakers
Automated controls that restrict trading when conditions are met:
Time-Based
- No trading for 24 hours after hitting daily loss limit
- 48-hour cooling period after weekly loss limit
- Mandatory weekly review day (no new positions)
Loss-Based
- 3 consecutive losses → reduce size 50%
- 5 consecutive losses → minimum size only
- 7 consecutive losses → halt 24 hours, full review
Volatility-Based
- Portfolio volatility >2× rolling average → reduce exposure 50%
- Market-wide liquidation events → pause all new entries
- Individual token volatility spike → exit or tighten stops
Emotional (Self-Assessed)
- Recognize tilt: anger after losses, urge to "make it back"
- FOMO: rushing entries without proper analysis
- Overconfidence: increasing size after a win streak without justification
See references/circuit_breakers.md for implementation details.
Risk Metrics
Value at Risk (VaR)
95th-percentile daily loss estimate using historical returns:
import numpy as np
def historical_var(returns: list[float], confidence: float = 0.95) -> float:
"""Calculate historical VaR at given confidence level."""
sorted_returns = sorted(returns)
index = int((1 - confidence) * len(sorted_returns))
return abs(sorted_returns[index])
# Example: 95% VaR of 3.2% means on 95% of days, loss won't exceed 3.2%Expected Shortfall (CVaR)
Average loss in the worst (1 - confidence)% of scenarios:
def expected_shortfall(returns: list[float], confidence: float = 0.95) -> float:
"""Average loss beyond VaR threshold."""
sorted_returns = sorted(returns)
index = int((1 - confidence) * len(sorted_returns))
tail = sorted_returns[:index]
return abs(sum(tail) / len(tail)) if tail else 0.0Maximum Drawdown
def max_drawdown(equity_curve: list[float]) -> float:
"""Peak-to-trough decline as a fraction."""
peak = equity_curve[0]
max_dd = 0.0
for value in equity_curve:
peak = max(peak, value)
dd = (peak - value) / peak
max_dd = max(max_dd, dd)
return max_ddAdditional Metrics
- Win/loss streak tracking: Detect hot/cold streaks for circuit breaker logic
- Rolling Sharpe ratio: 30-day rolling risk-adjusted returns
- Calmar ratio: Annualized return / max drawdown
- Sortino ratio: Return / downside deviation (penalizes only negative volatility)
Crypto-Specific Risks
Smart Contract Risk
- Never allocate >5% of account to a single unaudited protocol
- Diversify across audited protocols for yield strategies
- Monitor exploit databases and social channels for emerging threats
Rug Pull Risk
- Size inversely with token age: newer tokens get smaller positions
- Verify: locked liquidity, renounced mint authority, holder distribution
- Cross-reference with
token-holder-analysisskill for red flags
Bridge and Custody Risk
- Don't hold >20% on any single platform or bridge
- Self-custody the majority of trading capital
- Budget for bridge fees and delays in execution planning
MEV and Execution Risk
- Budget 1–3% for MEV/slippage on Solana DEX trades
- Use priority fees during congestion
- See
slippage-modelingskill for detailed cost estimation
Correlation Spikes
- In crashes, crypto correlations approach 1.0
- Your "diversified" portfolio may behave as one position
- Stress-test portfolio assuming all positions drop simultaneously
PumpFun Risk Framework
PumpFun and similar meme token platforms require a distinct risk approach:
Core Principle
Treat every PumpFun trade as a potential 100% loss. Size accordingly.
Position Limits
- Per-token maximum: 0.1–0.5 SOL
- Daily PumpFun budget: Fixed allocation (e.g., 2 SOL/day)
- Never exceed budget: When daily allocation is gone, stop
Tracking
- Track PumpFun P&L separately from main portfolio
- Calculate PumpFun win rate and expectancy independently
- Don't let PumpFun losses affect main portfolio risk limits
Risk Adjustments
- No stop-losses on PumpFun (assume 100% loss at entry)
- Take profits aggressively: 2×, 3×, 5× partial exits
- Time-based exit: close within hours, not days
Integration with Other Skills
- `position-sizing`: Use risk limits from this skill to constrain position sizes
- `exit-strategies`: Circuit breakers override exit strategies (forced exits)
- `portfolio-analytics`: Feed portfolio metrics back for risk assessment
- `liquidity-analysis`: Adjust position limits based on available liquidity
- `slippage-modeling`: Factor execution costs into risk calculations
Files
References
references/drawdown_management.md— Drawdown math, response framework, causes, and remediationreferences/exposure_limits.md— Position limits by token type, portfolio limits, correlation managementreferences/circuit_breakers.md— Implementation details for all circuit breaker types
Scripts
scripts/risk_dashboard.py— Portfolio risk dashboard with limit checking and color-coded statusscripts/drawdown_analyzer.py— Equity curve drawdown analysis with response recommendations
Quick Start
# Run the risk dashboard with demo data
python scripts/risk_dashboard.py --demo
# Analyze drawdowns on a demo equity curve
python scripts/drawdown_analyzer.py --demoCircuit Breakers
Automated controls that restrict or halt trading activity when predefined conditions are met. Circuit breakers protect against compounding losses, emotional decisions, and system failures.
Daily Loss Limit
Implementation
from datetime import datetime, timezone
class DailyLossBreaker:
"""Track daily P&L and halt trading when limit is hit."""
def __init__(self, account_size: float, limit_pct: float = 0.03):
self.account_size = account_size
self.limit_pct = limit_pct
self.limit_amount = account_size * limit_pct
self.realized_pnl = 0.0
self.unrealized_pnl = 0.0
self.reset_date = datetime.now(timezone.utc).date()
self.consecutive_limit_days = 0
def update(self, realized: float, unrealized: float) -> None:
today = datetime.now(timezone.utc).date()
if today != self.reset_date:
if self.is_triggered():
self.consecutive_limit_days += 1
else:
self.consecutive_limit_days = 0
self.realized_pnl = 0.0
self.unrealized_pnl = 0.0
self.reset_date = today
self.realized_pnl = realized
self.unrealized_pnl = unrealized
def total_pnl(self) -> float:
return self.realized_pnl + self.unrealized_pnl
def is_triggered(self) -> bool:
return self.total_pnl() <= -self.limit_amount
def needs_weekly_halt(self) -> bool:
return self.consecutive_limit_days >= 3Rules
| Rule | Detail |
|---|---|
| Tracking | Realized P&L + unrealized P&L combined |
| Trigger | Stop opening new positions when limit hit |
| Existing positions | May be managed (tighten stops) but not added to |
| Reset | Midnight UTC |
| Escalation | 3 consecutive days hitting limit → halt for remainder of week |
Recommended Limits
| Profile | Daily Limit | Weekly Escalation |
|---|---|---|
| Conservative | -3% | 2 consecutive days |
| Moderate | -4% | 3 consecutive days |
| Aggressive | -5% | 3 consecutive days |
Consecutive Loss Breaker
Implementation
class ConsecutiveLossBreaker:
"""Track consecutive losses and enforce size/halt rules."""
def __init__(self):
self.consecutive_losses = 0
self.consecutive_wins = 0
def record_trade(self, pnl: float) -> None:
if pnl < 0:
self.consecutive_losses += 1
self.consecutive_wins = 0
elif pnl > 0:
self.consecutive_wins += 1
if self.consecutive_wins >= 2:
self.consecutive_losses = 0
def size_multiplier(self) -> float:
if self.consecutive_losses >= 7:
return 0.0 # Halt
elif self.consecutive_losses >= 5:
return 0.25 # Minimum size
elif self.consecutive_losses >= 3:
return 0.50 # Half size
return 1.0 # Full size
def status(self) -> str:
if self.consecutive_losses >= 7:
return "HALT — 7+ consecutive losses, 24h mandatory break"
elif self.consecutive_losses >= 5:
return "MINIMUM SIZE — 5+ consecutive losses"
elif self.consecutive_losses >= 3:
return "HALF SIZE — 3+ consecutive losses"
return "NORMAL"Rules
| Consecutive Losses | Action | Reset Condition |
|---|---|---|
| 3 | Reduce position size by 50% | 2 consecutive wins |
| 5 | Minimum position size only | 2 consecutive wins |
| 7 | Halt trading for 24 hours | 24h elapsed + review complete |
| 10+ | Full stop, strategy review | Complete strategy audit |
Reset Logic
- Consecutive loss counter resets after 2 consecutive wins (not 1, to avoid whipsaw)
- After a 7+ loss halt, counter resets only after the mandatory break AND 2 consecutive wins in paper trading
- The counter persists across days — it tracks consecutive trade outcomes, not daily
Volatility Circuit Breaker
Implementation
import numpy as np
class VolatilityBreaker:
"""Monitor portfolio volatility and restrict trading in extreme conditions."""
def __init__(self, lookback: int = 20, trigger_multiple: float = 2.0,
reset_multiple: float = 1.5):
self.lookback = lookback
self.trigger_multiple = trigger_multiple
self.reset_multiple = reset_multiple
self.returns: list[float] = []
self.baseline_vol: float | None = None
def update(self, daily_return: float) -> None:
self.returns.append(daily_return)
if len(self.returns) > self.lookback * 3:
self.returns = self.returns[-self.lookback * 3:]
if len(self.returns) >= self.lookback:
self.baseline_vol = float(np.std(self.returns[-self.lookback:]))
def current_vol(self, window: int = 5) -> float | None:
if len(self.returns) < window:
return None
return float(np.std(self.returns[-window:]))
def is_triggered(self) -> bool:
if self.baseline_vol is None:
return False
current = self.current_vol()
if current is None:
return False
return current > self.baseline_vol * self.trigger_multiple
def is_reset(self) -> bool:
if self.baseline_vol is None:
return True
current = self.current_vol()
if current is None:
return True
return current <= self.baseline_vol * self.reset_multipleRules
| Condition | Trigger | Action | Reset |
|---|---|---|---|
| Short-term vol > 2× baseline | Triggered | Reduce exposure to 50%, widen stops | Vol drops below 1.5× baseline |
| Short-term vol > 3× baseline | Extreme | Reduce exposure to 25%, exit weak setups | Vol drops below 2× baseline |
| Market-wide liquidation event | Manual | Halt all entries | Manual assessment complete |
Crypto Volatility Indicators
- SOL 1-hour realized volatility vs 7-day average
- BTC dominance change rate (rapid shifts signal risk-off)
- DEX volume spikes (>3× average may indicate panic)
- Funding rates extreme readings (>0.1% or <-0.1%)
System Failure Breaker
Triggers
| Failure Type | Trigger | Action |
|---|---|---|
| API errors | 3+ consecutive failed API calls | Halt new trades |
| RPC failures | Primary + backup RPC unreachable | Halt all activity |
| Price feed stale | No price update for >60 seconds | Halt new trades |
| Unexpected P&L | Single trade P&L > 5× expected | Halt, investigate |
| Execution anomaly | Fill price >5% from expected | Halt, investigate |
Recovery
1. Identify the failure and confirm it is resolved 2. Verify all position data is accurate (reconcile on-chain state) 3. Resume with reduced size for first 3 trades 4. Return to normal only after 3 successful trades at reduced size
Combining Circuit Breakers
Multiple breakers can be active simultaneously. Use the most restrictive rule:
def effective_size_multiplier(
daily_loss_breaker: DailyLossBreaker,
consecutive_loss_breaker: ConsecutiveLossBreaker,
volatility_breaker: VolatilityBreaker,
) -> float:
"""Return the most restrictive size multiplier across all breakers."""
multipliers = []
if daily_loss_breaker.is_triggered():
multipliers.append(0.0)
if consecutive_loss_breaker.size_multiplier() < 1.0:
multipliers.append(consecutive_loss_breaker.size_multiplier())
if volatility_breaker.is_triggered():
multipliers.append(0.5)
return min(multipliers) if multipliers else 1.0Implementation Checklist
- [ ] Implement daily loss tracking (realized + unrealized)
- [ ] Set up daily loss limit with UTC reset
- [ ] Track consecutive losses across all strategies
- [ ] Calculate rolling portfolio volatility
- [ ] Define volatility baseline (20-period rolling std)
- [ ] Set up system health monitoring (API, RPC, price feeds)
- [ ] Log all circuit breaker activations with timestamp and cause
- [ ] Review circuit breaker thresholds monthly
- [ ] Test breaker logic with simulated extreme scenarios
- [ ] Ensure breakers cannot be overridden without explicit acknowledgment
Drawdown Management
Comprehensive framework for detecting, responding to, and recovering from portfolio drawdowns in crypto trading.
Drawdown Mathematics
Recovery Table
A drawdown of X% requires a gain greater than X% to recover, and the relationship is nonlinear:
| Drawdown | Required Gain | Ratio |
|---|---|---|
| -1% | +1.01% | 1.01× |
| -2% | +2.04% | 1.02× |
| -3% | +3.09% | 1.03× |
| -5% | +5.26% | 1.05× |
| -7% | +7.53% | 1.08× |
| -10% | +11.11% | 1.11× |
| -15% | +17.65% | 1.18× |
| -20% | +25.00% | 1.25× |
| -25% | +33.33% | 1.33× |
| -30% | +42.86% | 1.43× |
| -35% | +53.85% | 1.54× |
| -40% | +66.67% | 1.67× |
| -45% | +81.82% | 1.82× |
| -50% | +100.00% | 2.00× |
| -60% | +150.00% | 2.50× |
| -70% | +233.33% | 3.33× |
| -80% | +400.00% | 5.00× |
| -90% | +900.00% | 10.00× |
Formula: required_gain = drawdown / (1 - drawdown)
Why Small Drawdowns Matter
At -10%, recovery is manageable (+11.1%). At -20%, it becomes difficult (+25%). Past -30%, recovery is unlikely without exceptional conditions. The priority is always to keep drawdowns small.
A trader earning 1% per day on average needs:
- 6 trading days to recover from -5%
- 11 days to recover from -10%
- 25 days to recover from -20%
- 100 days to recover from -50%
Drawdown Detection
Calculating Current Drawdown
def current_drawdown(equity_curve: list[float]) -> float:
"""Calculate current drawdown from peak.
Returns:
Drawdown as a positive fraction (0.10 = 10% drawdown).
"""
peak = max(equity_curve)
current = equity_curve[-1]
if peak <= 0:
return 0.0
return (peak - current) / peakRolling Peak Tracking
Track the high-water mark continuously:
def rolling_peak(equity_curve: list[float]) -> list[float]:
"""Calculate rolling peak (high-water mark) at each point."""
peaks = []
current_peak = equity_curve[0]
for value in equity_curve:
current_peak = max(current_peak, value)
peaks.append(current_peak)
return peaksDrawdown Duration
Track not just depth but how long the portfolio has been underwater:
def time_underwater(equity_curve: list[float]) -> int:
"""Count periods since last equity high."""
peak = equity_curve[0]
periods = 0
for value in equity_curve:
if value >= peak:
peak = value
periods = 0
else:
periods += 1
return periodsDrawdown Response Framework
Level 0: Normal (0–5% drawdown)
- Detection: Equity within 5% of peak
- Action: Continue trading at full size
- Monitoring: Standard daily P&L review
- Psychology: Expected fluctuation, no concern
Level 1: Caution (5–10% drawdown)
- Detection: Alert triggers at 5% threshold
- Action: Reduce position sizes by 25–50%
- Monitoring: Review each trade more carefully before entry
- Psychology: Increased discipline, no revenge trading
- Additional: Review recent trades for pattern of errors
Level 2: Warning (10–15% drawdown)
- Detection: Alert triggers at 10% threshold
- Action: Trade at minimum position sizes only
- Monitoring: Daily strategy review, check for edge decay
- Psychology: Accept the drawdown, focus on process not P&L
- Additional: Consider whether market regime has changed
Level 3: Critical (15–20% drawdown)
- Detection: Alert triggers at 15% threshold
- Action: Halt all new trades, manage existing positions only
- Monitoring: Review entire strategy, check assumptions
- Psychology: Step back, no trading decisions while emotional
- Additional: Consult trading journal for similar past periods
Level 4: Emergency (>20% drawdown)
- Detection: Alert triggers at 20% threshold
- Action: Full stop — close positions systematically, not in panic
- Monitoring: Complete portfolio review before any resumption
- Psychology: Mandatory break (48–72 hours minimum)
- Additional: Re-evaluate all strategies, parameters, and risk limits
Recovery Criteria
Before resuming normal-size trading after a drawdown halt:
1. Time requirement: Minimum 48 hours of no trading 2. Analysis complete: Root cause identified and documented 3. Strategy validated: Backtested the strategy on recent data 4. Paper trading: 5–10 simulated trades showing the edge still exists 5. Gradual ramp: Start at 25% size → 50% → 75% → 100% over 1–2 weeks
Drawdown Causes and Remediation
Strategy Decay
- Symptoms: Win rate declining over weeks/months, not days
- Cause: The edge was arbitraged away or market structure changed
- Remediation: Retire the strategy, develop new ones on out-of-sample data
Regime Change
- Symptoms: Sudden performance drop, previously profitable setups failing
- Cause: Market shifted (trending → ranging, low vol → high vol)
- Remediation: Add regime detection, adapt parameters per regime
- Reference: See
regime-detectionskill
Overexposure
- Symptoms: Multiple positions stopping out simultaneously
- Cause: Too many correlated positions
- Remediation: Enforce correlation limits, reduce concurrent positions
Emotional Trading
- Symptoms: Deviation from plan, increasing size after losses, FOMO entries
- Cause: Psychological response to losses
- Remediation: Enforce circuit breakers, journal every trade, take breaks
Incorrect Position Sizing
- Symptoms: Single trades causing outsized impact on portfolio
- Cause: Position sizes too large relative to stop distance or account size
- Remediation: Review
position-sizingskill, enforce maximum risk per trade
Historical Drawdown Tracking
Maintain a drawdown log with these fields for every significant drawdown (>5%):
| Field | Description |
|---|---|
| Start date | When equity first dropped below prior peak |
| Trough date | Date of maximum drawdown depth |
| Recovery date | When equity returned to prior peak (or N/A) |
| Depth | Maximum drawdown percentage |
| Duration | Start to trough (periods) |
| Recovery time | Trough to recovery (periods) |
| Cause | Primary cause category |
| Lessons | Key takeaways documented |
This log is invaluable for identifying patterns: Are drawdowns seasonal? Strategy-specific? Correlated with market events?
Key Principles
1. Drawdowns are inevitable — The goal is management, not avoidance 2. Small drawdowns are recoverable — Keep them under 15% 3. Time heals, size kills — A small position that goes wrong is a learning opportunity; a large one is a disaster 4. Process over outcome — Follow the framework even when it feels overly cautious 5. Document everything — Future you will thank present you for the drawdown log
Exposure Limits
Detailed position and portfolio exposure constraints for crypto trading risk management.
Single Position Limits
By Token Type
Token classification determines maximum position size as a percentage of total account value:
| Token Type | Examples | Max Position | Rationale |
|---|---|---|---|
| Blue chip | SOL, ETH, BTC | 10% | High liquidity, lower rug risk |
| Large cap | RAY, JUP, BONK | 7% | Established but more volatile |
| Mid cap | Top 100 by mcap | 5% | Moderate liquidity risk |
| Small cap | Top 500 by mcap | 2% | Significant liquidity/rug risk |
| Micro cap | Sub-$10M mcap | 1% | Very high risk |
| PumpFun/meme | New launches | 0.5% | Assume potential 100% loss |
By Strategy Type
Different strategies carry different risk profiles:
| Strategy | Max Position | Rationale |
|---|---|---|
| Trend following | 5% | Wider stops, longer holds |
| Mean reversion | 3% | Can gap through stop levels |
| Momentum/scalp | 2% | High frequency, errors compound |
| Breakout | 3% | False breakouts are common |
| PumpFun snipe | 0.5% | Binary outcome expected |
By Confidence Level
Scale position size with conviction, but never exceed token-type limits:
| Confidence | Size Multiplier | Criteria |
|---|---|---|
| High | 100% of allowed max | Multiple confirming signals, strong setup |
| Medium | 50% of allowed max | Decent setup, some conflicting signals |
| Low | 25% of allowed max | Marginal setup, taking for diversification |
Example: High confidence on a mid-cap token = 5% × 100% = 5% position. Low confidence on same = 5% × 25% = 1.25%.
Portfolio-Level Limits
Total Exposure
Percentage of total account value deployed in open positions:
| Market Condition | Max Exposure | Cash Reserve |
|---|---|---|
| Strong trend, low vol | 80% | 20% |
| Normal conditions | 60% | 40% |
| High volatility | 40% | 60% |
| Drawdown >10% | 30% | 70% |
| Drawdown >15% | 20% | 80% |
| Circuit breaker active | 0% (existing only) | 100% |
Cash Reserve Purpose
The cash reserve serves multiple functions: 1. Opportunity capital: Ability to take advantage of sudden setups 2. Margin buffer: Prevents forced liquidations on leveraged positions 3. Psychological comfort: Reduces pressure to exit positions prematurely 4. Drawdown cushion: Limits portfolio drawdown speed
Maximum Concurrent Positions
| Account Size (SOL) | Max Positions | Rationale |
|---|---|---|
| <10 | 3 | Focus, meaningful size per trade |
| 10–50 | 5 | Moderate diversification |
| 50–200 | 7 | Balanced diversification |
| 200–1000 | 10 | Full diversification |
| >1000 | 15 | Diminishing returns beyond this |
Sector Concentration
Maximum allocation to any single narrative or sector:
| Sector | Max Allocation |
|---|---|
| DeFi protocols | 30% |
| Meme/culture tokens | 15% |
| Infrastructure/L1/L2 | 30% |
| Gaming/NFT-adjacent | 20% |
| AI tokens | 20% |
| Stablecoins (non-reserve) | 10% |
Correlation Management
The Correlation Problem
During normal markets, crypto assets may show moderate correlation (0.3–0.5). During sell-offs, correlations spike to 0.7–0.9+. A "diversified" portfolio of 10 crypto tokens may behave like 2–3 independent positions in a crash.
Effective Diversification
What does NOT diversify well:
- Multiple meme tokens (all driven by same sentiment)
- Multiple DeFi tokens (correlated with DeFi TVL)
- Multiple tokens on the same chain (correlated with chain activity)
- Long-only positions across any crypto tokens (all correlate with BTC)
What DOES diversify:
- Different strategies: trend + mean-reversion + market-neutral
- Different timeframes: scalp + swing + position
- Different exposures: long + short (when available)
- Cash reserves (zero correlation by definition)
Correlation Buckets
Group positions into correlation buckets and apply limits per bucket:
| Bucket | Contents | Max Allocation |
|---|---|---|
| BTC-correlated | BTC, SOL, ETH, major L1s | 30% |
| DeFi | DEX tokens, lending, yield | 20% |
| Meme | All meme/culture tokens | 15% |
| Stablecoin yield | LP positions, lending | 20% |
| Uncorrelated | Market-neutral strategies | No limit |
Calculating Portfolio Correlation
import numpy as np
def portfolio_correlation(returns_matrix: np.ndarray) -> float:
"""Average pairwise correlation of portfolio assets.
Args:
returns_matrix: N×T matrix (N assets, T time periods).
Returns:
Average pairwise correlation coefficient.
"""
corr = np.corrcoef(returns_matrix)
n = corr.shape[0]
# Extract upper triangle (excluding diagonal)
upper = corr[np.triu_indices(n, k=1)]
return float(np.mean(upper))Dynamic Limit Adjustments
Tightening During Drawdowns
When the portfolio is in drawdown, tighten all limits proportionally:
def adjusted_limit(base_limit: float, drawdown: float) -> float:
"""Reduce limits during drawdowns.
Args:
base_limit: Normal limit (e.g., 0.05 for 5%).
drawdown: Current drawdown as fraction (e.g., 0.10 for 10%).
Returns:
Adjusted limit, reduced proportionally.
"""
if drawdown < 0.05:
return base_limit
elif drawdown < 0.10:
return base_limit * 0.75
elif drawdown < 0.15:
return base_limit * 0.50
else:
return base_limit * 0.25Widening During Strong Performance
Expand limits cautiously when the portfolio is performing well:
- Only expand after 20+ days of positive equity curve slope
- Maximum expansion: 125% of base limits (never more)
- Revert to base limits immediately if drawdown begins
- Never expand PumpFun or micro-cap limits regardless of performance
Seasonal Adjustments
Historical crypto volatility patterns suggest:
- Reduce exposure: During major token unlock events, regulatory announcements
- Standard exposure: Normal market conditions
- Increase caution: Holiday periods (low liquidity), end-of-quarter (fund rebalancing)
Implementation Checklist
- [ ] Define token-type classification for each traded asset
- [ ] Set strategy-specific position limits
- [ ] Configure total exposure limits for current market regime
- [ ] Set up sector tracking and concentration alerts
- [ ] Calculate correlation buckets and apply bucket limits
- [ ] Implement dynamic adjustment based on drawdown level
- [ ] Review and update limits monthly
#!/usr/bin/env python3
"""Equity curve drawdown analysis with response recommendations.
Analyzes an equity curve to identify all drawdown periods, calculate
maximum drawdown statistics, and provide actionable recommendations
based on the current drawdown state.
Usage:
python scripts/drawdown_analyzer.py --demo
python scripts/drawdown_analyzer.py --equity equity_data.json
Dependencies:
uv pip install numpy
Environment Variables:
None required.
"""
import argparse
import json
import sys
from dataclasses import dataclass
from typing import Optional
import numpy as np
# ── Data Models ─────────────────────────────────────────────────────
@dataclass
class DrawdownPeriod:
"""A single drawdown period from peak to recovery."""
start_index: int
trough_index: int
recovery_index: Optional[int] # None if not yet recovered
peak_value: float
trough_value: float
depth: float # As positive fraction (0.10 = 10%)
duration_to_trough: int # Periods from start to trough
recovery_duration: Optional[int] # Periods from trough to recovery
total_duration: Optional[int] # Periods from start to recovery
@dataclass
class DrawdownSummary:
"""Summary statistics for all drawdowns in an equity curve."""
max_drawdown: float
max_drawdown_period: Optional[DrawdownPeriod]
current_drawdown: float
current_drawdown_start: Optional[int]
total_time_underwater: int
longest_underwater: int
num_drawdowns: int
avg_drawdown_depth: float
avg_recovery_time: float
all_periods: list[DrawdownPeriod]
# ── Core Analysis ───────────────────────────────────────────────────
def find_drawdown_periods(
equity: np.ndarray, min_depth: float = 0.01
) -> list[DrawdownPeriod]:
"""Identify all drawdown periods in an equity curve.
Args:
equity: Array of equity values over time.
min_depth: Minimum drawdown depth to record (default 1%).
Returns:
List of DrawdownPeriod objects sorted by start index.
"""
if len(equity) < 2:
return []
periods: list[DrawdownPeriod] = []
peak = equity[0]
peak_index = 0
in_drawdown = False
dd_start = 0
trough = equity[0]
trough_index = 0
for i in range(len(equity)):
if equity[i] >= peak:
# New high or recovery
if in_drawdown:
depth = (peak - trough) / peak if peak > 0 else 0.0
if depth >= min_depth:
periods.append(DrawdownPeriod(
start_index=dd_start,
trough_index=trough_index,
recovery_index=i,
peak_value=peak,
trough_value=trough,
depth=depth,
duration_to_trough=trough_index - dd_start,
recovery_duration=i - trough_index,
total_duration=i - dd_start,
))
in_drawdown = False
peak = equity[i]
peak_index = i
trough = equity[i]
trough_index = i
else:
if not in_drawdown:
in_drawdown = True
dd_start = peak_index
trough = equity[i]
trough_index = i
if equity[i] < trough:
trough = equity[i]
trough_index = i
# Handle open drawdown (not yet recovered)
if in_drawdown:
depth = (peak - trough) / peak if peak > 0 else 0.0
if depth >= min_depth:
periods.append(DrawdownPeriod(
start_index=dd_start,
trough_index=trough_index,
recovery_index=None,
peak_value=peak,
trough_value=trough,
depth=depth,
duration_to_trough=trough_index - dd_start,
recovery_duration=None,
total_duration=None,
))
return periods
def analyze_drawdowns(equity: np.ndarray) -> DrawdownSummary:
"""Comprehensive drawdown analysis of an equity curve.
Args:
equity: Array of equity values over time.
Returns:
DrawdownSummary with all statistics.
"""
periods = find_drawdown_periods(equity)
# Maximum drawdown
max_dd = 0.0
max_dd_period: Optional[DrawdownPeriod] = None
for p in periods:
if p.depth > max_dd:
max_dd = p.depth
max_dd_period = p
# Current drawdown
peak = np.max(equity)
current = equity[-1]
current_dd = (peak - current) / peak if peak > 0 else 0.0
current_dd_start: Optional[int] = None
if current_dd > 0.001:
# Find when the current drawdown started
peak_idx = int(np.argmax(equity))
current_dd_start = peak_idx
# Time underwater
peaks = np.maximum.accumulate(equity)
underwater = peaks > equity
total_underwater = int(np.sum(underwater))
# Longest consecutive underwater period
longest_uw = 0
current_uw = 0
for uw in underwater:
if uw:
current_uw += 1
longest_uw = max(longest_uw, current_uw)
else:
current_uw = 0
# Average drawdown depth
avg_depth = np.mean([p.depth for p in periods]) if periods else 0.0
# Average recovery time (only for recovered drawdowns)
recovered = [p for p in periods if p.recovery_duration is not None]
avg_recovery = (
np.mean([p.recovery_duration for p in recovered]) if recovered else 0.0
)
return DrawdownSummary(
max_drawdown=max_dd,
max_drawdown_period=max_dd_period,
current_drawdown=current_dd,
current_drawdown_start=current_dd_start,
total_time_underwater=total_underwater,
longest_underwater=longest_uw,
num_drawdowns=len(periods),
avg_drawdown_depth=float(avg_depth),
avg_recovery_time=float(avg_recovery),
all_periods=periods,
)
def recovery_required(drawdown: float) -> float:
"""Calculate gain needed to recover from a drawdown.
Args:
drawdown: Drawdown as positive fraction (0.20 = 20%).
Returns:
Required gain as positive fraction.
"""
if drawdown >= 1.0:
return float("inf")
if drawdown <= 0.0:
return 0.0
return drawdown / (1.0 - drawdown)
def drawdown_response(drawdown: float) -> tuple[str, str, str]:
"""Determine the appropriate response for a given drawdown level.
Returns:
Tuple of (level, status_color, recommendation).
"""
if drawdown < 0.05:
return ("Normal", "\033[92m", "Continue trading at full size.")
elif drawdown < 0.10:
return ("Caution", "\033[93m", "Reduce position sizes by 25-50%. Review recent trades for errors.")
elif drawdown < 0.15:
return ("Warning", "\033[91m", "Minimum position sizes only. Review strategy edge and market regime.")
elif drawdown < 0.20:
return ("Critical", "\033[91m", "Halt new trades. Manage existing positions only. Mandatory review.")
else:
return (
"Emergency",
"\033[91m",
"Full stop. Close positions systematically. 48-72 hour break. "
"Complete strategy review before resuming.",
)
# ── Output Formatting ──────────────────────────────────────────────
def print_separator(char: str = "=", width: int = 70) -> None:
"""Print a separator line."""
print(char * width)
def print_summary(summary: DrawdownSummary, equity: np.ndarray) -> None:
"""Print formatted drawdown analysis results."""
reset = "\033[0m"
print()
print_separator()
print(" DRAWDOWN ANALYSIS")
print_separator()
print(f"\n Equity Curve: {len(equity)} periods")
print(f" Start Value: {equity[0]:.2f}")
print(f" End Value: {equity[-1]:.2f}")
print(f" Peak Value: {np.max(equity):.2f}")
print(f" Total Return: {(equity[-1] / equity[0] - 1) * 100:+.1f}%")
# ── Maximum Drawdown ────────────────────────────────────────
print()
print_separator("-")
print(" MAXIMUM DRAWDOWN")
print_separator("-")
print(f" Max Drawdown: {summary.max_drawdown:.1%}")
print(f" Recovery Required: +{recovery_required(summary.max_drawdown):.1%}")
if summary.max_drawdown_period:
p = summary.max_drawdown_period
print(f" Peak Index: {p.start_index}")
print(f" Trough Index: {p.trough_index}")
print(f" Peak Value: {p.peak_value:.2f}")
print(f" Trough Value: {p.trough_value:.2f}")
print(f" Duration to Trough: {p.duration_to_trough} periods")
if p.recovery_index is not None:
print(f" Recovery Index: {p.recovery_index}")
print(f" Recovery Duration: {p.recovery_duration} periods")
print(f" Total Duration: {p.total_duration} periods")
else:
print(" Recovery: NOT YET RECOVERED")
# ── Current Status ──────────────────────────────────────────
print()
print_separator("-")
print(" CURRENT STATUS")
print_separator("-")
level, color, recommendation = drawdown_response(summary.current_drawdown)
print(f" Current Drawdown: {color}{summary.current_drawdown:.1%}{reset}")
print(f" Status Level: {color}{level}{reset}")
print(f" Recovery Needed: +{recovery_required(summary.current_drawdown):.1%}")
print(f" Recommendation: {recommendation}")
if summary.current_drawdown_start is not None and summary.current_drawdown > 0.001:
periods_in_dd = len(equity) - 1 - summary.current_drawdown_start
print(f" Periods in Current Drawdown: {periods_in_dd}")
# ── Underwater Analysis ─────────────────────────────────────
print()
print_separator("-")
print(" UNDERWATER ANALYSIS")
print_separator("-")
total_periods = len(equity)
uw_pct = summary.total_time_underwater / total_periods * 100 if total_periods > 0 else 0
print(f" Total Time Underwater: {summary.total_time_underwater} periods ({uw_pct:.1f}%)")
print(f" Longest Underwater: {summary.longest_underwater} periods")
print(f" Number of Drawdowns (>1%): {summary.num_drawdowns}")
print(f" Average Drawdown Depth: {summary.avg_drawdown_depth:.1%}")
if summary.avg_recovery_time > 0:
print(f" Average Recovery Time: {summary.avg_recovery_time:.1f} periods")
# ── All Drawdown Periods ────────────────────────────────────
if summary.all_periods:
print()
print_separator("-")
print(" ALL DRAWDOWN PERIODS")
print_separator("-")
print(f" {'#':>3s} {'Depth':>7s} {'Peak':>8s} {'Trough':>8s} {'To Trough':>10s} {'Recovery':>10s} {'Status':<12s}")
print(" " + "-" * 65)
for i, p in enumerate(sorted(summary.all_periods, key=lambda x: -x.depth), 1):
recovery_str = (
f"{p.recovery_duration}" if p.recovery_duration is not None else "OPEN"
)
status_str = "Recovered" if p.recovery_index is not None else "ACTIVE"
print(
f" {i:>3d} {p.depth:>6.1%} {p.peak_value:>8.2f} {p.trough_value:>8.2f} "
f"{p.duration_to_trough:>10d} {recovery_str:>10s} {status_str:<12s}"
)
# ── Recovery Table ──────────────────────────────────────────
print()
print_separator("-")
print(" RECOVERY REFERENCE TABLE")
print_separator("-")
print(f" {'Drawdown':>10s} {'Gain Needed':>12s} {'At 1%/day':>10s}")
print(" " + "-" * 36)
for dd_pct in [5, 10, 15, 20, 25, 30, 40, 50]:
dd = dd_pct / 100
gain = recovery_required(dd)
days = 0
cumulative = 1.0
target = 1.0 / (1.0 - dd)
while cumulative < target and days < 1000:
cumulative *= 1.01
days += 1
print(f" {dd:>9.0%} {gain:>11.1%} {days:>8d} days")
print()
print_separator()
# ── Demo Data ───────────────────────────────────────────────────────
def generate_demo_equity(
start: float = 100.0,
periods: int = 200,
seed: int = 42,
) -> np.ndarray:
"""Generate a realistic equity curve with multiple drawdowns.
Creates an equity curve that trends upward with realistic drawdown
characteristics including:
- A moderate drawdown early on (~8%)
- A significant drawdown in the middle (~18%)
- A recovery followed by a mild current drawdown (~6%)
Args:
start: Starting equity value.
periods: Number of periods to generate.
seed: Random seed for reproducibility.
Returns:
NumPy array of equity values.
"""
rng = np.random.default_rng(seed)
equity = [start]
current = start
# Phase 1: Mild uptrend (periods 0-40)
for _ in range(40):
ret = rng.normal(0.003, 0.015)
current *= (1 + ret)
equity.append(current)
# Phase 2: Moderate drawdown (periods 41-60)
for _ in range(20):
ret = rng.normal(-0.004, 0.012)
current *= (1 + ret)
equity.append(current)
# Phase 3: Recovery and new highs (periods 61-100)
for _ in range(40):
ret = rng.normal(0.004, 0.014)
current *= (1 + ret)
equity.append(current)
# Phase 4: Significant drawdown (periods 101-130)
for _ in range(30):
ret = rng.normal(-0.006, 0.015)
current *= (1 + ret)
equity.append(current)
# Phase 5: Slow recovery (periods 131-170)
for _ in range(40):
ret = rng.normal(0.005, 0.013)
current *= (1 + ret)
equity.append(current)
# Phase 6: Current mild drawdown (periods 171-200)
for _ in range(periods - 171):
ret = rng.normal(-0.001, 0.012)
current *= (1 + ret)
equity.append(current)
return np.array(equity[:periods])
def load_equity_from_file(filepath: str) -> np.ndarray:
"""Load equity curve from a JSON file.
Expected format: {"equity": [100.0, 101.5, 99.8, ...]}
Or a plain JSON array: [100.0, 101.5, 99.8, ...]
"""
try:
with open(filepath, "r") as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Error loading equity file: {e}")
sys.exit(1)
if isinstance(data, list):
return np.array(data, dtype=float)
elif isinstance(data, dict) and "equity" in data:
return np.array(data["equity"], dtype=float)
else:
print("Expected JSON array or object with 'equity' key")
sys.exit(1)
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Entry point for the drawdown analyzer."""
parser = argparse.ArgumentParser(
description="Equity curve drawdown analysis — identify and analyze drawdown periods"
)
parser.add_argument(
"--demo",
action="store_true",
help="Run with generated demo equity curve",
)
parser.add_argument(
"--equity",
type=str,
help="Path to JSON file with equity curve data",
)
parser.add_argument(
"--min-depth",
type=float,
default=0.02,
help="Minimum drawdown depth to report (default: 0.02 = 2%%)",
)
args = parser.parse_args()
if args.demo:
equity = generate_demo_equity()
print("\n [Running with generated demo equity curve]")
elif args.equity:
equity = load_equity_from_file(args.equity)
else:
parser.print_help()
print("\nProvide --demo or --equity <file.json>")
sys.exit(1)
summary = analyze_drawdowns(equity)
# Re-run with custom min_depth if specified
if args.min_depth != 0.01:
summary.all_periods = find_drawdown_periods(equity, min_depth=args.min_depth)
summary.num_drawdowns = len(summary.all_periods)
if summary.all_periods:
summary.avg_drawdown_depth = float(
np.mean([p.depth for p in summary.all_periods])
)
print_summary(summary, equity)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Portfolio risk dashboard with limit checking and color-coded status.
Analyzes a portfolio of positions against configurable risk limits and
displays a comprehensive dashboard showing exposure, concentration,
drawdown, and circuit breaker status.
Usage:
python scripts/risk_dashboard.py --demo
python scripts/risk_dashboard.py --positions positions.json
Dependencies:
None (pure Python, no external packages required)
Environment Variables:
ACCOUNT_SIZE: Total account size in SOL (default: 100)
"""
import argparse
import json
import math
import os
import sys
from dataclasses import dataclass, field
from typing import Optional
# ── Configuration ───────────────────────────────────────────────────
ACCOUNT_SIZE = float(os.getenv("ACCOUNT_SIZE", "100"))
# Risk limits (configurable)
LIMITS = {
"max_single_position_pct": 0.10, # 10% of account
"max_total_exposure_pct": 0.80, # 80% of account
"max_daily_loss_pct": 0.03, # 3% daily loss
"max_drawdown_warning_pct": 0.10, # 10% drawdown warning
"max_drawdown_critical_pct": 0.15, # 15% drawdown critical
"max_drawdown_halt_pct": 0.20, # 20% drawdown halt
"max_consecutive_losses": 3, # consecutive loss warning
"max_sector_concentration_pct": 0.30, # 30% per sector
"max_concurrent_positions": 10, # maximum open positions
}
# ── Data Models ─────────────────────────────────────────────────────
@dataclass
class Position:
"""A single portfolio position."""
token: str
entry_price: float
current_price: float
size_sol: float
stop_loss: Optional[float] = None
sector: str = "unknown"
token_type: str = "mid-cap" # blue-chip, mid-cap, small-cap, micro, pumpfun
@property
def pnl_sol(self) -> float:
"""Unrealized P&L in SOL."""
if self.entry_price == 0:
return 0.0
return self.size_sol * (self.current_price / self.entry_price - 1.0)
@property
def pnl_pct(self) -> float:
"""Unrealized P&L as percentage."""
if self.entry_price == 0:
return 0.0
return (self.current_price / self.entry_price - 1.0) * 100
@property
def risk_to_stop(self) -> float:
"""Risk in SOL if stop loss is hit."""
if self.stop_loss is None or self.entry_price == 0:
return self.size_sol # Assume 100% loss if no stop
loss_pct = (self.entry_price - self.stop_loss) / self.entry_price
return self.size_sol * max(0.0, loss_pct)
@property
def current_value(self) -> float:
"""Current position value in SOL."""
if self.entry_price == 0:
return 0.0
return self.size_sol * (self.current_price / self.entry_price)
@dataclass
class PortfolioState:
"""Aggregate portfolio state for risk assessment."""
account_size: float
positions: list[Position]
realized_pnl_today: float = 0.0
equity_peak: float = 0.0
consecutive_losses: int = 0
consecutive_wins: int = 0
trade_history: list[float] = field(default_factory=list)
# ── Status Helpers ──────────────────────────────────────────────────
class Status:
"""Color-coded status indicators."""
OK = "OK"
WARNING = "WARNING"
BREACH = "BREACH"
def colorize(text: str, status: str) -> str:
"""Add ANSI color codes based on status."""
colors = {
Status.OK: "\033[92m", # Green
Status.WARNING: "\033[93m", # Yellow
Status.BREACH: "\033[91m", # Red
}
reset = "\033[0m"
color = colors.get(status, "")
return f"{color}{text}{reset}"
def status_icon(status: str) -> str:
"""Return a text icon for the status."""
icons = {
Status.OK: "[OK]",
Status.WARNING: "[WARN]",
Status.BREACH: "[BREACH]",
}
return icons.get(status, "[??]")
# ── Risk Calculations ───────────────────────────────────────────────
def calculate_total_exposure(positions: list[Position], account_size: float) -> tuple[float, float]:
"""Calculate total deployed capital.
Returns:
Tuple of (total_sol, percentage_of_account).
"""
total = sum(p.size_sol for p in positions)
pct = total / account_size if account_size > 0 else 0.0
return total, pct
def calculate_total_risk(positions: list[Position], account_size: float) -> tuple[float, float]:
"""Calculate total portfolio risk (distance to stops).
Returns:
Tuple of (total_risk_sol, percentage_of_account).
"""
total = sum(p.risk_to_stop for p in positions)
pct = total / account_size if account_size > 0 else 0.0
return total, pct
def calculate_largest_position(positions: list[Position], account_size: float) -> tuple[str, float, float]:
"""Find the largest single position.
Returns:
Tuple of (token_name, size_sol, percentage_of_account).
"""
if not positions:
return ("none", 0.0, 0.0)
largest = max(positions, key=lambda p: p.size_sol)
pct = largest.size_sol / account_size if account_size > 0 else 0.0
return (largest.token, largest.size_sol, pct)
def calculate_hhi(positions: list[Position]) -> float:
"""Calculate Herfindahl-Hirschman Index for position concentration.
Returns:
HHI value between 0 and 10000.
- 10000: single position (maximum concentration)
- <1500: well diversified
- 1500-2500: moderate concentration
- >2500: high concentration
"""
if not positions:
return 0.0
total = sum(p.size_sol for p in positions)
if total == 0:
return 0.0
shares = [(p.size_sol / total) * 100 for p in positions]
return sum(s * s for s in shares)
def calculate_daily_pnl(
positions: list[Position], realized_pnl: float, account_size: float
) -> tuple[float, float]:
"""Calculate total daily P&L (realized + unrealized).
Returns:
Tuple of (total_pnl_sol, percentage_of_account).
"""
unrealized = sum(p.pnl_sol for p in positions)
total = realized_pnl + unrealized
pct = total / account_size if account_size > 0 else 0.0
return total, pct
def calculate_drawdown(current_equity: float, equity_peak: float) -> float:
"""Calculate current drawdown from peak.
Returns:
Drawdown as a positive fraction (0.10 = 10% drawdown).
"""
if equity_peak <= 0:
return 0.0
return max(0.0, (equity_peak - current_equity) / equity_peak)
def calculate_sector_concentration(
positions: list[Position], account_size: float
) -> dict[str, float]:
"""Calculate allocation percentage per sector.
Returns:
Dict mapping sector name to percentage of account.
"""
sectors: dict[str, float] = {}
for p in positions:
sectors[p.sector] = sectors.get(p.sector, 0.0) + p.size_sol
return {s: v / account_size for s, v in sectors.items()} if account_size > 0 else {}
def recovery_needed(drawdown: float) -> float:
"""Calculate the gain needed to recover from a drawdown.
Args:
drawdown: Drawdown as a positive fraction (e.g., 0.20 for 20%).
Returns:
Required gain as a positive fraction.
"""
if drawdown >= 1.0:
return float("inf")
if drawdown <= 0.0:
return 0.0
return drawdown / (1.0 - drawdown)
def drawdown_response_level(drawdown: float) -> tuple[str, str]:
"""Determine drawdown response level and recommendation.
Returns:
Tuple of (level_name, recommendation).
"""
if drawdown < 0.05:
return ("Normal", "Continue trading at full size")
elif drawdown < 0.10:
return ("Caution", "Reduce position sizes by 25-50%")
elif drawdown < 0.15:
return ("Warning", "Minimum position sizes only")
elif drawdown < 0.20:
return ("Critical", "Halt new trades, manage existing only")
else:
return ("Emergency", "Full stop, review everything before resuming")
# ── Dashboard Output ────────────────────────────────────────────────
def print_separator(char: str = "=", width: int = 70) -> None:
"""Print a separator line."""
print(char * width)
def print_header(title: str) -> None:
"""Print a section header."""
print()
print_separator()
print(f" {title}")
print_separator()
def print_metric(
label: str, value: str, status: str, limit_desc: str = ""
) -> None:
"""Print a single metric line with status."""
icon = colorize(status_icon(status), status)
limit_text = f" (limit: {limit_desc})" if limit_desc else ""
print(f" {icon} {label:<35s} {value:<20s}{limit_text}")
def run_dashboard(state: PortfolioState) -> dict[str, str]:
"""Run the risk dashboard and print results.
Args:
state: Current portfolio state.
Returns:
Dict of check names to status values for programmatic use.
"""
results: dict[str, str] = {}
limits = LIMITS
print_header("PORTFOLIO RISK DASHBOARD")
print(f" Account Size: {state.account_size:.2f} SOL")
print(f" Open Positions: {len(state.positions)}")
print(f" Equity Peak: {state.equity_peak:.2f} SOL")
# ── Exposure ────────────────────────────────────────────────
print_header("EXPOSURE")
total_sol, total_pct = calculate_total_exposure(state.positions, state.account_size)
exp_status = (
Status.BREACH if total_pct > limits["max_total_exposure_pct"]
else Status.WARNING if total_pct > limits["max_total_exposure_pct"] * 0.8
else Status.OK
)
print_metric(
"Total Exposure",
f"{total_sol:.2f} SOL ({total_pct:.1%})",
exp_status,
f"{limits['max_total_exposure_pct']:.0%}",
)
results["total_exposure"] = exp_status
cash = state.account_size - total_sol
cash_pct = cash / state.account_size if state.account_size > 0 else 0
cash_status = Status.OK if cash_pct >= 0.20 else Status.WARNING if cash_pct >= 0.10 else Status.BREACH
print_metric("Cash Reserve", f"{cash:.2f} SOL ({cash_pct:.1%})", cash_status, ">= 20%")
results["cash_reserve"] = cash_status
pos_count_status = (
Status.BREACH if len(state.positions) > limits["max_concurrent_positions"]
else Status.WARNING if len(state.positions) > limits["max_concurrent_positions"] * 0.8
else Status.OK
)
print_metric(
"Concurrent Positions",
f"{len(state.positions)}",
pos_count_status,
f"<= {limits['max_concurrent_positions']}",
)
results["concurrent_positions"] = pos_count_status
# ── Concentration ───────────────────────────────────────────
print_header("CONCENTRATION")
token_name, token_sol, token_pct = calculate_largest_position(state.positions, state.account_size)
pos_status = (
Status.BREACH if token_pct > limits["max_single_position_pct"]
else Status.WARNING if token_pct > limits["max_single_position_pct"] * 0.8
else Status.OK
)
print_metric(
f"Largest Position ({token_name})",
f"{token_sol:.2f} SOL ({token_pct:.1%})",
pos_status,
f"{limits['max_single_position_pct']:.0%}",
)
results["largest_position"] = pos_status
hhi = calculate_hhi(state.positions)
hhi_status = Status.OK if hhi < 1500 else Status.WARNING if hhi < 2500 else Status.BREACH
hhi_label = "Low" if hhi < 1500 else "Moderate" if hhi < 2500 else "High"
print_metric("Concentration (HHI)", f"{hhi:.0f} ({hhi_label})", hhi_status, "< 2500")
results["hhi"] = hhi_status
sectors = calculate_sector_concentration(state.positions, state.account_size)
for sector, pct in sorted(sectors.items(), key=lambda x: -x[1]):
sec_status = (
Status.BREACH if pct > limits["max_sector_concentration_pct"]
else Status.WARNING if pct > limits["max_sector_concentration_pct"] * 0.8
else Status.OK
)
print_metric(
f" Sector: {sector}",
f"{pct:.1%}",
sec_status,
f"{limits['max_sector_concentration_pct']:.0%}",
)
results[f"sector_{sector}"] = sec_status
# ── Risk ────────────────────────────────────────────────────
print_header("RISK")
risk_sol, risk_pct = calculate_total_risk(state.positions, state.account_size)
risk_status = Status.OK if risk_pct < 0.05 else Status.WARNING if risk_pct < 0.10 else Status.BREACH
print_metric("Portfolio Risk (to stops)", f"{risk_sol:.2f} SOL ({risk_pct:.1%})", risk_status, "< 10%")
results["portfolio_risk"] = risk_status
# ── Daily P&L ───────────────────────────────────────────────
print_header("DAILY P&L")
daily_sol, daily_pct = calculate_daily_pnl(
state.positions, state.realized_pnl_today, state.account_size
)
daily_status = (
Status.BREACH if daily_pct < -limits["max_daily_loss_pct"]
else Status.WARNING if daily_pct < -limits["max_daily_loss_pct"] * 0.5
else Status.OK
)
pnl_sign = "+" if daily_sol >= 0 else ""
print_metric(
"Daily P&L",
f"{pnl_sign}{daily_sol:.2f} SOL ({pnl_sign}{daily_pct:.1%})",
daily_status,
f"> -{limits['max_daily_loss_pct']:.0%}",
)
results["daily_pnl"] = daily_status
# ── Drawdown ────────────────────────────────────────────────
print_header("DRAWDOWN")
current_equity = state.account_size + sum(p.pnl_sol for p in state.positions) + state.realized_pnl_today
dd = calculate_drawdown(current_equity, state.equity_peak) if state.equity_peak > 0 else 0.0
dd_level, dd_rec = drawdown_response_level(dd)
dd_status = (
Status.BREACH if dd >= limits["max_drawdown_critical_pct"]
else Status.WARNING if dd >= limits["max_drawdown_warning_pct"]
else Status.OK
)
print_metric("Current Drawdown", f"{dd:.1%} ({dd_level})", dd_status, f"< {limits['max_drawdown_warning_pct']:.0%}")
results["drawdown"] = dd_status
if dd > 0:
rec = recovery_needed(dd)
print_metric("Recovery Needed", f"+{rec:.1%}", Status.WARNING if dd >= 0.10 else Status.OK)
print(f" Recommendation: {dd_rec}")
# ── Streaks ─────────────────────────────────────────────────
print_header("STREAKS & CIRCUIT BREAKERS")
if state.consecutive_losses > 0:
streak_status = (
Status.BREACH if state.consecutive_losses >= 5
else Status.WARNING if state.consecutive_losses >= limits["max_consecutive_losses"]
else Status.OK
)
print_metric(
"Consecutive Losses",
f"{state.consecutive_losses}",
streak_status,
f"< {limits['max_consecutive_losses']}",
)
results["consecutive_losses"] = streak_status
if state.consecutive_losses >= 7:
print(f" ACTION: Halt trading for 24 hours, full review required")
elif state.consecutive_losses >= 5:
print(f" ACTION: Minimum position sizes only")
elif state.consecutive_losses >= 3:
print(f" ACTION: Reduce position sizes by 50%")
else:
print_metric("Consecutive Losses", "0", Status.OK, f"< {limits['max_consecutive_losses']}")
results["consecutive_losses"] = Status.OK
if state.consecutive_wins > 0:
print_metric("Consecutive Wins", f"{state.consecutive_wins}", Status.OK)
# ── Positions Detail ────────────────────────────────────────
if state.positions:
print_header("POSITION DETAILS")
print(f" {'Token':<12s} {'Size':>8s} {'Entry':>10s} {'Current':>10s} {'P&L':>10s} {'P&L%':>8s} {'Risk':>8s}")
print(" " + "-" * 68)
for p in sorted(state.positions, key=lambda x: -x.size_sol):
pnl_sign = "+" if p.pnl_sol >= 0 else ""
print(
f" {p.token:<12s} {p.size_sol:>7.2f}S {p.entry_price:>10.6f} "
f"{p.current_price:>10.6f} {pnl_sign}{p.pnl_sol:>8.2f}S "
f"{pnl_sign}{p.pnl_pct:>6.1f}% {p.risk_to_stop:>7.2f}S"
)
# ── Summary ─────────────────────────────────────────────────
print_header("SUMMARY")
breaches = [k for k, v in results.items() if v == Status.BREACH]
warnings = [k for k, v in results.items() if v == Status.WARNING]
if breaches:
print(colorize(f" BREACHES ({len(breaches)}):", Status.BREACH))
for b in breaches:
print(colorize(f" - {b}", Status.BREACH))
if warnings:
print(colorize(f" WARNINGS ({len(warnings)}):", Status.WARNING))
for w in warnings:
print(colorize(f" - {w}", Status.WARNING))
if not breaches and not warnings:
print(colorize(" All checks passed. Portfolio within risk limits.", Status.OK))
print()
return results
# ── Demo Data ───────────────────────────────────────────────────────
def create_demo_portfolio() -> PortfolioState:
"""Create a realistic demo portfolio for dashboard demonstration."""
positions = [
Position(
token="SOL",
entry_price=145.00,
current_price=142.50,
size_sol=8.0,
stop_loss=135.00,
sector="infrastructure",
token_type="blue-chip",
),
Position(
token="JUP",
entry_price=0.85,
current_price=0.92,
size_sol=5.0,
stop_loss=0.75,
sector="defi",
token_type="large-cap",
),
Position(
token="RAY",
entry_price=2.10,
current_price=1.95,
size_sol=4.0,
stop_loss=1.80,
sector="defi",
token_type="large-cap",
),
Position(
token="BONK",
entry_price=0.00002,
current_price=0.000025,
size_sol=3.0,
stop_loss=0.000015,
sector="meme",
token_type="mid-cap",
),
Position(
token="WIF",
entry_price=1.80,
current_price=1.65,
size_sol=2.5,
stop_loss=1.50,
sector="meme",
token_type="mid-cap",
),
Position(
token="NEWMEME",
entry_price=0.001,
current_price=0.0008,
size_sol=0.5,
stop_loss=None, # No stop on PumpFun
sector="meme",
token_type="pumpfun",
),
Position(
token="ORCA",
entry_price=3.50,
current_price=3.60,
size_sol=3.0,
stop_loss=3.10,
sector="defi",
token_type="large-cap",
),
]
return PortfolioState(
account_size=ACCOUNT_SIZE,
positions=positions,
realized_pnl_today=-0.8,
equity_peak=ACCOUNT_SIZE * 1.05, # Was 5% higher at peak
consecutive_losses=2,
consecutive_wins=0,
)
def load_positions_from_file(filepath: str) -> PortfolioState:
"""Load portfolio state from a JSON file.
Expected format:
{
"account_size": 100,
"equity_peak": 105,
"realized_pnl_today": -0.5,
"consecutive_losses": 1,
"consecutive_wins": 0,
"positions": [
{
"token": "SOL",
"entry_price": 145.0,
"current_price": 142.5,
"size_sol": 8.0,
"stop_loss": 135.0,
"sector": "infrastructure",
"token_type": "blue-chip"
}
]
}
"""
try:
with open(filepath, "r") as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Error loading positions file: {e}")
sys.exit(1)
positions = []
for p in data.get("positions", []):
positions.append(Position(
token=p["token"],
entry_price=p["entry_price"],
current_price=p["current_price"],
size_sol=p["size_sol"],
stop_loss=p.get("stop_loss"),
sector=p.get("sector", "unknown"),
token_type=p.get("token_type", "mid-cap"),
))
return PortfolioState(
account_size=data.get("account_size", ACCOUNT_SIZE),
positions=positions,
realized_pnl_today=data.get("realized_pnl_today", 0.0),
equity_peak=data.get("equity_peak", data.get("account_size", ACCOUNT_SIZE)),
consecutive_losses=data.get("consecutive_losses", 0),
consecutive_wins=data.get("consecutive_wins", 0),
)
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
"""Entry point for the risk dashboard."""
parser = argparse.ArgumentParser(
description="Portfolio risk dashboard — analyze positions against risk limits"
)
parser.add_argument(
"--demo",
action="store_true",
help="Run with demo portfolio data",
)
parser.add_argument(
"--positions",
type=str,
help="Path to JSON file with portfolio positions",
)
args = parser.parse_args()
if args.demo:
state = create_demo_portfolio()
print("\n [Running with demo portfolio data]")
elif args.positions:
state = load_positions_from_file(args.positions)
else:
parser.print_help()
print("\nProvide --demo or --positions <file.json>")
sys.exit(1)
results = run_dashboard(state)
# Exit with non-zero code if any breaches
breaches = [k for k, v in results.items() if v == Status.BREACH]
sys.exit(1 if breaches else 0)
if __name__ == "__main__":
main()
Related skills
FAQ
What is the risk hierarchy?
Survival first, then capital preservation, then growth; violating this order is described as the primary cause of account blowups.
What kinds of circuit breakers are included?
Time-based (cooling periods), loss-based (consecutive-loss size cuts), and volatility-based (exposure cuts on volatility spikes).