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}