diff --git a/portfolio/builder.py b/portfolio/builder.py index 12c42cb..92edd3a 100644 --- a/portfolio/builder.py +++ b/portfolio/builder.py @@ -33,24 +33,17 @@ class ThresholdBuilder: class RankEqualWeightBuilder: - """Rank all stocks by signal. Buy top N at equal weight. Sell if drops out. + """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): + def __init__(self, top_n: Optional[int] = None, top_pct: float = 0.2, min_signal: Optional[float] = None): self.top_n = top_n - self.min_signal = min_signal # optional floor — skip stocks with signal below this + self.top_pct = top_pct + self.min_signal = min_signal 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} @@ -58,8 +51,14 @@ class RankEqualWeightBuilder: # 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 + # Determine top N: explicit count or percentage of available stocks + if self.top_n is not None: + n = self.top_n + else: + n = max(1, int(len(signals) * self.top_pct)) + + top_symbols = set(sym for sym, _ in ranked[:n]) + size_pct = 1.0 / n if n > 0 else 0.0 actions = {} for sym in signals: diff --git a/reports/pnl.png b/reports/pnl.png index df2df5a..c30056a 100644 Binary files a/reports/pnl.png and b/reports/pnl.png differ diff --git a/reports/summary.txt b/reports/summary.txt index 22bc0c1..9dab488 100644 --- a/reports/summary.txt +++ b/reports/summary.txt @@ -1,13 +1,13 @@ BACKTEST SUMMARY ======================================== -sharpe: -0.2136540474098261 -max_drawdown: 22.08204523900236 -max_drawdown_len: 445 -total_return: -0.011700103856604784 -avg_return: -2.4173768298770213e-05 -total_trades: 586 -won_trades: 287 -lost_trades: 295 +sharpe: 0.12450603119200966 +max_drawdown: 18.40327026827532 +max_drawdown_len: 251 +total_return: 0.04945463098329521 +avg_return: 0.00010217898963490745 +total_trades: 695 +won_trades: 357 +lost_trades: 333 SIGNAL IC ======================================== diff --git a/run_example.py b/run_example.py index faa210a..d5ce262 100644 --- a/run_example.py +++ b/run_example.py @@ -83,7 +83,7 @@ def main(forward_horizon: int = 5, universe: str = "csi500", signal_name: str = sizer_percent=0.95, ) runner = BacktestRunner(config) - builder = RankEqualWeightBuilder(top_n=5) + builder = RankEqualWeightBuilder(top_pct=0.2) for sym, df in data.items(): df = df.copy() df["signal"] = signal.compute(df).values