|
|
|
|
@@ -84,17 +84,6 @@ class EnsembleAlphaStrategy(Strategy):
|
|
|
|
|
row_sums = raw.sum(axis=1).replace(0, np.nan)
|
|
|
|
|
signals = raw.div(row_sums, axis=0).fillna(0.0)
|
|
|
|
|
|
|
|
|
|
# === Tail-risk protection ===
|
|
|
|
|
if self.tail_protection:
|
|
|
|
|
# Portfolio equity proxy: equal-weight market return
|
|
|
|
|
mkt_ret = ret.mean(axis=1)
|
|
|
|
|
mkt_eq = (1 + mkt_ret).cumprod()
|
|
|
|
|
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
|
|
|
|
|
in_tail = mkt_dd < self.tail_threshold
|
|
|
|
|
scale = pd.Series(1.0, index=data.index)
|
|
|
|
|
scale[in_tail] = self.tail_scale
|
|
|
|
|
signals = signals.mul(scale, axis=0)
|
|
|
|
|
|
|
|
|
|
# === Monthly rebalance ===
|
|
|
|
|
warmup = 252
|
|
|
|
|
rebal_mask = pd.Series(False, index=data.index)
|
|
|
|
|
@@ -105,6 +94,20 @@ class EnsembleAlphaStrategy(Strategy):
|
|
|
|
|
signals = signals.ffill().fillna(0.0)
|
|
|
|
|
signals.iloc[:warmup] = 0.0
|
|
|
|
|
|
|
|
|
|
# === Tail-risk protection (rebal-gated; NaN-safe market mean) ===
|
|
|
|
|
# Apply AFTER ffill so the scale changes only at rebal points,
|
|
|
|
|
# otherwise daily flips force half-out/half-in trades that burn
|
|
|
|
|
# the account through fixed per-trade fees.
|
|
|
|
|
if self.tail_protection:
|
|
|
|
|
mkt_ret = ret.mean(axis=1, skipna=True)
|
|
|
|
|
mkt_eq = (1 + mkt_ret.fillna(0.0)).cumprod()
|
|
|
|
|
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
|
|
|
|
|
in_tail = mkt_dd < self.tail_threshold
|
|
|
|
|
scale_raw = pd.Series(1.0, index=data.index)
|
|
|
|
|
scale_raw[in_tail] = self.tail_scale
|
|
|
|
|
scale = scale_raw.where(rebal_mask, np.nan).ffill().fillna(1.0)
|
|
|
|
|
signals = signals.mul(scale, axis=0)
|
|
|
|
|
|
|
|
|
|
return signals.shift(1).fillna(0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -162,16 +165,6 @@ class EnhancedFactorComboStrategy(Strategy):
|
|
|
|
|
row_sums = raw.sum(axis=1).replace(0, np.nan)
|
|
|
|
|
signals = raw.div(row_sums, axis=0).fillna(0.0)
|
|
|
|
|
|
|
|
|
|
# Tail protection
|
|
|
|
|
if self.tail_protection:
|
|
|
|
|
mkt_ret = ret.mean(axis=1)
|
|
|
|
|
mkt_eq = (1 + mkt_ret).cumprod()
|
|
|
|
|
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
|
|
|
|
|
in_tail = mkt_dd < -0.15
|
|
|
|
|
scale = pd.Series(1.0, index=data.index)
|
|
|
|
|
scale[in_tail] = 0.5
|
|
|
|
|
signals = signals.mul(scale, axis=0)
|
|
|
|
|
|
|
|
|
|
# Monthly rebalance
|
|
|
|
|
warmup = 252
|
|
|
|
|
rebal_mask = pd.Series(False, index=data.index)
|
|
|
|
|
@@ -182,6 +175,17 @@ class EnhancedFactorComboStrategy(Strategy):
|
|
|
|
|
signals = signals.ffill().fillna(0.0)
|
|
|
|
|
signals.iloc[:warmup] = 0.0
|
|
|
|
|
|
|
|
|
|
# Tail protection (rebal-gated to avoid daily-flip turnover)
|
|
|
|
|
if self.tail_protection:
|
|
|
|
|
mkt_ret = ret.mean(axis=1, skipna=True)
|
|
|
|
|
mkt_eq = (1 + mkt_ret.fillna(0.0)).cumprod()
|
|
|
|
|
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
|
|
|
|
|
in_tail = mkt_dd < -0.15
|
|
|
|
|
scale_raw = pd.Series(1.0, index=data.index)
|
|
|
|
|
scale_raw[in_tail] = 0.5
|
|
|
|
|
scale = scale_raw.where(rebal_mask, np.nan).ffill().fillna(1.0)
|
|
|
|
|
signals = signals.mul(scale, axis=0)
|
|
|
|
|
|
|
|
|
|
return signals.shift(1).fillna(0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -230,31 +234,41 @@ class RiskManagedEnsembleStrategy(Strategy):
|
|
|
|
|
# Step 1: Get raw signals from the ensemble (already shifted by 1)
|
|
|
|
|
raw = self.ensemble.generate_signals(data)
|
|
|
|
|
|
|
|
|
|
# Step 2: Compute MARKET returns (equal-weight of all stocks)
|
|
|
|
|
daily_rets = data.pct_change().fillna(0.0)
|
|
|
|
|
mkt_rets = daily_rets.mean(axis=1)
|
|
|
|
|
# Step 2: Compute MARKET returns over valid (non-masked) columns.
|
|
|
|
|
# `daily_rets` keeps NaN for PIT-masked tickers so they don't dilute
|
|
|
|
|
# the cross-sectional mean to ~0.
|
|
|
|
|
daily_rets = data.pct_change()
|
|
|
|
|
mkt_rets = daily_rets.mean(axis=1, skipna=True)
|
|
|
|
|
|
|
|
|
|
# Step 3: Market drawdown dampener
|
|
|
|
|
mkt_eq = (1 + mkt_rets).cumprod()
|
|
|
|
|
mkt_dd = mkt_eq / mkt_eq.cummax() - 1 # always ≤ 0
|
|
|
|
|
# Linear: at DD=0 → 1.0, at DD=-dd_denom → dd_floor
|
|
|
|
|
dd_scale = (1.0 + mkt_dd / self.dd_denom).clip(lower=self.dd_floor, upper=1.0)
|
|
|
|
|
dd_scale_lagged = dd_scale.shift(1).fillna(1.0) # PIT
|
|
|
|
|
mkt_eq = (1 + mkt_rets.fillna(0.0)).cumprod()
|
|
|
|
|
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
|
|
|
|
|
dd_scale_raw = (1.0 + mkt_dd / self.dd_denom).clip(
|
|
|
|
|
lower=self.dd_floor, upper=1.0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Step 4: Vol spike guard (uses portfolio's own vol for specificity)
|
|
|
|
|
# Step 4: Vol spike guard from portfolio returns (NaN-aware sum)
|
|
|
|
|
if self.vol_spike_guard:
|
|
|
|
|
port_rets = (raw * daily_rets).sum(axis=1)
|
|
|
|
|
port_rets = (raw * daily_rets).sum(axis=1, min_count=1).fillna(0.0)
|
|
|
|
|
short_vol = port_rets.rolling(self.vol_spike_window, min_periods=5).std() * np.sqrt(252)
|
|
|
|
|
vol_90th = short_vol.rolling(self.vol_spike_lookback, min_periods=126).quantile(0.90)
|
|
|
|
|
in_spike = short_vol > vol_90th
|
|
|
|
|
vol_scale = pd.Series(1.0, index=data.index)
|
|
|
|
|
vol_scale[in_spike] = self.vol_spike_floor
|
|
|
|
|
vol_scale_lagged = vol_scale.shift(1).fillna(1.0) # PIT
|
|
|
|
|
vol_scale_raw = pd.Series(1.0, index=data.index)
|
|
|
|
|
vol_scale_raw[in_spike] = self.vol_spike_floor
|
|
|
|
|
else:
|
|
|
|
|
vol_scale_lagged = 1.0
|
|
|
|
|
vol_scale_raw = pd.Series(1.0, index=data.index)
|
|
|
|
|
|
|
|
|
|
# Step 5: Combined scaling, sampled at the inner ensemble's rebal
|
|
|
|
|
# cadence so we don't trade in/out daily (which would incur huge
|
|
|
|
|
# fixed-fee costs).
|
|
|
|
|
combined = (dd_scale_raw * vol_scale_raw).shift(1).fillna(1.0)
|
|
|
|
|
rebal_freq = getattr(self.ensemble, "rebal_freq", 21)
|
|
|
|
|
warmup = 252
|
|
|
|
|
rebal_mask = pd.Series(False, index=data.index)
|
|
|
|
|
rebal_indices = list(range(warmup, len(data), rebal_freq))
|
|
|
|
|
rebal_mask.iloc[rebal_indices] = True
|
|
|
|
|
final_scale = combined.where(rebal_mask, np.nan).ffill().fillna(1.0)
|
|
|
|
|
|
|
|
|
|
# Step 5: Combined scaling
|
|
|
|
|
final_scale = dd_scale_lagged * vol_scale_lagged
|
|
|
|
|
return raw.mul(final_scale, axis=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -342,26 +356,31 @@ class SharpeBoostedEnsembleStrategy(Strategy):
|
|
|
|
|
signals = signals.shift(1).fillna(0.0) # PIT: 1-day execution lag
|
|
|
|
|
|
|
|
|
|
# === Asymmetric vol scaling ===
|
|
|
|
|
# Only reduce exposure when vol is high AND returns are negative
|
|
|
|
|
# High vol + positive returns = riding a trend, don't cut
|
|
|
|
|
daily_rets = data.pct_change().fillna(0.0)
|
|
|
|
|
port_rets = (signals * daily_rets).sum(axis=1)
|
|
|
|
|
# NB: scales are RE-EVALUATED only on rebalance days. Daily flips of
|
|
|
|
|
# asym/dd scales would force half-in/half-out trades each session,
|
|
|
|
|
# burning the account through fixed per-trade fees ($2 US / $5 CN).
|
|
|
|
|
# Use cross-sectional mean of non-masked returns so PIT-masked
|
|
|
|
|
# NaN→0 fills don't dilute the market signal.
|
|
|
|
|
daily_rets = data.pct_change()
|
|
|
|
|
port_rets = (signals * daily_rets).sum(axis=1, min_count=1).fillna(0.0)
|
|
|
|
|
short_vol = port_rets.rolling(20, min_periods=10).std() * np.sqrt(252)
|
|
|
|
|
vol_median = short_vol.rolling(252, min_periods=126).median()
|
|
|
|
|
recent_ret = port_rets.rolling(20, min_periods=10).sum()
|
|
|
|
|
high_vol_neg = (short_vol > vol_median * 1.5) & (recent_ret < 0)
|
|
|
|
|
asym_scale = pd.Series(1.0, index=data.index)
|
|
|
|
|
asym_scale[high_vol_neg] = self.asym_vol_floor
|
|
|
|
|
signals = signals.mul(asym_scale.shift(1).fillna(1.0), axis=0) # PIT
|
|
|
|
|
asym_scale_raw = pd.Series(1.0, index=data.index)
|
|
|
|
|
asym_scale_raw[high_vol_neg] = self.asym_vol_floor
|
|
|
|
|
|
|
|
|
|
# === Light market-DD dampener ===
|
|
|
|
|
# Uses market (not strategy) drawdown to avoid negative feedback loop
|
|
|
|
|
mkt_rets = daily_rets.mean(axis=1)
|
|
|
|
|
mkt_eq = (1 + mkt_rets).cumprod()
|
|
|
|
|
# === Market-DD dampener (rebal-gated, NaN-aware market mean) ===
|
|
|
|
|
mkt_rets = daily_rets.mean(axis=1, skipna=True)
|
|
|
|
|
mkt_eq = (1 + mkt_rets.fillna(0.0)).cumprod()
|
|
|
|
|
mkt_dd = mkt_eq / mkt_eq.cummax() - 1
|
|
|
|
|
dd_scale = (1.0 + mkt_dd / self.dd_denom).clip(
|
|
|
|
|
dd_scale_raw = (1.0 + mkt_dd / self.dd_denom).clip(
|
|
|
|
|
lower=self.dd_floor, upper=1.0
|
|
|
|
|
)
|
|
|
|
|
signals = signals.mul(dd_scale.shift(1).fillna(1.0), axis=0) # PIT
|
|
|
|
|
|
|
|
|
|
# Sample scales at rebal points only, then step-hold between rebals.
|
|
|
|
|
combined = (asym_scale_raw * dd_scale_raw).shift(1).fillna(1.0)
|
|
|
|
|
rebal_scale = combined.where(rebal_mask, np.nan).ffill().fillna(1.0)
|
|
|
|
|
signals = signals.mul(rebal_scale, axis=0)
|
|
|
|
|
|
|
|
|
|
return signals
|
|
|
|
|
|