File size: 8,273 Bytes
620004a
3ac23cd
620004a
 
 
 
c3d6bbe
620004a
 
 
496032f
 
 
3ac23cd
 
 
 
 
 
496032f
 
 
3ac23cd
b0c5d87
3ac23cd
7605749
fb162a4
 
 
 
 
 
 
 
 
 
 
 
 
3ac23cd
fb162a4
496032f
f1ea46e
 
 
 
fb162a4
 
 
f1ea46e
3ac23cd
 
721b886
fb162a4
721b886
 
 
fb162a4
f1ea46e
 
 
 
99ab553
 
 
721b886
f1ea46e
fb162a4
99ab553
fb162a4
99ab553
 
 
 
 
f1ea46e
721b886
fb162a4
f1ea46e
 
3ac23cd
fb162a4
 
 
b0c5d87
f1ea46e
b0c5d87
3ac23cd
 
 
 
b0c5d87
3ac23cd
b0c5d87
 
a9781e5
3ac23cd
a9781e5
 
 
 
 
3ac23cd
 
a9781e5
 
3ac23cd
a9781e5
b0c5d87
3ac23cd
b0c5d87
496032f
620004a
f1ea46e
fb162a4
b0c5d87
 
 
496032f
f1ea46e
 
 
721b886
8cb7f5b
94d99f9
fb162a4
 
 
 
 
8cb7f5b
c3d6bbe
fb162a4
3ac23cd
27c2334
3ac23cd
 
721b886
 
c3d6bbe
496032f
620004a
496032f
f1ea46e
3ac23cd
 
620004a
3ac23cd
f1ea46e
 
3ac23cd
 
620004a
 
3ac23cd
496032f
3ac23cd
 
496032f
 
 
620004a
 
 
3ac23cd
 
 
 
 
 
 
 
 
620004a
 
3ac23cd
 
 
 
 
620004a
3ac23cd
620004a
 
 
 
3ac23cd
 
 
 
 
 
 
620004a
 
3ac23cd
 
 
 
620004a
 
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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
"""
Backtesting and strategy execution logic β€” TFT model only.
"""

import numpy as np
import pandas as pd
from datetime import datetime
from utils import filter_to_trading_days, get_next_trading_day


def compute_signal_conviction(raw_scores):
    best_idx = int(np.argmax(raw_scores))
    mean = np.mean(raw_scores)
    std  = np.std(raw_scores)
    z = 0.0 if std < 1e-9 else (raw_scores[best_idx] - mean) / std
    if   z >= 2.0: label = "Very High"
    elif z >= 1.0: label = "High"
    elif z >= 0.0: label = "Moderate"
    else:          label = "Low"
    return best_idx, z, label


def execute_strategy(proba, y_fwd_test, test_dates, target_etfs, fee_bps,
                     stop_loss_pct=-0.12, z_reentry=1.0,
                     sofr=0.045, z_min_entry=0.5,
                     daily_ret_override=None):
    """
    Execute strategy on test set.
    NOTE: We do NOT filter by NYSE calendar here β€” test_dates already come
    from the dataset which only contains trading days. Calendar filtering
    was dropping recent dates due to calendar library lag.
    """
    # Convert to pandas DatetimeIndex if needed
    if not isinstance(test_dates, pd.DatetimeIndex):
        test_dates = pd.DatetimeIndex(test_dates)

    # Convert arrays to numpy
    proba      = np.array(proba)
    y_fwd_test = np.array(y_fwd_test)
    if daily_ret_override is not None:
        daily_ret_override = np.array(daily_ret_override)

    strat_rets      = []
    audit_trail     = []
    daily_rf        = sofr / 252
    stop_active     = False
    recent_rets     = []   # last 2 net returns for stop-loss check
    top_pick_rets   = []   # last 5 TOP-PICK actual daily returns for rotation
    rotated_etf_idx = None
    today           = datetime.now().date()

    for i in range(len(proba)):
        day_scores     = np.array(proba[i], dtype=float)
        ranked_indices = np.argsort(day_scores)[::-1]
        best_idx       = int(ranked_indices[0])
        second_idx     = int(ranked_indices[1]) if len(ranked_indices) > 1 else best_idx

        # Top pick's actual daily return (always track #1 pick for rotation)
        top_actual = (float(daily_ret_override[i][best_idx])
                      if daily_ret_override is not None
                      else float(y_fwd_test[i][best_idx]))

        # ── 5-day cumulative loss rotation ───────────────────────────────────
        # Rule: if top pick's 5-day cumulative return < 0 β†’ rotate to #2
        # Recovery: as soon as top pick has a positive day β†’ rotate back to #1
        if rotated_etf_idx is not None:
            if top_actual > 0:
                rotated_etf_idx = None

        if rotated_etf_idx is None and len(top_pick_rets) >= 5:
            cum_5d = 1.0
            for r in top_pick_rets[-5:]:
                cum_5d *= (1 + r)
            cum_5d -= 1.0
            if cum_5d < 0:
                rotated_etf_idx = second_idx

        active_idx  = rotated_etf_idx if rotated_etf_idx is not None else best_idx
        _, day_z, _ = compute_signal_conviction(day_scores)
        signal_etf  = target_etfs[active_idx].replace('_Ret', '')

        realized_ret = (float(daily_ret_override[i][active_idx])
                        if daily_ret_override is not None
                        else float(y_fwd_test[i][active_idx]))

        # ── Stop-loss + conviction gate ───────────────────────────────────────
        if stop_active:
            if day_z >= z_reentry and day_z >= z_min_entry:
                stop_active  = False
                net_ret      = realized_ret - (fee_bps / 10000)
                trade_signal = signal_etf
            else:
                net_ret      = daily_rf
                trade_signal = "CASH"
        else:
            if day_z < z_min_entry:
                net_ret      = daily_rf
                trade_signal = "CASH"
            else:
                if len(recent_rets) >= 2:
                    cum_2d = (1 + recent_rets[-2]) * (1 + recent_rets[-1]) - 1
                    if cum_2d <= stop_loss_pct:
                        stop_active  = True
                        net_ret      = daily_rf
                        trade_signal = "CASH"
                    else:
                        net_ret      = realized_ret - (fee_bps / 10000)
                        trade_signal = signal_etf
                else:
                    net_ret      = realized_ret - (fee_bps / 10000)
                    trade_signal = signal_etf

        strat_rets.append(net_ret)

        # ── Update rolling buffers AFTER decision ────────────────────────────
        recent_rets.append(net_ret)
        if len(recent_rets) > 2:
            recent_rets.pop(0)

        top_pick_rets.append(top_actual)
        if len(top_pick_rets) > 5:
            top_pick_rets.pop(0)

        # Audit trail: only show CLOSED trading days (exclude today β€” market not yet closed)
        trade_date = test_dates[i]
        if hasattr(trade_date, 'date'):
            trade_date_val = trade_date.date()
        else:
            trade_date_val = trade_date

        if trade_date_val < today:
            audit_trail.append({
                'Date':         trade_date_val.strftime('%Y-%m-%d'),
                'Signal':       trade_signal,
                'Top_Pick':     target_etfs[best_idx].replace('_Ret', ''),
                'Conviction_Z': round(day_z, 2),
                'Net_Return':   round(net_ret, 5),
                'Stop_Active':  stop_active,
                'Rotated':      rotated_etf_idx is not None
            })

    strat_rets = np.array(strat_rets)

    # ── Next trading day signal ───────────────────────────────────────────────
    if len(test_dates) > 0 and len(proba) > 0:
        last_date         = test_dates[-1]
        next_trading_date = get_next_trading_day(last_date)
        last_scores       = np.array(proba[-1], dtype=float)
        next_best_idx, conviction_zscore, conviction_label = \
            compute_signal_conviction(last_scores)
        next_signal    = target_etfs[next_best_idx].replace('_Ret', '')
        all_etf_scores = last_scores
    else:
        next_trading_date = datetime.now().date()
        next_signal       = "CASH"
        conviction_zscore = 0.0
        conviction_label  = "Low"
        all_etf_scores    = np.zeros(len(target_etfs))

    return (strat_rets, audit_trail, next_signal, next_trading_date,
            conviction_zscore, conviction_label, all_etf_scores)


def calculate_metrics(strat_rets, sofr_rate=0.045):
    cum_returns  = np.cumprod(1 + strat_rets)
    ann_return   = (cum_returns[-1] ** (252 / len(strat_rets))) - 1
    sharpe       = ((np.mean(strat_rets) - sofr_rate / 252) /
                    (np.std(strat_rets) + 1e-9) * np.sqrt(252))
    recent_rets  = strat_rets[-15:]
    hit_ratio    = np.mean(recent_rets > 0)
    cum_max      = np.maximum.accumulate(cum_returns)
    drawdown     = (cum_returns - cum_max) / cum_max
    max_dd       = np.min(drawdown)
    max_daily_dd = np.min(strat_rets)
    return {
        'cum_returns':  cum_returns,
        'ann_return':   ann_return,
        'sharpe':       sharpe,
        'hit_ratio':    hit_ratio,
        'max_dd':       max_dd,
        'max_daily_dd': max_daily_dd,
        'cum_max':      cum_max
    }


def calculate_benchmark_metrics(benchmark_returns, sofr_rate=0.045):
    cum_returns  = np.cumprod(1 + benchmark_returns)
    ann_return   = (cum_returns[-1] ** (252 / len(benchmark_returns))) - 1
    sharpe       = ((np.mean(benchmark_returns) - sofr_rate / 252) /
                    (np.std(benchmark_returns) + 1e-9) * np.sqrt(252))
    cum_max      = np.maximum.accumulate(cum_returns)
    dd           = (cum_returns - cum_max) / cum_max
    max_dd       = np.min(dd)
    max_daily_dd = np.min(benchmark_returns)
    return {
        'cum_returns':  cum_returns,
        'ann_return':   ann_return,
        'sharpe':       sharpe,
        'max_dd':       max_dd,
        'max_daily_dd': max_daily_dd
    }