Spaces:
Sleeping
Sleeping
Update backtest.py
Browse files- backtest.py +57 -133
backtest.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import matplotlib
|
| 2 |
-
matplotlib.use('Agg') # Force headless mode
|
| 3 |
|
| 4 |
import yfinance as yf
|
| 5 |
import pandas as pd
|
|
@@ -7,164 +7,88 @@ import numpy as np
|
|
| 7 |
import matplotlib.pyplot as plt
|
| 8 |
import os
|
| 9 |
|
| 10 |
-
|
| 11 |
-
START_DATE = "2008-01-01" # Start from 2008 to ensure all commodities have data
|
| 12 |
INITIAL_CAPITAL = 100000
|
| 13 |
|
| 14 |
-
# --- THE ASSET BASKET ---
|
| 15 |
ASSETS = {
|
| 16 |
-
"NIFTY": "^NSEI",
|
| 17 |
-
"GOLD": "GC=F",
|
| 18 |
-
"SILVER": "SI=F",
|
| 19 |
-
"OIL": "CL=F",
|
| 20 |
-
"BONDS": "^TNX"
|
| 21 |
}
|
| 22 |
|
| 23 |
-
def
|
| 24 |
-
print(
|
| 25 |
try:
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
# 2. Fix Column Names
|
| 31 |
if isinstance(data.columns, pd.MultiIndex):
|
| 32 |
data.columns = [col[0] for col in data.columns]
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
ticker_to_name = {v: k for k, v in ASSETS.items()}
|
| 37 |
-
data = data.rename(columns=ticker_to_name)
|
| 38 |
-
|
| 39 |
-
# 3. Clean Data
|
| 40 |
-
# Forward fill to handle different market holidays (US vs India)
|
| 41 |
data = data.ffill().bfill()
|
| 42 |
|
| 43 |
-
#
|
| 44 |
-
# We take the last price of each month
|
| 45 |
monthly_data = data.resample('M').last()
|
|
|
|
| 46 |
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
except Exception as e:
|
| 50 |
-
print(f"❌ Data Fetch Error: {e}")
|
| 51 |
-
return None, None
|
| 52 |
-
|
| 53 |
-
def strategy_momentum_rotation(daily_data, monthly_data):
|
| 54 |
-
"""
|
| 55 |
-
Logic:
|
| 56 |
-
1. Calculate 6-Month Returns (Momentum) for all assets.
|
| 57 |
-
2. Rank them.
|
| 58 |
-
3. Pick Top 2 Winners every month.
|
| 59 |
-
4. Trend Filter: Only buy if Price > 200 Day SMA (Daily check).
|
| 60 |
-
"""
|
| 61 |
-
print("⚙️ Running Momentum Rotation Engine...")
|
| 62 |
-
|
| 63 |
-
# --- STEP 1: MOMENTUM CALCULATION (Monthly) ---
|
| 64 |
-
# Calculate 6-month rolling return
|
| 65 |
-
momentum = monthly_data.pct_change(6)
|
| 66 |
-
|
| 67 |
-
# We exclude 'BONDS' from the momentum race (it's just a fallback)
|
| 68 |
-
tradeable_assets = ["NIFTY", "GOLD", "SILVER", "OIL"]
|
| 69 |
-
mom_scores = momentum[tradeable_assets]
|
| 70 |
-
|
| 71 |
-
# --- STEP 2: RANKING ---
|
| 72 |
-
# Rank assets 1 to 4 (4 is best, 1 is worst)
|
| 73 |
-
ranks = mom_scores.rank(axis=1, ascending=True)
|
| 74 |
-
|
| 75 |
-
# Create Weights: Top 2 assets get 50% each (0.5), others 0
|
| 76 |
-
# Logic: If Rank >= 3 (Top 2 out of 4), Weight = 0.5
|
| 77 |
-
target_weights = (ranks >= 3).astype(float) * 0.5
|
| 78 |
-
|
| 79 |
-
# --- STEP 3: APPLY TO DAILY DATA (Rebalance) ---
|
| 80 |
-
# We align monthly weights to daily data
|
| 81 |
-
# .shift(1) prevents look-ahead bias (we use last month's data to trade this month)
|
| 82 |
-
daily_weights = target_weights.reindex(daily_data.index).ffill().shift(1)
|
| 83 |
-
|
| 84 |
-
# --- STEP 4: TREND FILTER (Safety Check) ---
|
| 85 |
-
# Calculate 200-day SMA on daily data
|
| 86 |
-
sma_200 = daily_data[tradeable_assets].rolling(200).mean()
|
| 87 |
-
|
| 88 |
-
# Trend Rule: If Price < SMA200, Force Weight to 0 (Move that portion to Cash/Bonds)
|
| 89 |
-
trend_filter = (daily_data[tradeable_assets] > sma_200).astype(int)
|
| 90 |
-
|
| 91 |
-
# Final Weights = Momentum Weight * Trend Filter
|
| 92 |
-
final_weights = daily_weights * trend_filter
|
| 93 |
-
|
| 94 |
-
# Whatever is not invested goes to BONDS/CASH
|
| 95 |
-
# Ex: If we only hold 1 asset (0.5), remainder (0.5) goes to Bonds.
|
| 96 |
-
invested_sum = final_weights.sum(axis=1)
|
| 97 |
-
cash_weight = 1.0 - invested_sum
|
| 98 |
-
|
| 99 |
-
return final_weights, cash_weight
|
| 100 |
-
|
| 101 |
-
def backtest_engine():
|
| 102 |
-
# 1. Get Data
|
| 103 |
-
daily_df, monthly_df = fetch_data()
|
| 104 |
-
if daily_df is None: return "Error: No Data"
|
| 105 |
-
|
| 106 |
-
# 2. Run Strategy
|
| 107 |
-
asset_weights, cash_weight = strategy_momentum_rotation(daily_df, monthly_df)
|
| 108 |
-
|
| 109 |
-
# 3. Calculate Portfolio Returns
|
| 110 |
-
# Asset Returns
|
| 111 |
-
asset_rets = daily_df[asset_weights.columns].pct_change()
|
| 112 |
-
|
| 113 |
-
# Weighted Returns (Asset W * Asset Ret)
|
| 114 |
-
port_asset_ret = (asset_weights * asset_rets).sum(axis=1)
|
| 115 |
-
|
| 116 |
-
# Cash/Bond Return (Assume flat 4% annual risk-free if Bonds drop, or use Bond ETF return)
|
| 117 |
-
# Using small constant return for cash component to simplify
|
| 118 |
-
risk_free_daily = 0.04 / 252
|
| 119 |
-
port_cash_ret = cash_weight * risk_free_daily
|
| 120 |
-
|
| 121 |
-
# Total Strategy Return
|
| 122 |
-
strategy_ret = port_asset_ret + port_cash_ret
|
| 123 |
-
|
| 124 |
-
# Benchmark Return (Nifty Buy & Hold)
|
| 125 |
-
benchmark_ret = daily_df['NIFTY'].pct_change()
|
| 126 |
-
|
| 127 |
-
# 4. Wealth Index
|
| 128 |
-
daily_df['Strategy_Wealth'] = INITIAL_CAPITAL * (1 + strategy_ret).cumprod()
|
| 129 |
-
daily_df['Benchmark_Wealth'] = INITIAL_CAPITAL * (1 + benchmark_ret).cumprod()
|
| 130 |
-
|
| 131 |
-
# 5. Plotting
|
| 132 |
-
output_file = "backtest_result.png"
|
| 133 |
-
try:
|
| 134 |
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), gridspec_kw={'height_ratios': [2, 1]})
|
| 135 |
|
| 136 |
-
|
| 137 |
-
ax1.plot(
|
| 138 |
-
ax1.plot(daily_df.index, daily_df['Benchmark_Wealth'], label='Nifty 50 Buy & Hold', color='gray', linestyle='--', alpha=0.6)
|
| 139 |
-
ax1.set_title("Strategy vs Benchmark (2008-Present)")
|
| 140 |
-
ax1.set_ylabel("Wealth (INR)")
|
| 141 |
ax1.set_yscale('log')
|
| 142 |
ax1.legend()
|
| 143 |
-
ax1.
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
asset_weights['NIFTY'], asset_weights['GOLD'],
|
| 149 |
-
asset_weights['SILVER'], asset_weights['OIL'],
|
| 150 |
-
cash_weight,
|
| 151 |
-
labels=['Nifty', 'Gold', 'Silver', 'Oil', 'Cash'],
|
| 152 |
-
alpha=0.6)
|
| 153 |
-
ax2.set_title("Dynamic Asset Allocation")
|
| 154 |
-
ax2.set_ylabel("Weight")
|
| 155 |
ax2.legend(loc='upper left', fontsize='small')
|
|
|
|
| 156 |
|
| 157 |
plt.tight_layout()
|
| 158 |
-
|
| 159 |
-
# Save
|
| 160 |
-
if os.path.exists(output_file): os.remove(output_file)
|
| 161 |
-
plt.savefig(output_file, bbox_inches='tight')
|
| 162 |
plt.close()
|
| 163 |
|
| 164 |
return output_file
|
| 165 |
-
|
| 166 |
except Exception as e:
|
| 167 |
-
print(f"
|
| 168 |
return None
|
| 169 |
|
| 170 |
if __name__ == "__main__":
|
|
|
|
| 1 |
import matplotlib
|
| 2 |
+
matplotlib.use('Agg') # Force headless mode
|
| 3 |
|
| 4 |
import yfinance as yf
|
| 5 |
import pandas as pd
|
|
|
|
| 7 |
import matplotlib.pyplot as plt
|
| 8 |
import os
|
| 9 |
|
| 10 |
+
START_DATE = "2008-01-01"
|
|
|
|
| 11 |
INITIAL_CAPITAL = 100000
|
| 12 |
|
|
|
|
| 13 |
ASSETS = {
|
| 14 |
+
"NIFTY": "^NSEI",
|
| 15 |
+
"GOLD": "GC=F",
|
| 16 |
+
"SILVER": "SI=F",
|
| 17 |
+
"OIL": "CL=F",
|
| 18 |
+
"BONDS": "^TNX"
|
| 19 |
}
|
| 20 |
|
| 21 |
+
def backtest_engine():
|
| 22 |
+
print("⚙️ Fetching Multi-Asset Basket...")
|
| 23 |
try:
|
| 24 |
+
data = yf.download(list(ASSETS.values()), start=START_DATE, progress=False)['Close']
|
| 25 |
+
if data.empty: return None
|
| 26 |
+
|
| 27 |
+
# Clean Headers
|
|
|
|
| 28 |
if isinstance(data.columns, pd.MultiIndex):
|
| 29 |
data.columns = [col[0] for col in data.columns]
|
| 30 |
|
| 31 |
+
mapping = {v: k for k, v in ASSETS.items()}
|
| 32 |
+
data = data.rename(columns=mapping)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
data = data.ffill().bfill()
|
| 34 |
|
| 35 |
+
# Strategy: Monthly Momentum Rotation
|
|
|
|
| 36 |
monthly_data = data.resample('M').last()
|
| 37 |
+
momentum = monthly_data.pct_change(6) # 6-month momentum
|
| 38 |
|
| 39 |
+
# Rank assets
|
| 40 |
+
tradeable = ["NIFTY", "GOLD", "SILVER", "OIL"]
|
| 41 |
+
ranks = momentum[tradeable].rank(axis=1)
|
| 42 |
+
|
| 43 |
+
# Top 2 get 50% each
|
| 44 |
+
target_weights = (ranks >= 3).astype(float) * 0.5
|
| 45 |
+
|
| 46 |
+
# Shift to avoid look-ahead bias and align to daily
|
| 47 |
+
daily_weights = target_weights.reindex(data.index).ffill().shift(1)
|
| 48 |
+
|
| 49 |
+
# Trend Filter (Price > 200 SMA)
|
| 50 |
+
sma_200 = data[tradeable].rolling(200).mean()
|
| 51 |
+
trend_filter = (data[tradeable] > sma_200).astype(int)
|
| 52 |
+
|
| 53 |
+
# Final Allocation
|
| 54 |
+
final_weights = daily_weights * trend_filter
|
| 55 |
+
cash_weight = 1.0 - final_weights.sum(axis=1)
|
| 56 |
+
|
| 57 |
+
# Returns
|
| 58 |
+
port_ret = (final_weights * data[tradeable].pct_change()).sum(axis=1)
|
| 59 |
+
# Add small risk-free rate for cash
|
| 60 |
+
port_ret += cash_weight * (0.04/252)
|
| 61 |
+
|
| 62 |
+
# Wealth
|
| 63 |
+
data['Strategy'] = INITIAL_CAPITAL * (1 + port_ret).cumprod()
|
| 64 |
+
data['Nifty_BuyHold'] = INITIAL_CAPITAL * (1 + data['NIFTY'].pct_change()).cumprod()
|
| 65 |
+
|
| 66 |
+
# Plot
|
| 67 |
+
output_file = "backtest_result.png"
|
| 68 |
+
if os.path.exists(output_file): os.remove(output_file)
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), gridspec_kw={'height_ratios': [2, 1]})
|
| 71 |
|
| 72 |
+
ax1.plot(data.index, data['Strategy'], label='Momentum Rotation', color='blue')
|
| 73 |
+
ax1.plot(data.index, data['Nifty_BuyHold'], label='Nifty Only', color='grey', linestyle='--')
|
|
|
|
|
|
|
|
|
|
| 74 |
ax1.set_yscale('log')
|
| 75 |
ax1.legend()
|
| 76 |
+
ax1.set_title("Strategy vs Benchmark")
|
| 77 |
|
| 78 |
+
ax2.stackplot(data.index, final_weights['NIFTY'], final_weights['GOLD'],
|
| 79 |
+
final_weights['SILVER'], final_weights['OIL'], cash_weight,
|
| 80 |
+
labels=['Nifty', 'Gold', 'Silver', 'Oil', 'Cash'], alpha=0.6)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
ax2.legend(loc='upper left', fontsize='small')
|
| 82 |
+
ax2.set_title("Asset Allocation")
|
| 83 |
|
| 84 |
plt.tight_layout()
|
| 85 |
+
plt.savefig(output_file)
|
|
|
|
|
|
|
|
|
|
| 86 |
plt.close()
|
| 87 |
|
| 88 |
return output_file
|
| 89 |
+
|
| 90 |
except Exception as e:
|
| 91 |
+
print(f"Backtest Error: {e}")
|
| 92 |
return None
|
| 93 |
|
| 94 |
if __name__ == "__main__":
|