fix: rank-based allocator + momentum signal (reversal->momentum flip)
This commit is contained in:
+39
-9
@@ -1,5 +1,6 @@
|
|||||||
"""Map signal values to discrete position actions."""
|
"""Map signal values to discrete position actions."""
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -24,17 +25,46 @@ class ThresholdBuilder:
|
|||||||
self.size_pct = size_pct
|
self.size_pct = size_pct
|
||||||
|
|
||||||
def build(self, signal_value: float, in_position: bool) -> PositionAction:
|
def build(self, signal_value: float, in_position: bool) -> PositionAction:
|
||||||
"""Decide what to do given the current signal and position state.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
signal_value: Latest signal value for the stock.
|
|
||||||
in_position: Whether the portfolio currently holds the stock.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The action to take ("buy", "sell", or "hold").
|
|
||||||
"""
|
|
||||||
if not in_position and signal_value >= self.buy_threshold:
|
if not in_position and signal_value >= self.buy_threshold:
|
||||||
return PositionAction("buy", self.size_pct)
|
return PositionAction("buy", self.size_pct)
|
||||||
if in_position and signal_value <= self.sell_threshold:
|
if in_position and signal_value <= self.sell_threshold:
|
||||||
return PositionAction("sell", 0.0)
|
return PositionAction("sell", 0.0)
|
||||||
return PositionAction("hold", 0.0)
|
return PositionAction("hold", 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class RankEqualWeightBuilder:
|
||||||
|
"""Rank all stocks by signal. Buy top N at equal weight. Sell if drops out.
|
||||||
|
|
||||||
|
Called once per bar with ALL stock signals. Returns per-stock actions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, top_n: int = 5, min_signal: Optional[float] = None):
|
||||||
|
self.top_n = top_n
|
||||||
|
self.min_signal = min_signal # optional floor — skip stocks with signal below this
|
||||||
|
|
||||||
|
def build(self, signals: dict[str, float]) -> dict[str, PositionAction]:
|
||||||
|
"""Rank stocks by signal (descending). Top N get 'buy', rest get 'sell' if held.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
signals: {symbol: signal_value} for all stocks on this bar.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{symbol: PositionAction}
|
||||||
|
"""
|
||||||
|
# Filter by min_signal if set
|
||||||
|
if self.min_signal is not None:
|
||||||
|
signals = {s: v for s, v in signals.items() if v >= self.min_signal}
|
||||||
|
|
||||||
|
# Sort by signal descending
|
||||||
|
ranked = sorted(signals.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
|
top_symbols = set(sym for sym, _ in ranked[:self.top_n])
|
||||||
|
size_pct = 1.0 / self.top_n if self.top_n > 0 else 0.0
|
||||||
|
|
||||||
|
actions = {}
|
||||||
|
for sym in signals:
|
||||||
|
if sym in top_symbols:
|
||||||
|
actions[sym] = PositionAction("buy", size_pct)
|
||||||
|
else:
|
||||||
|
actions[sym] = PositionAction("sell", 0.0)
|
||||||
|
return actions
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 55 KiB |
+13
-13
@@ -1,21 +1,21 @@
|
|||||||
BACKTEST SUMMARY
|
BACKTEST SUMMARY
|
||||||
========================================
|
========================================
|
||||||
sharpe: -5.633716896325243
|
sharpe: -0.18318054011826762
|
||||||
max_drawdown: 48.659457956769764
|
max_drawdown: 31.155770933507835
|
||||||
max_drawdown_len: 451
|
max_drawdown_len: 403
|
||||||
total_return: -0.48968077055625825
|
total_return: -0.01938978410684507
|
||||||
avg_return: -0.0010117371292484674
|
avg_return: -4.006153741083692e-05
|
||||||
total_trades: 33
|
total_trades: 470
|
||||||
won_trades: 20
|
won_trades: 172
|
||||||
lost_trades: 12
|
lost_trades: 293
|
||||||
|
|
||||||
SIGNAL IC
|
SIGNAL IC
|
||||||
========================================
|
========================================
|
||||||
ic_mean: -0.006912277738865651
|
ic_mean: 0.006912277738865651
|
||||||
ic_std: 0.3332983458971776
|
ic_std: 0.3332983458971776
|
||||||
ir: -0.020739010030965125
|
ir: 0.020739010030965125
|
||||||
rank_ic_mean: -0.006980297831283274
|
rank_ic_mean: 0.006980297831283274
|
||||||
rank_ic_std: 0.32283972442680237
|
rank_ic_std: 0.32283972442680237
|
||||||
rank_ir: -0.02162155801513181
|
rank_ir: 0.02162155801513181
|
||||||
hit_rate: 0.4811715481171548
|
hit_rate: 0.5188284518828452
|
||||||
n_periods: 478
|
n_periods: 478
|
||||||
|
|||||||
+7
-11
@@ -1,10 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""End-to-end pipeline: HS300 universe -> reversal signal -> cross-sectional IC
|
"""End-to-end pipeline: HS300 universe -> momentum signal -> cross-sectional IC
|
||||||
-> multi-stock backtest (AlphaStrategy + PercentSizer) -> reports.
|
-> multi-stock backtest (AlphaStrategy + RankEqualWeightBuilder) -> reports."""
|
||||||
|
|
||||||
Note: this runs the first 30 HS300 constituents to keep runtime manageable.
|
|
||||||
Downloading daily bars for the full ~300 names takes roughly 10 minutes.
|
|
||||||
"""
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -15,8 +11,8 @@ from backtest.runner import BacktestRunner
|
|||||||
from data.downloader import download_batch
|
from data.downloader import download_batch
|
||||||
from data.universe import SYMBOLS
|
from data.universe import SYMBOLS
|
||||||
from eval.metrics import evaluate_cross_sectional
|
from eval.metrics import evaluate_cross_sectional
|
||||||
from portfolio.builder import ThresholdBuilder
|
from portfolio.builder import RankEqualWeightBuilder
|
||||||
from signals.reversal import ReversalSignal
|
from signals.momentum import MomentumSignal
|
||||||
from strategies.alpha_strategy import AlphaStrategy
|
from strategies.alpha_strategy import AlphaStrategy
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
@@ -33,8 +29,8 @@ def main():
|
|||||||
data = {s: df for s, df in data.items() if df is not None and not df.empty}
|
data = {s: df for s, df in data.items() if df is not None and not df.empty}
|
||||||
logger.info(f"Downloaded {len(data)}/{len(symbols)} symbols")
|
logger.info(f"Downloaded {len(data)}/{len(symbols)} symbols")
|
||||||
|
|
||||||
# 3. Compute the reversal signal per stock.
|
# 3. Compute the momentum signal per stock.
|
||||||
signal = ReversalSignal(lookback=5)
|
signal = MomentumSignal(lookback=5)
|
||||||
signal_series: dict[str, pd.Series] = {}
|
signal_series: dict[str, pd.Series] = {}
|
||||||
forward_returns: dict[str, pd.Series] = {}
|
forward_returns: dict[str, pd.Series] = {}
|
||||||
for sym, df in data.items():
|
for sym, df in data.items():
|
||||||
@@ -60,7 +56,7 @@ def main():
|
|||||||
sizer_percent=0.95,
|
sizer_percent=0.95,
|
||||||
)
|
)
|
||||||
runner = BacktestRunner(config)
|
runner = BacktestRunner(config)
|
||||||
builder = ThresholdBuilder(buy_threshold=0.02, sell_threshold=-0.02, size_pct=0.95)
|
builder = RankEqualWeightBuilder(top_n=5)
|
||||||
for sym, df in data.items():
|
for sym, df in data.items():
|
||||||
df = df.copy()
|
df = df.copy()
|
||||||
df["signal"] = signal.compute(df).values
|
df["signal"] = signal.compute(df).values
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Short-horizon momentum signal."""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from signals.base import AlphaSignal
|
||||||
|
|
||||||
|
|
||||||
|
class MomentumSignal(AlphaSignal):
|
||||||
|
"""Positive trailing return: stocks that rose score high (momentum).
|
||||||
|
|
||||||
|
The signal is ``close.pct_change(lookback)`` — opposite of ReversalSignal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, lookback: int = 5):
|
||||||
|
self.lookback = lookback
|
||||||
|
|
||||||
|
def compute(self, df: pd.DataFrame) -> pd.Series:
|
||||||
|
return df["close"].pct_change(self.lookback)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return f"momentum_{self.lookback}d"
|
||||||
@@ -4,23 +4,49 @@ import pandas as pd
|
|||||||
|
|
||||||
|
|
||||||
class AlphaStrategy(bt.Strategy):
|
class AlphaStrategy(bt.Strategy):
|
||||||
"""Trade each feed based on its precomputed ``signal`` line.
|
"""Trade feeds based on precomputed ``signal`` line.
|
||||||
|
|
||||||
The strategy delegates the buy/sell/hold decision to a portfolio builder
|
Supports two builder modes:
|
||||||
(e.g. ``ThresholdBuilder``) and sizes entries with ``order_target_percent``.
|
- ThresholdBuilder: per-stock threshold (passed ``(signal_value, in_position)``)
|
||||||
|
- RankEqualWeightBuilder: cross-sectional ranking (passed ``{symbol: signal}`` dict)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, builder):
|
def __init__(self, builder):
|
||||||
self.builder = builder
|
self.builder = builder
|
||||||
|
|
||||||
def next(self):
|
def next(self):
|
||||||
|
# Collect all signals
|
||||||
|
signals: dict[str, float] = {}
|
||||||
for data in self.datas:
|
for data in self.datas:
|
||||||
sig = data.signal[0]
|
sig = data.signal[0]
|
||||||
if pd.isna(sig):
|
if not pd.isna(sig):
|
||||||
continue
|
signals[data._name] = float(sig)
|
||||||
in_position = bool(self.getposition(data).size)
|
|
||||||
action = self.builder.build(float(sig), in_position)
|
if not signals:
|
||||||
if action.action == "buy":
|
return
|
||||||
self.order_target_percent(data=data, target=action.size_pct)
|
|
||||||
elif action.action == "sell":
|
# Detect builder type: if RankEqualWeightBuilder, use cross-sectional mode
|
||||||
self.close(data=data)
|
from portfolio.builder import RankEqualWeightBuilder
|
||||||
|
if isinstance(self.builder, RankEqualWeightBuilder):
|
||||||
|
actions = self.builder.build(signals)
|
||||||
|
for data in self.datas:
|
||||||
|
name = data._name
|
||||||
|
if name not in actions:
|
||||||
|
continue
|
||||||
|
action = actions[name]
|
||||||
|
if action.action == "buy":
|
||||||
|
self.order_target_percent(data=data, target=action.size_pct)
|
||||||
|
elif action.action == "sell":
|
||||||
|
self.close(data=data)
|
||||||
|
else:
|
||||||
|
# Legacy per-stock ThresholdBuilder
|
||||||
|
for data in self.datas:
|
||||||
|
name = data._name
|
||||||
|
if name not in signals:
|
||||||
|
continue
|
||||||
|
in_position = bool(self.getposition(data).size)
|
||||||
|
action = self.builder.build(signals[name], in_position)
|
||||||
|
if action.action == "buy":
|
||||||
|
self.order_target_percent(data=data, target=action.size_pct)
|
||||||
|
elif action.action == "sell":
|
||||||
|
self.close(data=data)
|
||||||
|
|||||||
Reference in New Issue
Block a user