diff --git a/portfolio/builder.py b/portfolio/builder.py index 22a37d6..12c42cb 100644 --- a/portfolio/builder.py +++ b/portfolio/builder.py @@ -1,5 +1,6 @@ """Map signal values to discrete position actions.""" from dataclasses import dataclass +from typing import Optional @dataclass @@ -24,17 +25,46 @@ class ThresholdBuilder: self.size_pct = size_pct 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: return PositionAction("buy", self.size_pct) if in_position and signal_value <= self.sell_threshold: return PositionAction("sell", 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 diff --git a/reports/ic.png b/reports/ic.png index b4b2e69..77032bf 100644 Binary files a/reports/ic.png and b/reports/ic.png differ diff --git a/reports/pnl.png b/reports/pnl.png index c981320..e6cf911 100644 Binary files a/reports/pnl.png and b/reports/pnl.png differ diff --git a/reports/summary.txt b/reports/summary.txt index 6e837d8..c0d3f67 100644 --- a/reports/summary.txt +++ b/reports/summary.txt @@ -1,21 +1,21 @@ BACKTEST SUMMARY ======================================== -sharpe: -5.633716896325243 -max_drawdown: 48.659457956769764 -max_drawdown_len: 451 -total_return: -0.48968077055625825 -avg_return: -0.0010117371292484674 -total_trades: 33 -won_trades: 20 -lost_trades: 12 +sharpe: -0.18318054011826762 +max_drawdown: 31.155770933507835 +max_drawdown_len: 403 +total_return: -0.01938978410684507 +avg_return: -4.006153741083692e-05 +total_trades: 470 +won_trades: 172 +lost_trades: 293 SIGNAL IC ======================================== -ic_mean: -0.006912277738865651 +ic_mean: 0.006912277738865651 ic_std: 0.3332983458971776 -ir: -0.020739010030965125 -rank_ic_mean: -0.006980297831283274 +ir: 0.020739010030965125 +rank_ic_mean: 0.006980297831283274 rank_ic_std: 0.32283972442680237 -rank_ir: -0.02162155801513181 -hit_rate: 0.4811715481171548 +rank_ir: 0.02162155801513181 +hit_rate: 0.5188284518828452 n_periods: 478 diff --git a/run_example.py b/run_example.py index ce11b48..8411dd0 100644 --- a/run_example.py +++ b/run_example.py @@ -1,10 +1,6 @@ #!/usr/bin/env python3 -"""End-to-end pipeline: HS300 universe -> reversal signal -> cross-sectional IC --> multi-stock backtest (AlphaStrategy + PercentSizer) -> 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. -""" +"""End-to-end pipeline: HS300 universe -> momentum signal -> cross-sectional IC +-> multi-stock backtest (AlphaStrategy + RankEqualWeightBuilder) -> reports.""" import logging import pandas as pd @@ -15,8 +11,8 @@ from backtest.runner import BacktestRunner from data.downloader import download_batch from data.universe import SYMBOLS from eval.metrics import evaluate_cross_sectional -from portfolio.builder import ThresholdBuilder -from signals.reversal import ReversalSignal +from portfolio.builder import RankEqualWeightBuilder +from signals.momentum import MomentumSignal from strategies.alpha_strategy import AlphaStrategy 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} logger.info(f"Downloaded {len(data)}/{len(symbols)} symbols") - # 3. Compute the reversal signal per stock. - signal = ReversalSignal(lookback=5) + # 3. Compute the momentum signal per stock. + signal = MomentumSignal(lookback=5) signal_series: dict[str, pd.Series] = {} forward_returns: dict[str, pd.Series] = {} for sym, df in data.items(): @@ -60,7 +56,7 @@ def main(): sizer_percent=0.95, ) 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(): df = df.copy() df["signal"] = signal.compute(df).values diff --git a/signals/momentum.py b/signals/momentum.py new file mode 100644 index 0000000..cd8f7af --- /dev/null +++ b/signals/momentum.py @@ -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" diff --git a/strategies/alpha_strategy.py b/strategies/alpha_strategy.py index dcceb29..19f4eba 100644 --- a/strategies/alpha_strategy.py +++ b/strategies/alpha_strategy.py @@ -4,23 +4,49 @@ import pandas as pd 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 - (e.g. ``ThresholdBuilder``) and sizes entries with ``order_target_percent``. + Supports two builder modes: + - ThresholdBuilder: per-stock threshold (passed ``(signal_value, in_position)``) + - RankEqualWeightBuilder: cross-sectional ranking (passed ``{symbol: signal}`` dict) """ def __init__(self, builder): self.builder = builder def next(self): + # Collect all signals + signals: dict[str, float] = {} for data in self.datas: sig = data.signal[0] - if pd.isna(sig): - continue - in_position = bool(self.getposition(data).size) - action = self.builder.build(float(sig), in_position) - if action.action == "buy": - self.order_target_percent(data=data, target=action.size_pct) - elif action.action == "sell": - self.close(data=data) + if not pd.isna(sig): + signals[data._name] = float(sig) + + if not signals: + return + + # Detect builder type: if RankEqualWeightBuilder, use cross-sectional mode + 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)