backtestchat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ """Pure backtest engine (no I/O).
2
+
3
+ Annualization goes through broker.base.bars_per_year(interval); crypto only
4
+ (24/7/365), so there is no asset-class branch.
5
+
6
+ Long/flat position model: fully invested when the strategy signal
7
+ is 1, fully in cash when 0. Signals are computed on a bar's close and the
8
+ resulting position is held over the NEXT bar (next-bar fill) to avoid look-ahead
9
+ bias. Costs/slippage are off by default (cost_bps=0) but wired so they can be
10
+ turned on without changing callers.
11
+ """
12
+
13
+ import math
14
+
15
+ from backtestchat.broker.base import bars_per_year
16
+ from backtestchat.strategy.registry import get_signal_fn, strategy_warmup
17
+
18
+ DEFAULT_INTERVAL = "1d"
19
+ DEFAULT_STARTING_EQUITY = 10_000.0
20
+
21
+
22
+ def _pct_returns(closes):
23
+ returns = [0.0]
24
+
25
+ for index in range(1, len(closes)):
26
+ prev = closes[index - 1]
27
+ returns.append((closes[index] / prev - 1.0) if prev else 0.0)
28
+
29
+ return returns
30
+
31
+
32
+ def _max_drawdown(equity):
33
+ peak = equity[0] if equity else 0.0
34
+ max_dd = 0.0
35
+
36
+ for value in equity:
37
+ if value > peak:
38
+ peak = value
39
+
40
+ if peak > 0:
41
+ dd = (value - peak) / peak
42
+
43
+ if dd < max_dd:
44
+ max_dd = dd
45
+
46
+ return max_dd * 100.0
47
+
48
+
49
+ def run_backtest(bars, strategy_type, params, interval=DEFAULT_INTERVAL,
50
+ starting_equity=DEFAULT_STARTING_EQUITY, cost_bps=0.0):
51
+ """Run a long/flat backtest over time-ordered OHLCV bars.
52
+
53
+ `bars` is a list of {ts, open, high, low, close, volume}. The strategy's
54
+ signal function (looked up by strategy_type) returns a 0/1 target position
55
+ per bar; positions are held next-bar to avoid look-ahead. Returns
56
+ {"equityCurve": [{ts, equity}], "trades": [{ts, side, price}], "metrics": {...}}.
57
+ Raises ValueError if there aren't enough bars for the strategy's warmup.
58
+ """
59
+ warmup = strategy_warmup(strategy_type, params)
60
+ if len(bars) <= warmup:
61
+ raise ValueError(
62
+ f"Not enough bars ({len(bars)}) for the {warmup}-bar warmup of this strategy."
63
+ )
64
+
65
+ signal_fn = get_signal_fn(strategy_type)
66
+ signals = signal_fn(bars, params)
67
+
68
+ return simulate(bars, signals, interval=interval,
69
+ starting_equity=starting_equity, cost_bps=cost_bps)
70
+
71
+
72
+ def simulate(bars, positions, interval=DEFAULT_INTERVAL,
73
+ starting_equity=DEFAULT_STARTING_EQUITY, cost_bps=0.0):
74
+ """Run a long/flat backtest from a precomputed 0/1 position series.
75
+
76
+ `positions[i]` is the target position computed from information available at
77
+ the CLOSE of bar i; it is held over bar i+1 (next-bar fill) to avoid
78
+ look-ahead. Returns
79
+ {"equityCurve": [{ts, equity}], "trades": [{ts, side, price}], "metrics": {...}}.
80
+ """
81
+ closes = [bar["close"] for bar in bars]
82
+ timestamps = [bar["ts"] for bar in bars]
83
+ bar_returns = _pct_returns(closes)
84
+ cost = cost_bps / 10_000.0
85
+
86
+ equity = starting_equity
87
+ equity_curve = [{"ts": timestamps[0], "equity": round(equity, 2)}]
88
+ trades = []
89
+ strategy_returns = []
90
+ invested_bars = 0
91
+ prev_position = 0
92
+
93
+ # Next-bar fill: position held over bar i is the position from bar i-1.
94
+ for index in range(1, len(closes)):
95
+ position = positions[index - 1] or 0
96
+
97
+ if position != prev_position:
98
+ side = "buy" if position == 1 else "sell"
99
+ trades.append({"ts": timestamps[index], "side": side, "price": round(closes[index], 4)})
100
+
101
+ # Apply a transaction cost on the bar the position flips.
102
+ equity *= (1.0 - cost)
103
+
104
+ bar_return = bar_returns[index] if position == 1 else 0.0
105
+ equity *= (1.0 + bar_return)
106
+ strategy_returns.append(bar_return)
107
+
108
+ if position == 1:
109
+ invested_bars += 1
110
+
111
+ equity_curve.append({"ts": timestamps[index], "equity": round(equity, 2)})
112
+ prev_position = position
113
+
114
+ metrics = _compute_metrics(
115
+ equity_curve=equity_curve,
116
+ strategy_returns=strategy_returns,
117
+ trades=trades,
118
+ invested_bars=invested_bars,
119
+ total_bars=len(closes) - 1,
120
+ interval=interval,
121
+ starting_equity=starting_equity,
122
+ )
123
+
124
+ return {"equityCurve": equity_curve, "trades": trades, "metrics": metrics}
125
+
126
+
127
+ def _compute_metrics(equity_curve, strategy_returns, trades, invested_bars,
128
+ total_bars, interval, starting_equity):
129
+ equity_values = [point["equity"] for point in equity_curve]
130
+ final_equity = equity_values[-1]
131
+ total_return = (final_equity / starting_equity - 1.0) * 100.0
132
+
133
+ factor = bars_per_year(interval)
134
+ n = len(strategy_returns)
135
+ years = n / factor if factor else 0.0
136
+
137
+ cagr = ((final_equity / starting_equity) ** (1.0 / years) - 1.0) * 100.0 if years > 0 else 0.0
138
+
139
+ if n > 1:
140
+ mean = sum(strategy_returns) / n
141
+ variance = sum((r - mean) ** 2 for r in strategy_returns) / (n - 1)
142
+ std = math.sqrt(variance)
143
+ else:
144
+ mean = strategy_returns[0] if strategy_returns else 0.0
145
+ std = 0.0
146
+
147
+ ann_vol = std * math.sqrt(factor) * 100.0
148
+ sharpe = (mean / std * math.sqrt(factor)) if std > 0 else 0.0
149
+
150
+ max_dd = _max_drawdown(equity_values)
151
+
152
+ # Win rate over completed round-trips (entry buy -> exit sell).
153
+ wins = 0
154
+ round_trips = 0
155
+ entry_price = None
156
+
157
+ for trade in trades:
158
+ if trade["side"] == "buy":
159
+ entry_price = trade["price"]
160
+ elif trade["side"] == "sell" and entry_price is not None:
161
+ round_trips += 1
162
+
163
+ if trade["price"] > entry_price:
164
+ wins += 1
165
+
166
+ entry_price = None
167
+
168
+ win_rate = (wins / round_trips * 100.0) if round_trips else 0.0
169
+ exposure = (invested_bars / total_bars * 100.0) if total_bars else 0.0
170
+
171
+ return {
172
+ "totalReturnPct": round(total_return, 2),
173
+ "cagrPct": round(cagr, 2),
174
+ "annVolPct": round(ann_vol, 2),
175
+ "sharpe": round(sharpe, 2),
176
+ "maxDrawdownPct": round(max_dd, 2),
177
+ "winRate": round(win_rate, 1),
178
+ "numTrades": len(trades),
179
+ "exposurePct": round(exposure, 1),
180
+ "finalEquity": round(final_equity, 2),
181
+ }
@@ -0,0 +1,186 @@
1
+ """Pure technical-indicator functions (no I/O).
2
+
3
+ All return a list aligned to the input length, with `None` during the warmup
4
+ period (before the indicator has enough data). Inputs are plain lists of floats.
5
+ """
6
+
7
+ import math
8
+
9
+
10
+ def sma(values, window):
11
+ """Trailing simple moving average; None for indices < window-1."""
12
+ out = []
13
+ running = 0.0
14
+
15
+ for index, value in enumerate(values):
16
+ running += value
17
+
18
+ if index >= window:
19
+ running -= values[index - window]
20
+
21
+ out.append(running / window if index >= window - 1 else None)
22
+
23
+ return out
24
+
25
+
26
+ def ema(values, window):
27
+ """Exponential moving average seeded with the SMA of the first `window`."""
28
+ out = [None] * len(values)
29
+
30
+ if len(values) < window:
31
+ return out
32
+
33
+ k = 2.0 / (window + 1)
34
+ seed = sum(values[:window]) / window
35
+ out[window - 1] = seed
36
+ prev = seed
37
+
38
+ for index in range(window, len(values)):
39
+ prev = values[index] * k + prev * (1 - k)
40
+ out[index] = prev
41
+
42
+ return out
43
+
44
+
45
+ def rolling_std(values, window):
46
+ """Trailing population standard deviation; None during warmup."""
47
+ out = []
48
+
49
+ for index in range(len(values)):
50
+ if index < window - 1:
51
+ out.append(None)
52
+ continue
53
+
54
+ window_slice = values[index - window + 1 : index + 1]
55
+ mean = sum(window_slice) / window
56
+ variance = sum((v - mean) ** 2 for v in window_slice) / window
57
+ out.append(math.sqrt(variance))
58
+
59
+ return out
60
+
61
+
62
+ def rolling_max(values, window):
63
+ out = []
64
+ for index in range(len(values)):
65
+ if index < window - 1:
66
+ out.append(None)
67
+ else:
68
+ out.append(max(values[index - window + 1 : index + 1]))
69
+ return out
70
+
71
+
72
+ def rolling_min(values, window):
73
+ out = []
74
+ for index in range(len(values)):
75
+ if index < window - 1:
76
+ out.append(None)
77
+ else:
78
+ out.append(min(values[index - window + 1 : index + 1]))
79
+ return out
80
+
81
+
82
+ def rsi(closes, period):
83
+ """Wilder's RSI; None until `period` returns are available."""
84
+ out = [None] * len(closes)
85
+
86
+ if len(closes) <= period:
87
+ return out
88
+
89
+ gains = 0.0
90
+ losses = 0.0
91
+
92
+ for index in range(1, period + 1):
93
+ change = closes[index] - closes[index - 1]
94
+ gains += max(change, 0.0)
95
+ losses += max(-change, 0.0)
96
+
97
+ avg_gain = gains / period
98
+ avg_loss = losses / period
99
+ out[period] = _rsi_value(avg_gain, avg_loss)
100
+
101
+ for index in range(period + 1, len(closes)):
102
+ change = closes[index] - closes[index - 1]
103
+ gain = max(change, 0.0)
104
+ loss = max(-change, 0.0)
105
+ avg_gain = (avg_gain * (period - 1) + gain) / period
106
+ avg_loss = (avg_loss * (period - 1) + loss) / period
107
+ out[index] = _rsi_value(avg_gain, avg_loss)
108
+
109
+ return out
110
+
111
+
112
+ def _rsi_value(avg_gain, avg_loss):
113
+ if avg_loss == 0:
114
+ return 100.0
115
+ rs = avg_gain / avg_loss
116
+ return 100.0 - 100.0 / (1.0 + rs)
117
+
118
+
119
+ def macd(closes, fast, slow, signal):
120
+ """Return (macd_line, signal_line), each aligned with None during warmup."""
121
+ ema_fast = ema(closes, fast)
122
+ ema_slow = ema(closes, slow)
123
+
124
+ macd_line = [
125
+ (ema_fast[i] - ema_slow[i]) if (ema_fast[i] is not None and ema_slow[i] is not None) else None
126
+ for i in range(len(closes))
127
+ ]
128
+
129
+ # Signal line = EMA of the macd_line over its non-None tail.
130
+ valid = [v for v in macd_line if v is not None]
131
+ signal_tail = ema(valid, signal)
132
+
133
+ signal_line = [None] * len(closes)
134
+ first_valid = next((i for i, v in enumerate(macd_line) if v is not None), None)
135
+
136
+ if first_valid is not None:
137
+ for offset, value in enumerate(signal_tail):
138
+ signal_line[first_valid + offset] = value
139
+
140
+ return macd_line, signal_line
141
+
142
+
143
+ def roc(closes, period):
144
+ """Rate of change (%) over `period` bars; None during warmup."""
145
+ out = [None] * len(closes)
146
+
147
+ for index in range(period, len(closes)):
148
+ prev = closes[index - period]
149
+ if prev:
150
+ out[index] = (closes[index] / prev - 1.0) * 100.0
151
+
152
+ return out
153
+
154
+
155
+ def true_range(highs, lows, closes):
156
+ """True range per bar; index 0 uses high-low."""
157
+ out = [highs[0] - lows[0]] if highs else []
158
+
159
+ for index in range(1, len(highs)):
160
+ prev_close = closes[index - 1]
161
+ out.append(max(
162
+ highs[index] - lows[index],
163
+ abs(highs[index] - prev_close),
164
+ abs(lows[index] - prev_close),
165
+ ))
166
+
167
+ return out
168
+
169
+
170
+ def atr(highs, lows, closes, window):
171
+ """Wilder's ATR (RMA of true range); None during warmup."""
172
+ tr = true_range(highs, lows, closes)
173
+ out = [None] * len(tr)
174
+
175
+ if len(tr) < window:
176
+ return out
177
+
178
+ seed = sum(tr[:window]) / window
179
+ out[window - 1] = seed
180
+ prev = seed
181
+
182
+ for index in range(window, len(tr)):
183
+ prev = (prev * (window - 1) + tr[index]) / window
184
+ out[index] = prev
185
+
186
+ return out
@@ -0,0 +1,272 @@
1
+ """Strategy registry - the catalog of single-signal long/flat strategies.
2
+
3
+ Each entry declares:
4
+ - label / description (for display + the agent catalog)
5
+ - param_schema (defaults + bounds; also rendered into the catalog)
6
+ - validate(params) -> normalized params dict (fills defaults, enforces bounds)
7
+ - signal_fn(bars, params) -> [0|1] per bar
8
+ - series_fn(bars, params) -> ([0|1] per bar, {name: per-bar indicator series})
9
+ - warmup(params) -> int (min bars before the first valid signal)
10
+ - format_label(params) -> human label (e.g. "RSI (14, 30/70)")
11
+
12
+ Adding a strategy is a registry entry + a signal function in signals.py; the
13
+ engine and services look everything up by strategy_type.
14
+ """
15
+
16
+ from backtestchat.strategy import signals
17
+
18
+
19
+ def _int(params, key, default):
20
+ return int(params.get(key, default)) if params.get(key) is not None else default
21
+
22
+
23
+ def _float(params, key, default):
24
+ return float(params.get(key, default)) if params.get(key) is not None else default
25
+
26
+
27
+ # --- validators (return normalized param dicts) --------------------------------
28
+
29
+ def _v_buy_hold(p):
30
+ return {}
31
+
32
+
33
+ def _v_crossover(p, fast_def, slow_def):
34
+ fast = _int(p, "fast", fast_def)
35
+ slow = _int(p, "slow", slow_def)
36
+ if fast < 2 or slow < 3:
37
+ raise ValueError("fast must be >= 2 and slow must be >= 3.")
38
+ if fast >= slow:
39
+ raise ValueError("fast window must be smaller than slow window.")
40
+ return {"fast": fast, "slow": slow}
41
+
42
+
43
+ def _v_macd(p):
44
+ fast = _int(p, "fast", 12)
45
+ slow = _int(p, "slow", 26)
46
+ signal = _int(p, "signal", 9)
47
+ if fast < 2 or slow <= fast or signal < 1:
48
+ raise ValueError("macd needs fast >= 2, slow > fast, signal >= 1.")
49
+ return {"fast": fast, "slow": slow, "signal": signal}
50
+
51
+
52
+ def _v_rsi(p):
53
+ period = _int(p, "period", 14)
54
+ lower = _float(p, "lower", 30.0)
55
+ upper = _float(p, "upper", 70.0)
56
+ if period < 2 or not (0 < lower < upper < 100):
57
+ raise ValueError("rsi needs period >= 2 and 0 < lower < upper < 100.")
58
+ return {"period": period, "lower": lower, "upper": upper}
59
+
60
+
61
+ def _v_bollinger(p):
62
+ window = _int(p, "window", 20)
63
+ num_std = _float(p, "num_std", 2.0)
64
+ if window < 3 or num_std <= 0:
65
+ raise ValueError("bollinger needs window >= 3 and num_std > 0.")
66
+ return {"window": window, "num_std": num_std}
67
+
68
+
69
+ def _v_roc(p):
70
+ period = _int(p, "period", 63)
71
+ if period < 2:
72
+ raise ValueError("roc needs period >= 2.")
73
+ return {"period": period}
74
+
75
+
76
+ def _v_price_above_sma(p):
77
+ window = _int(p, "window", 200)
78
+ if window < 2:
79
+ raise ValueError("price_above_sma needs window >= 2.")
80
+ return {"window": window}
81
+
82
+
83
+ def _v_donchian(p):
84
+ entry = _int(p, "entry", 20)
85
+ exit_w = _int(p, "exit", 10)
86
+ if entry < 2 or exit_w < 2:
87
+ raise ValueError("donchian needs entry >= 2 and exit >= 2.")
88
+ return {"entry": entry, "exit": exit_w}
89
+
90
+
91
+ def _v_stochastic(p):
92
+ k = _int(p, "k", 14)
93
+ d = _int(p, "d", 3)
94
+ if k < 2 or d < 1:
95
+ raise ValueError("stochastic needs k >= 2 and d >= 1.")
96
+ return {"k": k, "d": d}
97
+
98
+
99
+ def _v_atr_channel(p):
100
+ window = _int(p, "window", 20)
101
+ mult = _float(p, "mult", 2.0)
102
+ if window < 2 or mult <= 0:
103
+ raise ValueError("atr_channel needs window >= 2 and mult > 0.")
104
+ return {"window": window, "mult": mult}
105
+
106
+
107
+ STRATEGY_REGISTRY = {
108
+ "buy_hold": {
109
+ "label": "Buy & Hold",
110
+ "description": "Always fully invested. Market baseline.",
111
+ "param_schema": {},
112
+ "signal_fn": signals.buy_hold_signals,
113
+ "series_fn": signals.buy_hold_series,
114
+ "validate": _v_buy_hold,
115
+ "warmup": lambda p: 1,
116
+ "format_label": lambda p: "Buy & Hold",
117
+ },
118
+ "ma_crossover": {
119
+ "label": "SMA Crossover",
120
+ "description": "Long when the fast SMA is above the slow SMA. params: fast (50), slow (200).",
121
+ "param_schema": {"fast": {"default": 50}, "slow": {"default": 200}},
122
+ "signal_fn": signals.ma_crossover_signals,
123
+ "series_fn": signals.ma_crossover_series,
124
+ "validate": lambda p: _v_crossover(p, 50, 200),
125
+ "warmup": lambda p: p["slow"],
126
+ "format_label": lambda p: f"SMA Crossover ({p['fast']}/{p['slow']})",
127
+ },
128
+ "ema_crossover": {
129
+ "label": "EMA Crossover",
130
+ "description": "Long when the fast EMA is above the slow EMA. params: fast (12), slow (26).",
131
+ "param_schema": {"fast": {"default": 12}, "slow": {"default": 26}},
132
+ "signal_fn": signals.ema_crossover_signals,
133
+ "series_fn": signals.ema_crossover_series,
134
+ "validate": lambda p: _v_crossover(p, 12, 26),
135
+ "warmup": lambda p: p["slow"] * 3,
136
+ "format_label": lambda p: f"EMA Crossover ({p['fast']}/{p['slow']})",
137
+ },
138
+ "macd": {
139
+ "label": "MACD",
140
+ "description": "Long when the MACD line is above its signal line. params: fast (12), slow (26), signal (9).",
141
+ "param_schema": {"fast": {"default": 12}, "slow": {"default": 26}, "signal": {"default": 9}},
142
+ "signal_fn": signals.macd_signals,
143
+ "series_fn": signals.macd_series,
144
+ "validate": _v_macd,
145
+ "warmup": lambda p: p["slow"] * 3 + p["signal"],
146
+ "format_label": lambda p: f"MACD ({p['fast']}/{p['slow']}/{p['signal']})",
147
+ },
148
+ "rsi": {
149
+ "label": "RSI",
150
+ "description": "Mean-reversion: go long when RSI falls below lower, exit when it rises above upper. params: period (14), lower (30), upper (70).",
151
+ "param_schema": {"period": {"default": 14}, "lower": {"default": 30}, "upper": {"default": 70}},
152
+ "signal_fn": signals.rsi_signals,
153
+ "series_fn": signals.rsi_series,
154
+ "validate": _v_rsi,
155
+ "warmup": lambda p: p["period"] + 1,
156
+ "format_label": lambda p: f"RSI ({p['period']}, {int(p['lower'])}/{int(p['upper'])})",
157
+ },
158
+ "bollinger": {
159
+ "label": "Bollinger Bands",
160
+ "description": "Mean-reversion: go long when price closes below the lower band, exit when it closes above the middle band. params: window (20), num_std (2).",
161
+ "param_schema": {"window": {"default": 20}, "num_std": {"default": 2}},
162
+ "signal_fn": signals.bollinger_signals,
163
+ "series_fn": signals.bollinger_series,
164
+ "validate": _v_bollinger,
165
+ "warmup": lambda p: p["window"],
166
+ "format_label": lambda p: f"Bollinger ({p['window']}, {p['num_std']:g}sd)",
167
+ },
168
+ "roc": {
169
+ "label": "Momentum (ROC)",
170
+ "description": "Long when the rate of change over the period is positive. params: period (63).",
171
+ "param_schema": {"period": {"default": 63}},
172
+ "signal_fn": signals.roc_signals,
173
+ "series_fn": signals.roc_series,
174
+ "validate": _v_roc,
175
+ "warmup": lambda p: p["period"],
176
+ "format_label": lambda p: f"Momentum ROC ({p['period']})",
177
+ },
178
+ "price_above_sma": {
179
+ "label": "Price vs SMA",
180
+ "description": "Trend filter: long when price is above its SMA. params: window (200).",
181
+ "param_schema": {"window": {"default": 200}},
182
+ "signal_fn": signals.price_above_sma_signals,
183
+ "series_fn": signals.price_above_sma_series,
184
+ "validate": _v_price_above_sma,
185
+ "warmup": lambda p: p["window"],
186
+ "format_label": lambda p: f"Price > SMA ({p['window']})",
187
+ },
188
+ "donchian_breakout": {
189
+ "label": "Donchian Breakout",
190
+ "description": "Trend breakout: long when price breaks above the prior entry-day high, exit below the prior exit-day low. params: entry (20), exit (10).",
191
+ "param_schema": {"entry": {"default": 20}, "exit": {"default": 10}},
192
+ "signal_fn": signals.donchian_breakout_signals,
193
+ "series_fn": signals.donchian_breakout_series,
194
+ "validate": _v_donchian,
195
+ "warmup": lambda p: max(p["entry"], p["exit"]),
196
+ "format_label": lambda p: f"Donchian ({p['entry']}/{p['exit']})",
197
+ },
198
+ "stochastic": {
199
+ "label": "Stochastic Oscillator",
200
+ "description": "Long when %K is above %D. params: k (14), d (3).",
201
+ "param_schema": {"k": {"default": 14}, "d": {"default": 3}},
202
+ "signal_fn": signals.stochastic_signals,
203
+ "series_fn": signals.stochastic_series,
204
+ "validate": _v_stochastic,
205
+ "warmup": lambda p: p["k"] + p["d"],
206
+ "format_label": lambda p: f"Stochastic ({p['k']}/{p['d']})",
207
+ },
208
+ "atr_channel": {
209
+ "label": "ATR Channel (Keltner)",
210
+ "description": "Breakout: long when price closes above EMA + mult*ATR, exit when it closes below the EMA. params: window (20), mult (2).",
211
+ "param_schema": {"window": {"default": 20}, "mult": {"default": 2}},
212
+ "signal_fn": signals.atr_channel_signals,
213
+ "series_fn": signals.atr_channel_series,
214
+ "validate": _v_atr_channel,
215
+ "warmup": lambda p: p["window"] + 1,
216
+ "format_label": lambda p: f"ATR Channel ({p['window']}, {p['mult']:g}x)",
217
+ },
218
+ }
219
+
220
+
221
+ def is_supported_strategy(strategy_type):
222
+ return strategy_type in STRATEGY_REGISTRY
223
+
224
+
225
+ def supported_strategies():
226
+ return list(STRATEGY_REGISTRY.keys())
227
+
228
+
229
+ def get_signal_fn(strategy_type):
230
+ entry = STRATEGY_REGISTRY.get(strategy_type)
231
+ if not entry:
232
+ raise ValueError(f"Unsupported strategy: {strategy_type}")
233
+ return entry["signal_fn"]
234
+
235
+
236
+ def get_series_fn(strategy_type):
237
+ entry = STRATEGY_REGISTRY.get(strategy_type)
238
+ if not entry:
239
+ raise ValueError(f"Unsupported strategy: {strategy_type}")
240
+ return entry["series_fn"]
241
+
242
+
243
+ def validate_params(strategy_type, params):
244
+ entry = STRATEGY_REGISTRY.get(strategy_type)
245
+ if not entry:
246
+ raise ValueError(f"Unsupported strategy: {strategy_type}")
247
+ return entry["validate"](params or {})
248
+
249
+
250
+ def strategy_warmup(strategy_type, params):
251
+ entry = STRATEGY_REGISTRY.get(strategy_type)
252
+ if not entry:
253
+ return 1
254
+ return entry["warmup"](params)
255
+
256
+
257
+ def strategy_label(strategy_type, params=None):
258
+ entry = STRATEGY_REGISTRY.get(strategy_type)
259
+ if not entry:
260
+ return strategy_type
261
+ try:
262
+ return entry["format_label"](params or entry["validate"]({}))
263
+ except Exception:
264
+ return entry["label"]
265
+
266
+
267
+ def describe_strategies():
268
+ """Catalog text for the agent prompt."""
269
+ lines = []
270
+ for key, entry in STRATEGY_REGISTRY.items():
271
+ lines.append(f"- {key}: {entry['description']}")
272
+ return "\n".join(lines)