P2SAMAPA commited on
Commit
3ac23cd
·
unverified ·
1 Parent(s): 40ed09a

Update strategy.py

Browse files
Files changed (1) hide show
  1. strategy.py +82 -175
strategy.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Backtesting and strategy execution logic
3
  """
4
 
5
  import numpy as np
@@ -9,141 +9,73 @@ from utils import filter_to_trading_days, get_next_trading_day
9
 
10
 
11
  def compute_signal_conviction(raw_scores):
12
- """
13
- Compute Z-score conviction for the selected ETF signal.
14
-
15
- Args:
16
- raw_scores: 1-D numpy array of model scores/probabilities for each ETF
17
- (e.g. class probabilities from RF/XGB, or raw return preds)
18
-
19
- Returns:
20
- best_idx : index of the chosen ETF
21
- z_score : z-score of the best ETF score vs the distribution of all scores
22
- conviction : human-readable label (Very High / High / Moderate / Low)
23
- """
24
  best_idx = int(np.argmax(raw_scores))
25
  mean = np.mean(raw_scores)
26
- std = np.std(raw_scores)
27
-
28
- if std < 1e-9:
29
- z = 0.0
30
- else:
31
- z = (raw_scores[best_idx] - mean) / std
32
-
33
- if z >= 2.0:
34
- label = "Very High"
35
- elif z >= 1.0:
36
- label = "High"
37
- elif z >= 0.0:
38
- label = "Moderate"
39
- else:
40
- label = "Low"
41
-
42
  return best_idx, z, label
43
 
44
 
45
- def execute_strategy(preds, y_raw_test, test_dates, target_etfs, fee_bps,
46
- model_type="ensemble",
47
  stop_loss_pct=-0.12, z_reentry=1.0,
48
- sofr=0.045, all_proba=None, z_min_entry=0.5,
49
  daily_ret_override=None):
50
- """
51
- Execute trading strategy with T+1 execution, trailing stop-loss,
52
- and minimum conviction gate.
53
-
54
- Stop-loss : if 2-day cumulative return ≤ stop_loss_pct → CASH earning Rf
55
- Re-entry : return to ETF when conviction Z-score ≥ z_reentry
56
- Conviction gate : only enter ETF if conviction Z-score ≥ z_min_entry
57
- daily_ret_override: if provided, use these actual daily returns for P&L
58
- instead of y_raw_test (used when model trained on
59
- multi-day forward returns but P&L should be daily)
60
- """
61
-
62
- # Filter to only trading days
63
- if model_type == "ensemble":
64
- filtered_dates, filtered_data = filter_to_trading_days(
65
- test_dates, [preds, y_raw_test]
66
- )
67
- preds, y_raw_test = filtered_data
68
- if all_proba is not None:
69
- _, [all_proba] = filter_to_trading_days(test_dates, [all_proba])
70
- else:
71
- filtered_dates, filtered_data = filter_to_trading_days(
72
- test_dates, [preds, y_raw_test]
73
- )
74
- preds, y_test = filtered_data
75
 
 
76
  test_dates = filtered_dates
 
 
 
 
77
 
78
- strat_rets = []
79
  audit_trail = []
80
- daily_rf = sofr / 252 # daily risk-free rate earned while in CASH
81
-
82
- stop_active = False # True = stop triggered, holding CASH
83
- recent_rets = [] # rolling buffer for 2-day cumulative return check
84
-
85
- num_realized = len(preds)
86
- today = datetime.now().date()
87
-
88
- for i in range(num_realized):
89
- # ── Get model scores for conviction Z-score ──────────────────────────
90
- if model_type == "ensemble":
91
- best_idx = int(preds[i])
92
- signal_etf = target_etfs[best_idx].replace('_Ret', '')
93
- # Use daily return for P&L if override provided, else use target
94
- if daily_ret_override is not None:
95
- realized_ret = daily_ret_override[i][best_idx]
96
- else:
97
- realized_ret = y_raw_test[i][best_idx]
98
- # Use full per-day probabilities if available, else one-hot
99
- if all_proba is not None:
100
- day_scores = np.array(all_proba[i], dtype=float)
101
- else:
102
- day_scores = np.zeros(len(target_etfs))
103
- day_scores[best_idx] = 1.0
104
  else:
105
- best_idx = np.argmax(preds[i])
106
- signal_etf = target_etfs[best_idx].replace('_Ret', '')
107
- realized_ret = y_test[i][best_idx]
108
- day_scores = np.array(preds[i], dtype=float)
109
-
110
- # ── Conviction Z-score for today ─────────────────────────────────────
111
- _, day_z, _ = compute_signal_conviction(day_scores)
112
 
113
- # ── Stop-loss logic ──────────────────────────────────────────────────
114
  if stop_active:
115
- # Stay in CASH until conviction Z-score exceeds re-entry threshold
116
- if day_z >= z_reentry:
117
- stop_active = False
118
- # Re-enter only if conviction also meets minimum entry bar
119
- if day_z >= z_min_entry:
120
- net_ret = realized_ret - (fee_bps / 10000)
121
- trade_signal = signal_etf
122
- else:
123
- net_ret = daily_rf
124
- trade_signal = "CASH"
125
  else:
126
- # Remain in CASH — earn daily Rf, no fee
127
- net_ret = daily_rf
128
  trade_signal = "CASH"
129
  else:
130
- # ── Conviction gate: only trade if model is decisive enough ──────
131
  if day_z < z_min_entry:
132
- net_ret = daily_rf
133
  trade_signal = "CASH"
134
  else:
135
- # Check 2-day cumulative return for stop trigger
136
  if len(recent_rets) >= 2:
137
  cum_2d = (1 + recent_rets[-2]) * (1 + recent_rets[-1]) - 1
138
  if cum_2d <= stop_loss_pct:
139
- stop_active = True
140
- net_ret = daily_rf # switch to CASH immediately today
141
  trade_signal = "CASH"
142
  else:
143
- net_ret = realized_ret - (fee_bps / 10000)
144
  trade_signal = signal_etf
145
  else:
146
- net_ret = realized_ret - (fee_bps / 10000)
147
  trade_signal = signal_etf
148
 
149
  strat_rets.append(net_ret)
@@ -154,94 +86,69 @@ def execute_strategy(preds, y_raw_test, test_dates, target_etfs, fee_bps,
154
  trade_date = test_dates[i]
155
  if trade_date.date() < today:
156
  audit_trail.append({
157
- 'Date': trade_date.strftime('%Y-%m-%d'),
158
- 'Signal': trade_signal,
159
- 'Realized': realized_ret,
160
- 'Net_Return': net_ret,
161
- 'Stop_Active': stop_active
 
162
  })
163
 
164
  strat_rets = np.array(strat_rets)
165
 
166
- # ── Next signal with conviction ──────────────────────────────────────────
167
- if len(test_dates) > 0:
168
- last_date = test_dates[-1]
169
  next_trading_date = get_next_trading_day(last_date)
170
-
171
- if len(preds) > 0:
172
- last_pred = preds[-1]
173
-
174
- if model_type == "ensemble":
175
- # Ensemble preds are class indices — we can't get per-ETF scores
176
- # directly from a single integer. Use one-hot as a proxy so
177
- # conviction always reflects "model chose this one class".
178
- # If you later expose predict_proba, pass that here instead.
179
- scores = np.zeros(len(target_etfs))
180
- scores[int(last_pred)] = 1.0
181
- else:
182
- scores = np.array(last_pred, dtype=float)
183
-
184
- next_best_idx, conviction_zscore, conviction_label = compute_signal_conviction(scores)
185
- next_signal = target_etfs[next_best_idx].replace('_Ret', '')
186
- all_etf_scores = scores
187
- else:
188
- next_signal = "CASH"
189
- conviction_zscore = 0.0
190
- conviction_label = "Low"
191
- all_etf_scores = np.zeros(len(target_etfs))
192
  else:
193
  next_trading_date = datetime.now().date()
194
- next_signal = "CASH"
195
  conviction_zscore = 0.0
196
- conviction_label = "Low"
197
- all_etf_scores = np.zeros(len(target_etfs))
198
 
199
  return (strat_rets, audit_trail, next_signal, next_trading_date,
200
  conviction_zscore, conviction_label, all_etf_scores)
201
 
202
 
203
  def calculate_metrics(strat_rets, sofr_rate=0.045):
204
- """Calculate strategy performance metrics"""
205
- cum_returns = np.cumprod(1 + strat_rets)
206
- ann_return = (cum_returns[-1] ** (252 / len(strat_rets))) - 1
207
- sharpe = (np.mean(strat_rets) - (sofr_rate / 252)) / (np.std(strat_rets) + 1e-9) * np.sqrt(252)
208
-
209
- recent_rets = strat_rets[-15:]
210
- hit_ratio = np.mean(recent_rets > 0)
211
-
212
- cum_max = np.maximum.accumulate(cum_returns)
213
- drawdown = (cum_returns - cum_max) / cum_max
214
- max_dd = np.min(drawdown)
215
-
216
  max_daily_dd = np.min(strat_rets)
217
-
218
  return {
219
- 'cum_returns': cum_returns,
220
- 'ann_return': ann_return,
221
- 'sharpe': sharpe,
222
- 'hit_ratio': hit_ratio,
223
- 'max_dd': max_dd,
224
  'max_daily_dd': max_daily_dd,
225
- 'cum_max': cum_max
226
  }
227
 
228
 
229
  def calculate_benchmark_metrics(benchmark_returns, sofr_rate=0.045):
230
- """Calculate benchmark performance metrics"""
231
- cum_returns = np.cumprod(1 + benchmark_returns)
232
- ann_return = (cum_returns[-1] ** (252 / len(benchmark_returns))) - 1
233
- sharpe = (np.mean(benchmark_returns) - (sofr_rate / 252)) / (np.std(benchmark_returns) + 1e-9) * np.sqrt(252)
234
-
235
- cum_max = np.maximum.accumulate(cum_returns)
236
- dd = (cum_returns - cum_max) / cum_max
237
- max_dd = np.min(dd)
238
-
239
  max_daily_dd = np.min(benchmark_returns)
240
-
241
  return {
242
- 'cum_returns': cum_returns,
243
- 'ann_return': ann_return,
244
- 'sharpe': sharpe,
245
- 'max_dd': max_dd,
246
  'max_daily_dd': max_daily_dd
247
  }
 
1
  """
2
+ Backtesting and strategy execution logic — TFT model only.
3
  """
4
 
5
  import numpy as np
 
9
 
10
 
11
  def compute_signal_conviction(raw_scores):
 
 
 
 
 
 
 
 
 
 
 
 
12
  best_idx = int(np.argmax(raw_scores))
13
  mean = np.mean(raw_scores)
14
+ std = np.std(raw_scores)
15
+ z = 0.0 if std < 1e-9 else (raw_scores[best_idx] - mean) / std
16
+ if z >= 2.0: label = "Very High"
17
+ elif z >= 1.0: label = "High"
18
+ elif z >= 0.0: label = "Moderate"
19
+ else: label = "Low"
 
 
 
 
 
 
 
 
 
 
20
  return best_idx, z, label
21
 
22
 
23
+ def execute_strategy(proba, y_fwd_test, test_dates, target_etfs, fee_bps,
 
24
  stop_loss_pct=-0.12, z_reentry=1.0,
25
+ sofr=0.045, z_min_entry=0.5,
26
  daily_ret_override=None):
27
+ arrays_to_filter = [proba, y_fwd_test]
28
+ if daily_ret_override is not None:
29
+ arrays_to_filter.append(daily_ret_override)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
+ filtered_dates, filtered_arrays = filter_to_trading_days(test_dates, arrays_to_filter)
32
  test_dates = filtered_dates
33
+ proba = filtered_arrays[0]
34
+ y_fwd_test = filtered_arrays[1]
35
+ if daily_ret_override is not None:
36
+ daily_ret_override = filtered_arrays[2]
37
 
38
+ strat_rets = []
39
  audit_trail = []
40
+ daily_rf = sofr / 252
41
+ stop_active = False
42
+ recent_rets = []
43
+ today = datetime.now().date()
44
+
45
+ for i in range(len(proba)):
46
+ day_scores = np.array(proba[i], dtype=float)
47
+ best_idx, day_z, _ = compute_signal_conviction(day_scores)
48
+ signal_etf = target_etfs[best_idx].replace('_Ret', '')
49
+
50
+ if daily_ret_override is not None:
51
+ realized_ret = float(daily_ret_override[i][best_idx])
 
 
 
 
 
 
 
 
 
 
 
 
52
  else:
53
+ realized_ret = float(y_fwd_test[i][best_idx])
 
 
 
 
 
 
54
 
 
55
  if stop_active:
56
+ if day_z >= z_reentry and day_z >= z_min_entry:
57
+ stop_active = False
58
+ net_ret = realized_ret - (fee_bps / 10000)
59
+ trade_signal = signal_etf
 
 
 
 
 
 
60
  else:
61
+ net_ret = daily_rf
 
62
  trade_signal = "CASH"
63
  else:
 
64
  if day_z < z_min_entry:
65
+ net_ret = daily_rf
66
  trade_signal = "CASH"
67
  else:
 
68
  if len(recent_rets) >= 2:
69
  cum_2d = (1 + recent_rets[-2]) * (1 + recent_rets[-1]) - 1
70
  if cum_2d <= stop_loss_pct:
71
+ stop_active = True
72
+ net_ret = daily_rf
73
  trade_signal = "CASH"
74
  else:
75
+ net_ret = realized_ret - (fee_bps / 10000)
76
  trade_signal = signal_etf
77
  else:
78
+ net_ret = realized_ret - (fee_bps / 10000)
79
  trade_signal = signal_etf
80
 
81
  strat_rets.append(net_ret)
 
86
  trade_date = test_dates[i]
87
  if trade_date.date() < today:
88
  audit_trail.append({
89
+ 'Date': trade_date.strftime('%Y-%m-%d'),
90
+ 'Signal': trade_signal,
91
+ 'Conviction_Z': round(day_z, 2),
92
+ 'Realized': round(realized_ret, 5),
93
+ 'Net_Return': round(net_ret, 5),
94
+ 'Stop_Active': stop_active
95
  })
96
 
97
  strat_rets = np.array(strat_rets)
98
 
99
+ if len(test_dates) > 0 and len(proba) > 0:
100
+ last_date = test_dates[-1]
 
101
  next_trading_date = get_next_trading_day(last_date)
102
+ last_scores = np.array(proba[-1], dtype=float)
103
+ next_best_idx, conviction_zscore, conviction_label = compute_signal_conviction(last_scores)
104
+ next_signal = target_etfs[next_best_idx].replace('_Ret', '')
105
+ all_etf_scores = last_scores
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  else:
107
  next_trading_date = datetime.now().date()
108
+ next_signal = "CASH"
109
  conviction_zscore = 0.0
110
+ conviction_label = "Low"
111
+ all_etf_scores = np.zeros(len(target_etfs))
112
 
113
  return (strat_rets, audit_trail, next_signal, next_trading_date,
114
  conviction_zscore, conviction_label, all_etf_scores)
115
 
116
 
117
  def calculate_metrics(strat_rets, sofr_rate=0.045):
118
+ cum_returns = np.cumprod(1 + strat_rets)
119
+ ann_return = (cum_returns[-1] ** (252 / len(strat_rets))) - 1
120
+ sharpe = ((np.mean(strat_rets) - sofr_rate / 252) /
121
+ (np.std(strat_rets) + 1e-9) * np.sqrt(252))
122
+ recent_rets = strat_rets[-15:]
123
+ hit_ratio = np.mean(recent_rets > 0)
124
+ cum_max = np.maximum.accumulate(cum_returns)
125
+ drawdown = (cum_returns - cum_max) / cum_max
126
+ max_dd = np.min(drawdown)
 
 
 
127
  max_daily_dd = np.min(strat_rets)
 
128
  return {
129
+ 'cum_returns': cum_returns,
130
+ 'ann_return': ann_return,
131
+ 'sharpe': sharpe,
132
+ 'hit_ratio': hit_ratio,
133
+ 'max_dd': max_dd,
134
  'max_daily_dd': max_daily_dd,
135
+ 'cum_max': cum_max
136
  }
137
 
138
 
139
  def calculate_benchmark_metrics(benchmark_returns, sofr_rate=0.045):
140
+ cum_returns = np.cumprod(1 + benchmark_returns)
141
+ ann_return = (cum_returns[-1] ** (252 / len(benchmark_returns))) - 1
142
+ sharpe = ((np.mean(benchmark_returns) - sofr_rate / 252) /
143
+ (np.std(benchmark_returns) + 1e-9) * np.sqrt(252))
144
+ cum_max = np.maximum.accumulate(cum_returns)
145
+ dd = (cum_returns - cum_max) / cum_max
146
+ max_dd = np.min(dd)
 
 
147
  max_daily_dd = np.min(benchmark_returns)
 
148
  return {
149
+ 'cum_returns': cum_returns,
150
+ 'ann_return': ann_return,
151
+ 'sharpe': sharpe,
152
+ 'max_dd': max_dd,
153
  'max_daily_dd': max_daily_dd
154
  }