{ "cells": [ { "cell_type": "markdown", "id": "beecb70e", "metadata": {}, "source": [ "# FTDT Quant Lab — Portfolio Construction & Risk Management\n", "\n", "**Goal:** Combine multiple independent alpha sources into a single risk-managed portfolio targeting Sharpe > 1.5.\n", "\n", "**Key concepts:**\n", "1. **Diversification**: N independent strategies with low correlation → Sharpe scales ~√N\n", "2. **Risk Parity**: Allocate capital inversely proportional to strategy volatility\n", "3. **Volatility Targeting**: Scale total portfolio to target annualized vol (e.g., 20%)\n", "4. **Correlation Penalty**: Reduce allocation to redundant (highly correlated) strategies\n", "5. **Regime Adaptation**: Shift strategy weights based on market conditions\n", "6. **Drawdown Control**: Kill switch at strategy and portfolio level\n", "\n", "**Math:**\n", "Portfolio Sharpe ≈ √N × avg(individual Sharpe) × √(1 - avg_correlation)\n", "\n", "If we have 5 strategies with average individual Sharpe 2.0 and average correlation 0.2:\n", "Portfolio Sharpe ≈ √5 × 2.0 × √(0.8) ≈ 4.0\n", "\n", "This is the engine. 5 good strategies + low correlation → Sharpe >> 1.5." ] }, { "cell_type": "code", "execution_count": null, "id": "36f62f63", "metadata": {}, "outputs": [], "source": [ "# Setup\n", "import sys; sys.path.insert(0, str(Path.cwd().parent))\n", "\n", "import numpy as np\n", "import pandas as pd\n", "import matplotlib.pyplot as plt\n", "import seaborn as sns\n", "\n", "from strategies.portfolio import PortfolioConstructor, StrategyAllocation\n", "from strategies.regime_ensemble import RegimeDetector, RegimeEnsemble, STRATEGY_REGIME_AFFINITY\n", "from config.fee_tiers import get_perp_fees, get_strategy_fee_model\n", "\n", "sns.set_theme(style=\"darkgrid\")\n", "plt.rcParams[\"figure.figsize\"] = (14, 6)\n" ] }, { "cell_type": "markdown", "id": "d061b751", "metadata": {}, "source": [ "## 1. Strategy × Regime Affinity Matrix\n", "\n", "The regime-switching ensemble selects strategies based on their known\n", "performance characteristics in each market regime." ] }, { "cell_type": "code", "execution_count": null, "id": "bcd7d239", "metadata": {}, "outputs": [], "source": [ "affinity = STRATEGY_REGIME_AFFINITY\n", "affinity_df = pd.DataFrame(affinity).T\n", "\n", "fig, ax = plt.subplots(figsize=(14, 8))\n", "sns.heatmap(affinity_df, annot=True, fmt='.1f', cmap='YlOrRd', \n", " vmin=0, vmax=1, ax=ax, cbar_kws={'label': 'Affinity Score'})\n", "ax.set_title('Strategy × Regime Affinity Matrix')\n", "plt.tight_layout()\n", "plt.show()\n", "\n", "# Best strategy per regime\n", "print(\"Best strategy for each regime:\")\n", "for regime in affinity_df.index:\n", " best = affinity_df.loc[regime].idxmax()\n", " score = affinity_df.loc[regime, best]\n", " print(f\" {regime:20s} → {best:20s} (score: {score:.1f})\")\n" ] }, { "cell_type": "markdown", "id": "a7c2cdc3", "metadata": {}, "source": [ "## 2. Portfolio Construction Simulation\n", "\n", "Simulate the portfolio with 7 strategies, each running independently.\n", "Use correlated returns to test the diversification benefits." ] }, { "cell_type": "code", "execution_count": null, "id": "6cae17a9", "metadata": {}, "outputs": [], "source": [ "# Simulated returns for 7 strategies with some correlation\n", "np.random.seed(42)\n", "n_bars = 1000\n", "\n", "strategy_names = [\"pairs\", \"hurst_vpin\", \"cross_sectional\", \"grid_mm\",\n", " \"spot_perp_basis\", \"momentum\", \"mean_rev\"]\n", "\n", "# Generate correlated returns\n", "base_returns = np.random.randn(n_bars, 3) * 0.005\n", "\n", "returns = {}\n", "returns[\"pairs\"] = base_returns[:, 0] * 0.6 + np.random.randn(n_bars) * 0.003\n", "returns[\"hurst_vpin\"] = base_returns[:, 1] * 0.8 + np.random.randn(n_bars) * 0.004\n", "returns[\"cross_sectional\"] = base_returns[:, 0] * 0.3 + base_returns[:, 1] * 0.5 + np.random.randn(n_bars) * 0.003\n", "returns[\"grid_mm\"] = base_returns[:, 2] * 0.4 + np.random.randn(n_bars) * 0.002\n", "returns[\"spot_perp_basis\"] = np.random.randn(n_bars) * 0.003 # uncorrelated\n", "returns[\"momentum\"] = base_returns[:, 1] * 0.7 + np.random.randn(n_bars) * 0.004\n", "returns[\"mean_rev\"] = -base_returns[:, 0] * 0.5 + np.random.randn(n_bars) * 0.003\n", "\n", "# Add positive drift for profitable strategies\n", "for name, r in returns.items():\n", " returns[name] = r + 0.0005 # Small positive edge\n", "\n", "# Compute correlation\n", "ret_df = pd.DataFrame(returns)\n", "corr = ret_df.corr()\n", "sns.heatmap(corr, annot=True, fmt='.2f', cmap='RdBu_r', center=0,\n", " vmin=-1, vmax=1, square=True)\n", "plt.title('Strategy Return Correlation Matrix')\n", "plt.show()\n" ] }, { "cell_type": "code", "execution_count": null, "id": "2ab6b2b1", "metadata": {}, "outputs": [], "source": [ "# Build and simulate portfolio\n", "pf = PortfolioConstructor(\n", " capital=100_000,\n", " vol_target=0.20,\n", " max_correlation=0.70,\n", " max_drawdown_stop=0.15,\n", " portfolio_mdd_stop=0.10,\n", ")\n", "\n", "for name in strategy_names:\n", " pf.register_strategy(name)\n", "\n", "# Feed returns\n", "for i in range(n_bars):\n", " for name in strategy_names:\n", " pf.update_returns(name, [returns[name][i]])\n", " pf.update_portfolio_value({\n", " name: returns[name][i] * pf.capital * 0.1\n", " for name in strategy_names\n", " })\n", "\n", "# Portfolio metrics\n", "metrics = pf.summary()\n", "print(f\"=== Portfolio Metrics ===\")\n", "print(f\"Total Equity: ${metrics.total_equity:,.2f}\")\n", "print(f\"Total PnL: ${metrics.total_pnl:,.2f} ({metrics.total_pnl_pct*100:.1f}%)\")\n", "print(f\"Volatility: {metrics.vol_20d*100:.1f}%\")\n", "print(f\"Sharpe Ratio: {metrics.sharpe:.2f}\")\n", "print(f\"Sortino Ratio: {metrics.sortino:.2f}\")\n", "print(f\"Max Drawdown: {metrics.max_drawdown_pct*100:.1f}%\")\n", "print(f\"Win Rate: {metrics.win_rate*100:.0f}%\")\n", "\n", "# Equity curve\n", "eq = list(pf.portfolio_equity_history)\n", "plt.plot(eq, linewidth=0.5)\n", "plt.title('Portfolio Equity Curve')\n", "plt.ylabel('Equity ($)')\n", "plt.xlabel('Bar')\n", "plt.show()\n" ] }, { "cell_type": "markdown", "id": "ff45a282", "metadata": {}, "source": [ "## 3. Risk Decomposition\n", "\n", "Where is the risk coming from? Which strategies contribute most to drawdowns?" ] }, { "cell_type": "code", "execution_count": null, "id": "cad69d26", "metadata": {}, "outputs": [], "source": [ "# Risk attribution per strategy\n", "allocations = pf.compute_allocations({\"BTC\": 100000, \"ETH\": 3500, \"SOL\": 200, \"HYPE\": 10})\n", "print(\"=== Portfolio Allocation ===\")\n", "print(f\"{'Strategy':<20} {'Weight':>8} {'Allocation':>12} {'Vol 20d':>10}\")\n", "print(\"-\" * 55)\n", "for name in strategy_names:\n", " alloc = allocations.get(name, 0)\n", " st = pf.strategies.get(name)\n", " if st:\n", " print(f\"{name:<20} {st.weight:>7.1%} ${alloc:>10,.0f} {st.vol_20d*100:>8.1f}%\")\n", "total_alloc = sum(allocations.values())\n", "print(f\"\\n{'Total':<20} {' ':>8} ${total_alloc:>10,.0f}\")\n", "print(f\"Reserve: ${pf.capital - total_alloc:>10,.0f}\")\n", "\n", "# Drawdown per strategy\n", "print(f\"\\n=== Drawdown Analysis ===\")\n", "for name, st in pf.strategies.items():\n", " if st.peak_equity > 0:\n", " dd = (1.0 - st.equity / st.peak_equity) * 100\n", " print(f\" {name:<20s}: DD={dd:5.1f}% | Equity=${st.equity:,.0f} | Peak=${st.peak_equity:,.0f}\")\n" ] }, { "cell_type": "markdown", "id": "04372d8b", "metadata": {}, "source": [ "## 4. Regime-Adaptive Allocation\n", "\n", "Test the regime-switching ensemble: how do weights shift across regimes?" ] }, { "cell_type": "code", "execution_count": null, "id": "7a628f24", "metadata": {}, "outputs": [], "source": [ "# Simulate different regimes\n", "ensemble = RegimeEnsemble()\n", "\n", "# Seed with some signals\n", "for name in strategy_names:\n", " ensemble.update_strategy_signal(name, \"BUY\", 0.6 + np.random.random() * 0.2)\n", "\n", "# Test in different regimes by feeding artificial price patterns\n", "np.random.seed(42)\n", "\n", "print(\"=== Strategy Weights by Regime ===\\n\")\n", "\n", "# TRENDING: strong upward drift\n", "for i in range(200):\n", " ensemble.feed_price(100000 + i * 50 + np.random.randn() * 200)\n", "trending_weights = ensemble.compute_weights()\n", "print(\"TRENDING:\")\n", "for s, w in sorted(trending_weights.items(), key=lambda x: x[1], reverse=True)[:5]:\n", " print(f\" {s:20s}: {w:.1%}\")\n", "\n", "# Reset detector and test MEAN_REVERTING\n", "ensemble.detector.prices.clear()\n", "for i in range(200):\n", " px = 100000 + np.sin(i * 0.1) * 2000 + np.random.randn() * 500\n", " ensemble.feed_price(px)\n", "mr_weights = ensemble.compute_weights()\n", "print(\"\\nMEAN_REVERTING:\")\n", "for s, w in sorted(mr_weights.items(), key=lambda x: x[1], reverse=True)[:5]:\n", " print(f\" {s:20s}: {w:.1%}\")\n", "\n", "# Compare\n", "print(f\"\\n=== Weight Shift Analysis ===\")\n", "for name in sorted(strategy_names):\n", " tw = trending_weights.get(name, 0)\n", " mw = mr_weights.get(name, 0)\n", " shift = mw - tw\n", " direction = \"▲ MR\" if shift > 0.01 else (\"▼ TREND\" if shift < -0.01 else \"— same\")\n", " print(f\" {name:20s}: TR={tw:.2%} MR={mw:.2%} ({direction})\")\n" ] }, { "cell_type": "markdown", "id": "e5d9646a", "metadata": {}, "source": [ "## 5. Sharpe Decomposition\n", "\n", "Target: Sharpe > 1.5. How many strategies do we need?\n", "\n", "```\n", "Portfolio Sharpe = √N × avg(individual Sharpe) × √(1 - avg_correlation)\n", " = √N × Sᵢ × √(1 - ρ̄)\n", "```\n", "\n", "**Scenarios:**\n", "| N strategies | Avg Sharpe | Avg Corr | Portfolio Sharpe | Target? |\n", "|-------------|-----------|---------|-----------------|---------| \n", "| 3 | 1.5 | 0.3 | 2.17 | ✅ |\n", "| 5 | 1.0 | 0.2 | 2.00 | ✅ |\n", "| 5 | 0.8 | 0.5 | 1.26 | ❌ |\n", "| 7 | 1.0 | 0.3 | 2.21 | ✅ |\n", "| 7 | 0.7 | 0.2 | 1.66 | ✅ |\n", "\n", "**Conclusion:** With 5-7 strategies averaging 1.0 individual Sharpe and correlation below 0.3, we comfortably exceed Sharpe 1.5. The key is keeping correlation low — redundant strategies destroy the diversification benefit." ] }, { "cell_type": "code", "execution_count": null, "id": "68e1dac1", "metadata": {}, "outputs": [], "source": [ "def portfolio_sharpe(n_strategies, avg_sharpe, avg_correlation):\n", " return np.sqrt(n_strategies) * avg_sharpe * np.sqrt(1 - avg_correlation)\n", "\n", "# Parameter sweep\n", "ns = range(2, 11)\n", "sharpes = [0.5, 0.8, 1.0, 1.2, 1.5]\n", "corrs = [0.1, 0.2, 0.3, 0.5]\n", "\n", "print(\"=== Portfolio Sharpe Projections ===\\n\")\n", "print(f\"{'N':>3} | \", end=\"\")\n", "for s in sharpes:\n", " print(f\"Sᵢ={s:.1f} \", end=\"\")\n", "print(\"| ρ̄=0.2\")\n", "\n", "for n in ns:\n", " print(f\"{n:3d} | \", end=\"\")\n", " for s in sharpes:\n", " ps = portfolio_sharpe(n, s, 0.2)\n", " marker = \" ✅\" if ps > 1.5 else \" \"\n", " print(f\"{ps:5.2f}{marker} \", end=\"\")\n", " print()\n", "\n", "print(f\"\\nTarget line: Sharpe > 1.50\")\n", "print(f\"Bold numbers pass the target. Strategy: maximize N × Sᵢ × (1 - ρ̄)\")\n" ] }, { "cell_type": "markdown", "id": "8f68c80b", "metadata": {}, "source": [ "## 6. Deployment Pipeline\n", "\n", "The complete pipeline from idea → deployment:\n", "\n", "```\n", "IDEA → Signal Generation → VBT Backtest → Walk-Forward → \n", "→ DSR/PSR/Haircut → QuantVerdict → \n", "→ Paper Trading (24h+) → Queue Simulation → \n", "→ LIVE (1/10 size, daily PnL stop)\n", "```\n", "\n", "**Operational rules:**\n", "- Never deploy more than 2 new strategies simultaneously\n", "- Each strategy starts at 1/10 target size for 1 week\n", "- Daily PnL stop: halt strategy if -2% in one day\n", "- Weekly review: check Sharpe, DD, win rate vs. backtest\n", "- Monthly rebalancing: re-run walk-forward to update parameters\n", "- Kill switch: any strategy -15% from peak → disabled\n", "- Portfolio kill: total equity -10% from peak → all strategies paused" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.13.0" } }, "nbformat": 4, "nbformat_minor": 5 }