Spaces:
Running on Zero
Running on Zero
| """Strategy presets. | |
| Every strategy is a pure function of price history (and, optionally, stored | |
| model signals) that returns decisions aligned to **bar close**. None of them | |
| shift their own output -- `engine.run_backtest` does that, exactly once, so | |
| next-bar-open execution cannot be bypassed by a strategy. | |
| Every indicator here is causal: it uses `rolling`/`ewm` over past bars only. | |
| `tests/test_engine.py` proves this by perturbation rather than trusting it. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from typing import Callable, Protocol | |
| import numpy as np | |
| import pandas as pd | |
| from .engine import StrategyOutput | |
| # -------------------------------------------------------------------------- | |
| # Sentiment interface (stubbed for v1, real source lands later) | |
| # -------------------------------------------------------------------------- | |
| class SentimentSource(Protocol): | |
| """Anything that can score sentiment per bar, causally.""" | |
| def score(self, index: pd.DatetimeIndex, asset: str) -> pd.Series: | |
| """Value in [-1, 1] per bar, using only information available at that bar.""" | |
| ... | |
| class NeutralSentiment: | |
| """Default source: no opinion. Keeps the gate open so the momentum leg | |
| behaves as plain momentum until a real feed is wired in.""" | |
| name = "neutral-stub" | |
| is_stub = True | |
| def score(self, index: pd.DatetimeIndex, asset: str) -> pd.Series: | |
| return pd.Series(1.0, index=index, dtype="float64") | |
| class PriceProxySentiment: | |
| """Deterministic stand-in derived from realised momentum. | |
| Clearly labelled as a proxy -- it is *not* news sentiment. It exists so the | |
| Sentiment-Gated preset is demonstrable end to end before the real feed | |
| exists, and it is causal by construction. | |
| """ | |
| name = "price-proxy-stub" | |
| is_stub = True | |
| def __init__(self, lookback: int = 24): | |
| self.lookback = lookback | |
| def score(self, index: pd.DatetimeIndex, asset: str) -> pd.Series: | |
| return pd.Series(np.nan, index=index, dtype="float64") | |
| def score_from_prices(self, prices: pd.DataFrame) -> pd.Series: | |
| ret = prices["close"].pct_change(self.lookback) | |
| scaled = np.tanh(ret / (ret.rolling(self.lookback * 4).std().replace(0, np.nan) + 1e-12)) | |
| return scaled.fillna(0.0).clip(-1.0, 1.0) | |
| # -------------------------------------------------------------------------- | |
| # Indicator helpers (all causal) | |
| # -------------------------------------------------------------------------- | |
| def sma(s: pd.Series, n: int) -> pd.Series: | |
| return s.rolling(int(n), min_periods=int(n)).mean() | |
| def ema(s: pd.Series, n: int) -> pd.Series: | |
| return s.ewm(span=int(n), adjust=False, min_periods=int(n)).mean() | |
| def rsi(s: pd.Series, n: int = 14) -> pd.Series: | |
| delta = s.diff() | |
| gain = delta.clip(lower=0.0) | |
| loss = -delta.clip(upper=0.0) | |
| avg_gain = gain.ewm(alpha=1 / int(n), adjust=False, min_periods=int(n)).mean() | |
| avg_loss = loss.ewm(alpha=1 / int(n), adjust=False, min_periods=int(n)).mean() | |
| rs = avg_gain / avg_loss.replace(0.0, np.nan) | |
| return (100.0 - 100.0 / (1.0 + rs)).fillna(50.0) | |
| def bollinger(s: pd.Series, n: int = 20, k: float = 2.0): | |
| mid = s.rolling(int(n), min_periods=int(n)).mean() | |
| sd = s.rolling(int(n), min_periods=int(n)).std(ddof=0) | |
| return mid - k * sd, mid, mid + k * sd | |
| def macd(s: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9): | |
| line = ema(s, fast) - ema(s, slow) | |
| sig = line.ewm(span=int(signal), adjust=False, min_periods=int(signal)).mean() | |
| return line, sig, line - sig | |
| def _cross_up(a: pd.Series, b: pd.Series) -> pd.Series: | |
| return ((a > b) & (a.shift(1) <= b.shift(1))).astype("boolean").fillna(False).astype(bool) | |
| def _cross_down(a: pd.Series, b: pd.Series) -> pd.Series: | |
| return ((a < b) & (a.shift(1) >= b.shift(1))).astype("boolean").fillna(False).astype(bool) | |
| def _triggers(index, entries, exits, entry_text: str, exit_text: str) -> pd.Series: | |
| t = pd.Series("", index=index, dtype="object") | |
| t[entries] = entry_text | |
| t[exits] = exit_text | |
| return t | |
| # -------------------------------------------------------------------------- | |
| # Presets | |
| # -------------------------------------------------------------------------- | |
| def buy_and_hold(prices: pd.DataFrame, params: dict | None = None, | |
| signals: pd.DataFrame | None = None) -> StrategyOutput: | |
| """Enter on the first bar, never exit. The benchmark every claim is measured against.""" | |
| idx = prices.index | |
| entries = pd.Series(False, index=idx) | |
| exits = pd.Series(False, index=idx) | |
| if len(idx): | |
| entries.iloc[0] = True | |
| return StrategyOutput(entries=entries, exits=exits, | |
| triggers=_triggers(idx, entries, exits, "buy and hold entry", "")) | |
| def sma_crossover(prices: pd.DataFrame, params: dict | None = None, | |
| signals: pd.DataFrame | None = None) -> StrategyOutput: | |
| p = params or {} | |
| fast_n, slow_n = int(p.get("fast_ma", 20)), int(p.get("slow_ma", 50)) | |
| close = prices["close"] | |
| fast, slow = sma(close, fast_n), sma(close, slow_n) | |
| entries = _cross_up(fast, slow) | |
| exits = _cross_down(fast, slow) | |
| return StrategyOutput( | |
| entries=entries, exits=exits, | |
| triggers=_triggers(prices.index, entries, exits, | |
| f"SMA{fast_n} crossed above SMA{slow_n}", | |
| f"SMA{fast_n} crossed below SMA{slow_n}"), | |
| ) | |
| def rsi_mean_reversion(prices: pd.DataFrame, params: dict | None = None, | |
| signals: pd.DataFrame | None = None) -> StrategyOutput: | |
| p = params or {} | |
| n = int(p.get("rsi_period", 14)) | |
| lo, hi = float(p.get("oversold", 30)), float(p.get("overbought", 70)) | |
| r = rsi(prices["close"], n) | |
| entries = ((r < lo) & (r.shift(1) >= lo)).astype("boolean").fillna(False).astype(bool) | |
| exits = ((r > hi) & (r.shift(1) <= hi)).astype("boolean").fillna(False).astype(bool) | |
| return StrategyOutput( | |
| entries=entries, exits=exits, | |
| triggers=_triggers(prices.index, entries, exits, | |
| f"RSI({n}) fell below {lo:g}", f"RSI({n}) rose above {hi:g}"), | |
| ) | |
| def bollinger_breakout(prices: pd.DataFrame, params: dict | None = None, | |
| signals: pd.DataFrame | None = None) -> StrategyOutput: | |
| p = params or {} | |
| n, k = int(p.get("bb_period", 20)), float(p.get("bb_std", 2.0)) | |
| close = prices["close"] | |
| lower, mid, upper = bollinger(close, n, k) | |
| entries = ((close > upper) & (close.shift(1) <= upper.shift(1))).astype("boolean").fillna(False).astype(bool) | |
| exits = ((close < mid) & (close.shift(1) >= mid.shift(1))).astype("boolean").fillna(False).astype(bool) | |
| return StrategyOutput( | |
| entries=entries, exits=exits, | |
| triggers=_triggers(prices.index, entries, exits, | |
| f"close broke above the {n}/{k:g}σ upper band", | |
| "close fell back through the band midline"), | |
| ) | |
| def macd_momentum(prices: pd.DataFrame, params: dict | None = None, | |
| signals: pd.DataFrame | None = None) -> StrategyOutput: | |
| p = params or {} | |
| f, s, g = int(p.get("macd_fast", 12)), int(p.get("macd_slow", 26)), int(p.get("macd_signal", 9)) | |
| line, sig, _ = macd(prices["close"], f, s, g) | |
| entries = _cross_up(line, sig) | |
| exits = _cross_down(line, sig) | |
| return StrategyOutput( | |
| entries=entries, exits=exits, | |
| triggers=_triggers(prices.index, entries, exits, | |
| f"MACD({f},{s}) crossed above its {g}-period signal", | |
| f"MACD({f},{s}) crossed below its {g}-period signal"), | |
| ) | |
| def forecast_follower(prices: pd.DataFrame, params: dict | None = None, | |
| signals: pd.DataFrame | None = None) -> StrategyOutput: | |
| """Rule over stored quantiles: go long when the median forecast implies | |
| enough upside; optionally exit when price breaches the q10 floor. | |
| The stored forecast at bar `t` was produced from data up to `t`, and the | |
| engine shifts it before acting, so the earliest possible fill is `t+1`'s open. | |
| """ | |
| p = params or {} | |
| threshold = float(p.get("threshold", 0.005)) | |
| use_q10_stop = bool(p.get("use_q10_stop", True)) | |
| exit_threshold = float(p.get("exit_threshold", 0.0)) | |
| idx = prices.index | |
| close = prices["close"] | |
| if signals is None or signals.empty or "q50" not in signals.columns: | |
| false = pd.Series(False, index=idx) | |
| return StrategyOutput(entries=false, exits=false.copy(), | |
| triggers=pd.Series("", index=idx, dtype="object")) | |
| q50 = signals["q50"].reindex(idx).ffill() | |
| q10 = signals["q10"].reindex(idx).ffill() if "q10" in signals.columns else None | |
| edge = (q50 / close) - 1.0 | |
| entries = ((edge > threshold) & (edge.shift(1) <= threshold)).astype("boolean").fillna(False).astype(bool) | |
| exits = ((edge < exit_threshold) & (edge.shift(1) >= exit_threshold)).astype("boolean").fillna(False).astype(bool) | |
| if use_q10_stop and q10 is not None: | |
| breach = (close < q10).astype("boolean").fillna(False).astype(bool) | |
| prev_breach = breach.astype("boolean").shift(1).fillna(False).astype(bool) | |
| exits = (exits | (breach & ~prev_breach)).astype("boolean").fillna(False).astype(bool) | |
| trig = pd.Series("", index=idx, dtype="object") | |
| trig[entries] = f"forecast median implied >{threshold:.2%} upside" | |
| trig[exits] = "forecast edge closed or price breached the q10 floor" | |
| return StrategyOutput(entries=entries, exits=exits, triggers=trig) | |
| def sentiment_gated_momentum(prices: pd.DataFrame, params: dict | None = None, | |
| signals: pd.DataFrame | None = None, | |
| sentiment: SentimentSource | None = None) -> StrategyOutput: | |
| """Momentum that only fires while the sentiment gate is open. | |
| The sentiment input sits behind `SentimentSource`. Until a real feed is | |
| wired in, the default is a labelled stub -- see `NeutralSentiment`. | |
| """ | |
| p = params or {} | |
| fast_n, slow_n = int(p.get("fast_ma", 20)), int(p.get("slow_ma", 50)) | |
| gate = float(p.get("sentiment_gate", 0.40)) | |
| trail = p.get("trail_pct") | |
| close = prices["close"] | |
| fast, slow = sma(close, fast_n), sma(close, slow_n) | |
| src = sentiment or PriceProxySentiment() | |
| if hasattr(src, "score_from_prices"): | |
| score = src.score_from_prices(prices) | |
| else: | |
| score = src.score(prices.index, "") | |
| score = score.reindex(prices.index).fillna(0.0) | |
| gate_open = score >= gate | |
| entries = (_cross_up(fast, slow) & gate_open).astype("boolean").fillna(False).astype(bool) | |
| was_open = gate_open.astype("boolean").shift(1).fillna(False).astype(bool) | |
| exits = (_cross_down(fast, slow) | (~gate_open & was_open)) \ | |
| .astype("boolean").fillna(False).astype(bool) | |
| trig = pd.Series("", index=prices.index, dtype="object") | |
| trig[entries] = f"MA cross up with sentiment ≥ {gate:.2f}" | |
| trig[exits] = "MA cross down or sentiment gate closed" | |
| if trail: | |
| trig[entries] = trig[entries] + f" (trailing stop {float(trail):.1%})" | |
| return StrategyOutput(entries=entries, exits=exits, triggers=trig) | |
| # -------------------------------------------------------------------------- | |
| # Registry | |
| # -------------------------------------------------------------------------- | |
| class Preset: | |
| name: str | |
| fn: Callable | |
| needs_signals: bool = False | |
| available: bool = True | |
| unavailable_reason: str = "" | |
| params: tuple[tuple[str, str, float, float, float], ...] = () | |
| # (key, label, default, min, max) | |
| PRESETS: dict[str, Preset] = { | |
| p.name: p | |
| for p in [ | |
| Preset("Buy & Hold (benchmark)", buy_and_hold), | |
| Preset("SMA Crossover", sma_crossover, params=( | |
| ("fast_ma", "Fast MA", 20, 2, 200), | |
| ("slow_ma", "Slow MA", 50, 3, 400), | |
| )), | |
| Preset("RSI Mean Reversion", rsi_mean_reversion, params=( | |
| ("rsi_period", "RSI period", 14, 2, 100), | |
| ("oversold", "Oversold", 30, 1, 49), | |
| ("overbought", "Overbought", 70, 51, 99), | |
| )), | |
| Preset("Bollinger Breakout", bollinger_breakout, params=( | |
| ("bb_period", "Period", 20, 5, 200), | |
| ("bb_std", "Std devs", 2.0, 0.5, 5.0), | |
| )), | |
| Preset("MACD Momentum", macd_momentum, params=( | |
| ("macd_fast", "Fast EMA", 12, 2, 100), | |
| ("macd_slow", "Slow EMA", 26, 3, 200), | |
| ("macd_signal", "Signal", 9, 2, 50), | |
| )), | |
| Preset("Chronos Forecast Follower", forecast_follower, needs_signals=True, params=( | |
| ("threshold", "Entry edge", 0.005, 0.0, 0.2), | |
| ("exit_threshold", "Exit edge", 0.0, -0.1, 0.1), | |
| )), | |
| Preset("Sentiment-Gated Momentum", sentiment_gated_momentum, params=( | |
| ("fast_ma", "Fast MA", 20, 2, 200), | |
| ("slow_ma", "Slow MA", 50, 3, 400), | |
| ("sentiment_gate", "Sentiment gate", 0.40, -1.0, 1.0), | |
| )), | |
| # Present in the design; not runnable in v1. | |
| Preset("Pairs Trading", buy_and_hold, available=False, | |
| unavailable_reason="Needs a second leg; single-asset runs only in v1."), | |
| Preset("Custom (code)", buy_and_hold, available=False, | |
| unavailable_reason="Running user-supplied strategy code is disabled by " | |
| "design — this Space never executes untrusted code."), | |
| ] | |
| } | |
| PRESET_NAMES = list(PRESETS) | |
| def build(name: str, prices: pd.DataFrame, params: dict | None = None, | |
| signals: pd.DataFrame | None = None) -> StrategyOutput: | |
| """Run a preset by name. Unknown or unavailable presets raise.""" | |
| preset = PRESETS.get(name) | |
| if preset is None: | |
| raise KeyError(f"unknown strategy preset {name!r}") | |
| if not preset.available: | |
| raise ValueError(f"{name} is not available: {preset.unavailable_reason}") | |
| if preset.needs_signals and (signals is None or signals.empty): | |
| raise ValueError(f"{name} needs stored model signals for this asset and timeframe") | |
| return preset.fn(prices, params or {}, signals) | |
| def defaults_for(name: str) -> dict: | |
| preset = PRESETS.get(name) | |
| if preset is None: | |
| return {} | |
| return {k: d for k, _, d, _, _ in preset.params} | |