Spaces:
Running
Running
File size: 4,678 Bytes
2a8aa76 56e8cf6 4c49a9f 2a8aa76 57bdf72 8748d1b 57bdf72 56e8cf6 8748d1b 57bdf72 8748d1b 57bdf72 8748d1b 56e8cf6 8748d1b 8455d80 56e8cf6 8748d1b 8455d80 8748d1b 57bdf72 56e8cf6 8748d1b 8455d80 8748d1b 8455d80 56e8cf6 8748d1b 56e8cf6 8748d1b 8455d80 8748d1b 8455d80 8748d1b 8455d80 574245e 8748d1b 8455d80 8748d1b 4c49a9f 8748d1b ee3672b def695a 8748d1b 56e8cf6 4c49a9f 9f750fe 8748d1b 9f750fe 8455d80 8748d1b 56e8cf6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 | import pandas as pd
import numpy as np
import pandas_market_calendars as mcal
def run_trend_module(price_df, bench_series, sofr_series, target_vol, start_yr, sub_option):
"""
Quantitative Engine based on Zarattini & Antonacci (2025).
Implements Volatility Targeting and Conviction-based ETF Allocation.
"""
# --- 1. DATA CLEANING & PREPARATION ---
# Forward fill holes and drop assets with no data in the current window
price_df = price_df.ffill()
# Ensure benchmarks and SOFR are aligned
sofr_series = sofr_series.ffill()
# --- 2. TREND & CONVICTION SIGNALS ---
sma_200 = price_df.rolling(200).mean()
sma_50 = price_df.rolling(50).mean()
# Conviction = % distance above the 200 SMA (momentum strength)
conviction_score = (price_df / sma_200) - 1
# Basic Signal: 50 SMA > 200 SMA
base_signals = (sma_50 > sma_200).astype(int)
# --- 3. CONVICTION FILTERING (Sub-Options) ---
if sub_option == "3 Highest Conviction":
# Rank assets daily; 1 is highest conviction.
# Only assets in a base trend (base_signals == 1) are eligible for ranking.
ranked_conviction = conviction_score.where(base_signals == 1)
ranks = ranked_conviction.rank(axis=1, ascending=False)
final_signals = ((ranks <= 3)).astype(int)
elif sub_option == "1 Highest Conviction":
ranked_conviction = conviction_score.where(base_signals == 1)
ranks = ranked_conviction.rank(axis=1, ascending=False)
final_signals = ((ranks <= 1)).astype(int)
else:
# "All Trending ETFs"
final_signals = base_signals
# --- 4. VOLATILITY TARGETING (RISK BUDGETING) ---
returns = price_df.pct_change()
# 60-day Annualized Realized Volatility
asset_vol = returns.rolling(60).std() * np.sqrt(252)
# Safety: If vol is NaN or 0, set to a very high number to prevent infinite weights
asset_vol = asset_vol.replace(0, np.nan).fillna(9.99)
# Methodology: Target Vol / Asset Vol, distributed across active signals
active_counts = final_signals.sum(axis=1)
# Avoid division by zero if no assets are in trend
raw_weights = (target_vol / asset_vol).divide(active_counts, axis=0).replace([np.inf, -np.inf], 0).fillna(0)
# Multiply by signals to zero out non-trending assets
final_weights = raw_weights * final_signals
# --- 5. EXPOSURE & LEVERAGE MANAGEMENT ---
total_exposure = final_weights.sum(axis=1)
# Cap total gross leverage at 1.5x (150%)
leverage_cap = 1.5
scale_factor = total_exposure.apply(lambda x: leverage_cap/x if x > leverage_cap else 1.0)
final_weights = final_weights.multiply(scale_factor, axis=0)
# --- 6. CASH (SOFR) ALLOCATION ---
# Remainder of the 100% capital not used in the risk budget goes to SOFR
final_exposure = final_weights.sum(axis=1)
cash_weight = 1.0 - final_exposure
# --- 7. PERFORMANCE CALCULATION ---
# Strategy Return = (Weights * Asset Returns) + (Cash Weight * SOFR)
# We shift weights by 1 to prevent look-ahead bias (trading at today's close for tomorrow)
portfolio_ret = (final_weights.shift(1) * returns).sum(axis=1)
portfolio_ret += cash_weight.shift(1) * (sofr_series.shift(1) / 252)
# --- 8. OUT-OF-SAMPLE (OOS) METRICS ---
oos_mask = portfolio_ret.index.year >= start_yr
oos_returns = portfolio_ret[oos_mask]
equity_curve = (1 + oos_returns).cumprod()
bench_returns = bench_series.pct_change().fillna(0)[oos_mask]
bench_curve = (1 + bench_returns).cumprod()
# Drawdowns
dd_series = (equity_curve / equity_curve.cummax()) - 1
# Stats
ann_ret = oos_returns.mean() * 252
ann_vol = oos_returns.std() * np.sqrt(252)
current_sofr = sofr_series.ffill().iloc[-1]
# Sharpe Ratio: (Return - RiskFree) / Vol
sharpe = (ann_ret - current_sofr) / ann_vol if ann_vol > 0 else 0
# --- 9. NEXT TRADING DAY CALENDAR ---
nyse = mcal.get_calendar('NYSE')
# Anchor to system clock to ensure we always look FORWARD
today_dt = pd.Timestamp.now().normalize()
search_start = today_dt + pd.Timedelta(days=1)
sched = nyse.schedule(start_date=search_start, end_date=search_start + pd.Timedelta(days=10))
next_day = sched.index[0]
return {
'equity_curve': equity_curve,
'bench_curve': bench_curve,
'ann_ret': ann_ret,
'sharpe': sharpe,
'max_dd': dd_series.min(),
'next_day': next_day.date(),
'current_weights': final_weights.iloc[-1],
'cash_weight': cash_weight.iloc[-1],
'current_sofr': current_sofr
}
|