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
    }