Spaces:
Running on Zero
Running on Zero
File size: 14,337 Bytes
46f1a78 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 | """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
# --------------------------------------------------------------------------
@dataclass(frozen=True)
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}
|