0446443d36
New strategies: - Cross-Sectional Momentum: long top-N, short bottom-N across HL universe - Spot-Perp Basis Arbitrage: delta-neutral spot vs perp price gap trading - Regime-Switching Ensemble: dynamically allocates strategies by market regime - Portfolio Construction: risk parity, vol targeting, correlation penalty Infrastructure: - DuckDBDataProvider: real tick/candle data for backtests (replaces synthetic) - Walk-Forward Validation: systematic IS/OOS across all 12 strategies - 3 Jupyter research notebooks (EDA, strategy research, portfolio) Pipeline integration: - deploy.py registry, sweep_runner, vbt_runner all updated - fee_tiers support for new strategies - All modules syntax-validated and import-tested
382 lines
14 KiB
Plaintext
382 lines
14 KiB
Plaintext
{
|
||
"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
|
||
}
|