quantred 2.4.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.
Files changed (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,643 @@
1
+ """Base class for trading strategies - the part where you encode your beautiful
2
+ theory about the market, which the backtester then evaluates without mercy.
3
+
4
+ A :class:`Strategy` is responsible for exactly three things:
5
+
6
+ 1. **Indicators** - turn raw OHLCV into the columns it needs (:meth:`process_data`).
7
+ 2. **Conviction** - turn those columns into a single continuous **score** per bar
8
+ (:meth:`calculate_scores`): signed (``+`` bullish, ``-`` bearish), with
9
+ magnitude = strength. This is the one source of truth for the strategy's view.
10
+ The discrete ``BUY/SELL/HOLD`` the trade clock consumes is *derived* from the
11
+ score by the base class (:meth:`generate_signals`); the cross-sectional alpha
12
+ layer reads the same score. There is no parallel discrete-signal path.
13
+ 3. **Sizing & risk** - decide how big a position should be and reject signals
14
+ that conflict with the current book.
15
+
16
+ Deliberately *not* a strategy's job (separation of concerns):
17
+
18
+ * fetching data -> ``quantred/marketdata``
19
+ * placing orders -> ``quantred/execution``
20
+ * simulating fills / P&L -> ``quantred/engine``
21
+ * computing performance -> ``quantred/analytics``
22
+
23
+ The same strategy object is driven unchanged by the backtest and live engines.
24
+ """
25
+
26
+ import logging
27
+ from abc import ABC, abstractmethod
28
+ from dataclasses import dataclass
29
+ from datetime import datetime
30
+ from typing import Any, Dict, Optional, Tuple
31
+
32
+ import pandas as pd
33
+
34
+ from quantred.strategies import signals
35
+ from quantred.utils.timeutils import match_index_tz
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ #: Whether a live strategy opens a position implied by the score but whose entry edge
40
+ #: it never saw — a crossing lost to a rejected bar, a dropped stream, a restart, or
41
+ #: the warm-up history. On by default: a trend-follower started mid-trend should hold
42
+ #: the trend, not sit flat until the next crossing. Set ``reaffirm_entries=False`` in a
43
+ #: strategy's config to wait for a fresh edge instead. Exits ignore this entirely.
44
+ REAFFIRM_ENTRIES_DEFAULT = True
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ScoreThresholds:
49
+ """Hysteresis bands that turn a continuous score into discrete entries/exits.
50
+
51
+ Semantics (long side): enter long when ``score > enter_long``; stay long while
52
+ ``score > exit_long``; exit when ``score <= exit_long``. The short side mirrors
53
+ it. Defaults are all 0 - pure sign: long while score > 0. A strategy with
54
+ asymmetric entry/exit levels (e.g. enter oversold, exit overbought) overrides
55
+ :meth:`Strategy.signal_thresholds`.
56
+ """
57
+
58
+ enter_long: float = 0.0
59
+ exit_long: float = 0.0
60
+ enter_short: float = 0.0
61
+ exit_short: float = 0.0
62
+
63
+
64
+ #: Portfolio limits applied when a strategy declares no ``position_limits``.
65
+ #:
66
+ #: The two fractions measure different things and read alike:
67
+ #:
68
+ #: * ``max_total_risk`` is a **risk budget** - the fraction of equity the book gives
69
+ #: up if every open position stops out. A position contributes
70
+ #: ``notional x stop_loss``, so a tight stop buys a lot of notional for very
71
+ #: little budget. It bounds loss-at-stop, not how much is deployed.
72
+ #: * ``max_gross_exposure`` is a **notional cap** - marked gross notional over
73
+ #: equity, shorts counted by magnitude. ``None`` (the default) leaves free cash as
74
+ #: the only bound on deployed notional.
75
+ #: * ``min_notional`` is an **execution floor** in dollars, not a fraction: a venue or
76
+ #: broker minimum below which an order would be refused. ``None`` (the default) keeps
77
+ #: the historical behaviour of assuming any positive size is fillable, which stops
78
+ #: being a safe assumption as capital shrinks - at $4,000 across a wide universe the
79
+ #: sizer routinely asks for positions a real account could not open.
80
+ #:
81
+ #: They live here rather than in the backtest engine because both clocks enforce
82
+ #: them, and the trade clock cannot import the engine.
83
+ DEFAULT_POSITION_LIMITS: Dict[str, Any] = {
84
+ "max_positions": 5,
85
+ "max_position_size": 1500.0,
86
+ "max_total_risk": 0.05,
87
+ "max_gross_exposure": None,
88
+ #: Ceiling on |long - short| as a fraction of equity. Distinct from
89
+ #: ``max_gross_exposure``, which bounds long + short: a book can sit inside a gross
90
+ #: cap while being entirely one-directional, so a long/short strategy needs both.
91
+ "max_net_exposure": None,
92
+ "min_notional": None,
93
+ }
94
+
95
+
96
+ def _did_you_mean(name: str, known) -> str:
97
+ import difflib
98
+
99
+ close = difflib.get_close_matches(name, list(known), n=1)
100
+ return f" (did you mean {close[0]!r}?)" if close else ""
101
+
102
+
103
+ #: Config keys the base class itself reads or writes that are not tunable parameters.
104
+ #: Documented in the configuration guide; a config may carry any of them.
105
+ BEHAVIOURAL_CONFIG_KEYS = ("position_limits", "reaffirm_entries", "required_lookback_periods", "timeframe")
106
+
107
+
108
+ def refuse_unknown_params(strategy_class, params: Optional[Dict[str, Any]]) -> None:
109
+ """Refuse a key that is a near-miss of a declared parameter - almost always a typo.
110
+
111
+ ``Strategy.__init__`` range-checks declared parameters and passes any other key
112
+ straight through, so ``{"fast_emaperiod": 9}`` ran at the defaults and was even
113
+ served the defaults' memoized result, with nothing saying the override reached
114
+ nothing.
115
+
116
+ Only near-misses are refused. The base class documents that strategy-specific keys
117
+ may be added freely, and a private strategy may read keys it never declares in
118
+ ``PARAM_RANGES``; refusing every undeclared key broke ``reaffirm_entries`` and would
119
+ break those. Any other undeclared key is let through and logged. The base class's
120
+ behavioural keys and ``_``-prefixed identity keys are never questioned.
121
+ """
122
+ import difflib
123
+
124
+ declared = strategy_class.PARAM_RANGES
125
+ # A strategy's own non-parameter keys (found in the final review: a near-pair such
126
+ # as `entry_threshold` declared and `exit_threshold` read undeclared was refused on
127
+ # every surface, `live --config` included).
128
+ extra = set(getattr(strategy_class, "EXTRA_CONFIG_KEYS", ()) or ())
129
+ undeclared = [
130
+ name
131
+ for name in (params or {})
132
+ if name not in declared
133
+ and name not in BEHAVIOURAL_CONFIG_KEYS
134
+ and name not in extra
135
+ and not name.startswith("_")
136
+ ]
137
+ typos = {}
138
+ for name in undeclared:
139
+ close = difflib.get_close_matches(name, list(declared), n=1, cutoff=0.8)
140
+ if close:
141
+ typos[name] = close[0]
142
+ else:
143
+ logger.warning(
144
+ "%s declares no parameter %r; passing it through as a strategy-specific key.",
145
+ strategy_class.__name__,
146
+ name,
147
+ )
148
+ if typos:
149
+ named = "; ".join(f"{name!r} (did you mean {close!r}?)" for name, close in typos.items())
150
+ raise ValueError(
151
+ f"{strategy_class.__name__} has no parameter {named}. Its parameters: {', '.join(sorted(declared))}."
152
+ )
153
+
154
+
155
+ def refuse_undeclared_params(strategy_class, params: Optional[Dict[str, Any]]) -> None:
156
+ """Refuse any key the strategy does not declare in ``PARAM_RANGES``.
157
+
158
+ The strict sibling of :func:`refuse_unknown_params`, for a caller whose params are by
159
+ definition one point of a *declared* search space - a screen only ever varies
160
+ declared parameters, so anything else is not a screened point. Passed a screen's
161
+ ``best_point`` (a flat dict of parameters and metrics), the lenient check let the
162
+ metric keys through into a journaled trial's config; and ``{"bogus": 1}`` ran the
163
+ defaults while echoing ``bogus`` back as if it had been confirmed.
164
+ """
165
+ declared = strategy_class.PARAM_RANGES
166
+ extra = [name for name in (params or {}) if name not in declared]
167
+ if extra:
168
+ named = "; ".join(f"{name!r}{_did_you_mean(name, declared)}" for name in extra)
169
+ raise ValueError(
170
+ f"{strategy_class.__name__} declares no parameter {named}. Its parameters: "
171
+ f"{', '.join(sorted(declared))}. Pass only parameter values - from `run_screen`, "
172
+ "`best_point.params`, not the metrics beside them."
173
+ )
174
+
175
+
176
+ def refuse_unknown_limits(limits: Optional[Dict[str, Any]]) -> None:
177
+ """Refuse a book limit nothing enforces - a typo'd cap is no cap at all."""
178
+ unknown = [name for name in (limits or {}) if name not in DEFAULT_POSITION_LIMITS]
179
+ if unknown:
180
+ named = "; ".join(f"{name!r}{_did_you_mean(name, DEFAULT_POSITION_LIMITS)}" for name in unknown)
181
+ raise ValueError(f"No position limit {named}. Limits: {', '.join(DEFAULT_POSITION_LIMITS)}.")
182
+
183
+
184
+ def resolve_book(
185
+ strategy_class: "type[Strategy]", overrides: Optional[Dict[str, Any]] = None
186
+ ) -> Dict[str, Any]:
187
+ """The book a run will actually trade: class defaults with ``overrides`` merged in.
188
+
189
+ Deliberately independent of the tunable parameters. A search's identity is fixed
190
+ before its winner is known, so the recipe that identifies it cannot resolve a book
191
+ from params that do not exist yet — and if the recipe and the config saved
192
+ afterwards resolved differently, the two would disagree about the book for the same
193
+ run, which is the whole defect this resolution exists to close.
194
+
195
+ :func:`build_with_limits` is the sibling for the case where params *are* known and
196
+ an instance is wanted; this is the one for asking what the book is.
197
+ """
198
+ strategy = strategy_class.create_with_defaults()
199
+ return {**strategy.position_limits(), **(overrides or {})}
200
+
201
+
202
+ def build_with_limits(
203
+ strategy_class: "type[Strategy]",
204
+ params: Dict[str, Any],
205
+ position_limits: Optional[Dict[str, Any]] = None,
206
+ ) -> "Strategy":
207
+ """Construct a candidate and give it the book the config asked for.
208
+
209
+ ``position_limits`` is not a tunable parameter, so anything reconstructing a
210
+ strategy from params alone drops it — and a config asking for eight positions then
211
+ gets evaluated at one, which is a different strategy wearing the same name. Applied
212
+ after construction because it does not go through ``PARAM_RANGES`` validation, and
213
+ merged over what the class declares so an unspecified limit keeps its default.
214
+
215
+ One definition, because every sweep over a parameter space needs it and each one
216
+ that wrote its own is a place the book can silently go missing.
217
+ """
218
+ strategy = strategy_class(dict(params))
219
+ if position_limits:
220
+ strategy.config["position_limits"] = {**strategy.position_limits(), **position_limits}
221
+ return strategy
222
+
223
+
224
+ class Strategy(ABC):
225
+ """Abstract base for all trading strategies."""
226
+
227
+ #: Subclasses declare tunable parameters here for validation/optimization.
228
+ PARAM_RANGES: Dict[str, Dict[str, Any]] = {}
229
+
230
+ #: Relationships between those parameters that any valid config must satisfy,
231
+ #: as ``(left, op, right)`` triples where each side is a parameter name or a
232
+ #: literal — e.g. ``(("fast_ema_period", "<", "slow_ema_period"),)``.
233
+ #:
234
+ #: Declared rather than checked in ``validate_config`` because a sampler has to be
235
+ #: able to *read* them: a combination that gets evaluated is a journaled trial, and
236
+ #: a journaled trial permanently raises the deflation bar for every future
237
+ #: candidate in its family. Rejecting invalid points after drawing them would still
238
+ #: spend that budget; the search space simply must not contain them.
239
+ PARAM_CONSTRAINTS: Tuple[Tuple[str, str, Any], ...] = ()
240
+
241
+ #: Config keys this strategy reads that are not tunable parameters. A key that is
242
+ #: neither declared here nor in ``PARAM_RANGES`` still passes through, but one whose
243
+ #: name is close to a declared parameter is refused as a likely typo; a strategy that
244
+ #: declares ``entry_threshold`` and reads an undeclared ``exit_threshold`` names it
245
+ #: here so the check knows it is deliberate.
246
+ EXTRA_CONFIG_KEYS: Tuple[str, ...] = ()
247
+
248
+ #: Whether the strategy may hold short positions. Long-only strategies never
249
+ #: emit SELL entries; a negative score simply means "flat" (exit any long).
250
+ LONG_ONLY: bool = True
251
+
252
+ @classmethod
253
+ def create_with_defaults(cls) -> "Strategy":
254
+ """Construct an instance using each parameter's declared default value.
255
+
256
+ The single construction path used by the CLI, MCP server, and research
257
+ agent, so every entry point builds a strategy the same way.
258
+ """
259
+ config = {param: spec["default"] for param, spec in cls.PARAM_RANGES.items()}
260
+ return cls(config)
261
+
262
+ def __init__(self, config: Dict[str, Any]):
263
+ """Initialize with a configuration dict.
264
+
265
+ Recognized keys (strategy-specific keys may be added freely):
266
+ risk_per_trade: fraction of capital risked per trade
267
+ stop_loss / take_profit: fractional distances from entry price
268
+ position_limits: optional {max_positions, max_position_size,
269
+ max_total_risk, max_gross_exposure}. max_total_risk is a
270
+ stop-weighted risk budget (loss if everything stops out), not a
271
+ cap on deployed notional; max_gross_exposure is the notional cap.
272
+ Both are enforced across the book by the backtest engine.
273
+ reaffirm_entries: live-only; open a position the score implies even when
274
+ its entry edge was missed (default True). See
275
+ :data:`REAFFIRM_ENTRIES_DEFAULT`.
276
+ """
277
+ self.config = config
278
+
279
+ # symbol -> open position details, used for live signal validation.
280
+ self.positions: Dict[str, Dict[str, Any]] = {}
281
+
282
+ # Rolling per-symbol buffers, used only in live/real-time mode.
283
+ self.real_time_data: Dict[str, pd.DataFrame] = {}
284
+ self.last_processed_data: Dict[str, pd.DataFrame] = {}
285
+ self.max_buffer_size = 1000
286
+
287
+ # Cache the lookback the indicators need so the data layer can warm up.
288
+ self.config["required_lookback_periods"] = self.calculate_required_lookback()
289
+
290
+ self._validate_parameters()
291
+
292
+ # ------------------------------------------------------------------ #
293
+ # Configuration
294
+ # ------------------------------------------------------------------ #
295
+ def _validate_parameters(self) -> None:
296
+ """Coerce and range-check config values declared in ``PARAM_RANGES``."""
297
+ for param, value in self.config.items():
298
+ spec = self.PARAM_RANGES.get(param)
299
+ if spec is None:
300
+ continue
301
+
302
+ param_type = spec["type"]
303
+ try:
304
+ if param_type == "int":
305
+ value = int(value)
306
+ elif param_type == "float":
307
+ value = float(value)
308
+ self.config[param] = value
309
+ except (ValueError, TypeError):
310
+ raise ValueError(f"Parameter '{param}' value {value!r} cannot be converted to {param_type}")
311
+
312
+ # A spec without min/max is a *pinned* parameter: typed and required, but
313
+ # not searched (see ParameterSpace.searchable). There is no range to check.
314
+ if "min" in spec and "max" in spec and not (spec["min"] <= value <= spec["max"]):
315
+ raise ValueError(
316
+ f"Parameter '{param}' value {value} is outside valid range [{spec['min']}, {spec['max']}]"
317
+ )
318
+
319
+ self._validate_param_constraints()
320
+
321
+ def _validate_param_constraints(self) -> None:
322
+ """Enforce ``PARAM_CONSTRAINTS`` on the assembled config.
323
+
324
+ Here rather than in each strategy's ``initialize``, and reading the same
325
+ declaration a sampler reads, so the rule has one definition. A strategy that
326
+ restates the comparison in code gives the sampler nothing to introspect - and
327
+ the sampler is the half that matters, because a combination it draws gets
328
+ *evaluated*, and an evaluated combination is a journaled trial.
329
+ """
330
+ if not self.PARAM_CONSTRAINTS:
331
+ return
332
+ from quantred.optimization.param_space import ParameterConstraints
333
+
334
+ broken = ParameterConstraints(self.PARAM_CONSTRAINTS, list(self.PARAM_RANGES)).violations(self.config)
335
+ if broken:
336
+ raise ValueError(f"{type(self).__name__} parameter constraints violated: {'; '.join(broken)}")
337
+
338
+ # ------------------------------------------------------------------ #
339
+ # Abstract hooks implemented by concrete strategies
340
+ # ------------------------------------------------------------------ #
341
+ @abstractmethod
342
+ def initialize(self) -> None:
343
+ """Validate parameters / set up indicators before trading begins."""
344
+
345
+ @abstractmethod
346
+ def process_data(self, data: pd.DataFrame) -> pd.DataFrame:
347
+ """Return ``data`` enriched with the indicator columns the strategy needs."""
348
+
349
+ @abstractmethod
350
+ def calculate_scores(self, data: pd.DataFrame) -> pd.Series:
351
+ """Return a continuous, signed conviction score per bar of processed ``data``.
352
+
353
+ Positive = bullish, negative = bearish, magnitude = strength; NaN where
354
+ indicators aren't yet valid. This is the strategy's single source of truth:
355
+ the discrete trade signal is derived from it (:meth:`generate_signals`) and
356
+ the cross-sectional alpha layer scales the same score.
357
+ """
358
+
359
+ @abstractmethod
360
+ def calculate_required_lookback(self) -> int:
361
+ """Number of bars required before indicators/signals are valid."""
362
+
363
+ # ------------------------------------------------------------------ #
364
+ # Signal derivation (shared - never overridden by a strategy)
365
+ # ------------------------------------------------------------------ #
366
+ def signal_thresholds(self) -> ScoreThresholds:
367
+ """The hysteresis bands used to derive discrete signals from the score.
368
+
369
+ Defaults to pure sign (enter long when score > 0). Strategies with
370
+ asymmetric entry/exit levels override this.
371
+ """
372
+ return ScoreThresholds()
373
+
374
+ def generate_signals(self, data: pd.DataFrame) -> Dict[Any, str]:
375
+ """Derive the discrete ``BUY/SELL/HOLD`` stream from :meth:`calculate_scores`.
376
+
377
+ Walks the score series once, tracking the desired position direction with
378
+ hysteresis: a fresh entry emits ``BUY``/``SELL``; returning to flat emits
379
+ ``CLOSE_BUY``/``CLOSE_SELL``; a direct flip closes first (the opposite entry
380
+ follows on the next bar). While a direction is held the bar emits ``HOLD``,
381
+ so entries are edge-triggered - re-affirmation is left to the engine, which
382
+ dedupes against the open position.
383
+ """
384
+ return self._walk_scores(data)[0]
385
+
386
+ def _walk_scores(self, data: pd.DataFrame) -> tuple:
387
+ """Return ``(signal per bar, the direction the score implies at the last bar)``.
388
+
389
+ The direction is the part edges alone cannot express. An edge says *change*;
390
+ the direction says *what should be true now*, which is the only thing that can
391
+ be compared against what is actually held.
392
+ """
393
+ if data.empty:
394
+ return {}, 0
395
+
396
+ scores = self.calculate_scores(data)
397
+ thresholds = self.signal_thresholds()
398
+ long_only = self.LONG_ONLY
399
+
400
+ out: Dict[Any, str] = {}
401
+ state = 0 # desired direction implied by the score: +1 long, -1 short, 0 flat
402
+ values = scores.to_numpy()
403
+ index = scores.index
404
+ for i in range(len(values)):
405
+ score = values[i]
406
+ if score != score: # NaN - indicators not yet valid
407
+ out[index[i]] = signals.HOLD
408
+ continue
409
+ target = self._desired_direction(score, state, thresholds, long_only)
410
+ signal, state = self._transition(state, target)
411
+ out[index[i]] = signal
412
+ return out, state
413
+
414
+ @staticmethod
415
+ def _desired_direction(score: float, state: int, th: ScoreThresholds, long_only: bool) -> int:
416
+ """Target direction for this bar given the score and the current direction."""
417
+ # Hold an existing position until its exit band trips.
418
+ if state == 1 and score > th.exit_long:
419
+ return 1
420
+ if state == -1 and score < th.exit_short:
421
+ return -1
422
+ # Flat, or just released: look for a fresh entry.
423
+ if score > th.enter_long:
424
+ return 1
425
+ if not long_only and score < th.enter_short:
426
+ return -1
427
+ return 0
428
+
429
+ @staticmethod
430
+ def _transition(state: int, target: int) -> tuple:
431
+ """Map a (current -> target) direction change to a signal + the new state."""
432
+ if target == state:
433
+ return signals.HOLD, state
434
+ if state == 0: # entering from flat
435
+ return (signals.BUY, 1) if target == 1 else (signals.SELL, -1)
436
+ # Leaving a position (exit to flat, or a flip): always close first and go
437
+ # flat; a flip re-enters on the next bar once state is 0.
438
+ return (signals.CLOSE_BUY if state == 1 else signals.CLOSE_SELL), 0
439
+
440
+ # ------------------------------------------------------------------ #
441
+ # Position sizing & risk
442
+ # ------------------------------------------------------------------ #
443
+ def position_limits(self) -> Dict[str, Any]:
444
+ """Configured portfolio limits merged over :data:`DEFAULT_POSITION_LIMITS`.
445
+
446
+ One accessor so the backtest engine and the live trader read the same merged
447
+ dict, and so a config that sets only some of the keys gets defaults for the
448
+ rest rather than a ``KeyError`` at the first entry.
449
+ """
450
+ return {**DEFAULT_POSITION_LIMITS, **(self.config.get("position_limits") or {})}
451
+
452
+ def calculate_position_size(self, capital: float, price: float, risk_factor: float = 1.0) -> float:
453
+ """Size a position from risk-per-trade, capped by configured limits.
454
+
455
+ The result is the smallest of three constraints: the risk-per-trade
456
+ target, the max notional per position, and the max total portfolio risk.
457
+
458
+ That third one is applied to *one* position in isolation, because sizing
459
+ has no view of the open book - it answers "could this position alone consume
460
+ the whole risk budget?". Enforcing the budget across the book is the
461
+ backtest engine's job on the research clock and the live trader's on the
462
+ trade clock; both read :meth:`position_limits`. ``max_gross_exposure`` is
463
+ absent here for the same reason, and more so: a cap on total deployed
464
+ notional says nothing about what any single position may take.
465
+ """
466
+ risk_amount = capital * self.config["risk_per_trade"] * risk_factor
467
+ stop_loss_pct = self.config["stop_loss"]
468
+ target_size = risk_amount / (price * stop_loss_pct)
469
+
470
+ limits = self.position_limits()
471
+ max_size_by_notional = limits["max_position_size"] / price
472
+ max_size_by_total_risk = (capital * limits["max_total_risk"]) / (price * stop_loss_pct)
473
+
474
+ return min(target_size, max_size_by_notional, max_size_by_total_risk)
475
+
476
+ def check_exit_conditions(self, data: pd.DataFrame) -> pd.DataFrame:
477
+ """Flag stop-loss / take-profit exits for currently open positions.
478
+
479
+ Returns a DataFrame aligned to ``data.index`` with ``exit_signal``,
480
+ ``exit_reason`` and ``signal_type`` columns.
481
+ """
482
+ exits = pd.DataFrame(index=data.index)
483
+ exits["exit_signal"] = False
484
+ exits["exit_reason"] = ""
485
+ exits["signal_type"] = signals.HOLD
486
+
487
+ for position in self.positions.values():
488
+ side = position.get("side")
489
+ if side == signals.BUY:
490
+ stop_hit = data["close"] <= position["stop_loss"]
491
+ take_hit = data["close"] >= position["take_profit"]
492
+ close_signal = signals.CLOSE_BUY
493
+ elif side == signals.SELL:
494
+ stop_hit = data["close"] >= position["stop_loss"]
495
+ take_hit = data["close"] <= position["take_profit"]
496
+ close_signal = signals.CLOSE_SELL
497
+ else:
498
+ continue
499
+
500
+ for mask, reason in ((stop_hit, "STOP_LOSS"), (take_hit, "TAKE_PROFIT")):
501
+ exits.loc[mask, "exit_signal"] = True
502
+ exits.loc[mask, "exit_reason"] = reason
503
+ exits.loc[mask, "signal_type"] = close_signal
504
+
505
+ return exits
506
+
507
+ def validate_signal(self, signal: str, symbol: str, price: float) -> bool:
508
+ """Reject signals that conflict with the current position book."""
509
+ if signal in signals.EXIT_SIGNALS:
510
+ position = self.positions.get(symbol)
511
+ if position is None:
512
+ return False
513
+ return (signal == signals.CLOSE_BUY and position["side"] == signals.BUY) or (
514
+ signal == signals.CLOSE_SELL and position["side"] == signals.SELL
515
+ )
516
+
517
+ # Don't stack a second position on the same side of an existing one.
518
+ existing = self.positions.get(symbol)
519
+ if existing and existing["side"] == signal:
520
+ return False
521
+ return True
522
+
523
+ # ------------------------------------------------------------------ #
524
+ # Real-time processing (live mode)
525
+ # ------------------------------------------------------------------ #
526
+ def _reaffirm(self, symbol: str, signal: str, direction: int) -> str:
527
+ """Re-state an edge that was missed, by comparing intent against the book.
528
+
529
+ Signals are edge-triggered: an entry is emitted on the bar the score crosses
530
+ and never again. Live, that edge can be missed - a bar rejected by the quality
531
+ guard, a dropped stream, a restart, or simply a crossing that happened inside
532
+ the warm-up history. Afterwards the score still says "should be long" while the
533
+ bar emits ``HOLD`` forever, and the position is never opened. The mirror case
534
+ is worse: a missed exit leaves a real position that nothing will close.
535
+
536
+ So live mode compares the direction the score implies against the position book
537
+ (which :class:`~quantred.execution.live_trader.LiveTrader` keeps synced with
538
+ broker truth) and re-states the difference. Where an edge says *change*, this
539
+ says *what should be true now* - the loop converges on the intended book
540
+ instead of depending on having caught one specific bar.
541
+
542
+ This is live-only. The backtest walks the same scores through
543
+ :meth:`generate_signals`, where its book is derived from those very signals and
544
+ so can never disagree - the two paths differ only in the case where the live
545
+ one has fallen behind.
546
+
547
+ A flip closes first and re-enters on the next bar, exactly as
548
+ :meth:`_transition` does, rather than reversing in one step.
549
+
550
+ **Entries are gated; exits never are.** ``reaffirm_entries=False`` makes the
551
+ strategy wait for a fresh crossing rather than opening a position on a signal
552
+ whose edge it did not witness - most visibly, an engine started into an
553
+ established trend stays flat until the next entry. That is a legitimate
554
+ preference about what a strategy trades. Declining to *close* a position the
555
+ strategy no longer wants is not a preference, it is a stuck position, so the
556
+ exit side stays unconditional whatever the flag says.
557
+ """
558
+ if signal != signals.HOLD:
559
+ return signal
560
+
561
+ desired = {1: signals.BUY, -1: signals.SELL}.get(direction)
562
+ held = self.positions.get(symbol)
563
+ held_side = held.get("side") if held else None
564
+
565
+ if desired == held_side: # book already matches intent (including both flat)
566
+ return signals.HOLD
567
+ if held_side is not None: # holding something we should not be: close it
568
+ return signals.CLOSE_BUY if held_side == signals.BUY else signals.CLOSE_SELL
569
+ if not self.config.get("reaffirm_entries", REAFFIRM_ENTRIES_DEFAULT):
570
+ return signals.HOLD
571
+ return desired or signals.HOLD
572
+
573
+ def process_bar(self, symbol: str, bar: Dict[str, float], timestamp: datetime) -> Optional[str]:
574
+ """Fold one streamed OHLCV bar into the rolling buffer and emit a signal.
575
+
576
+ ``bar`` must contain ``open``/``high``/``low``/``close``/``volume``. Returns
577
+ the latest signal once enough history has accumulated, otherwise ``None``.
578
+ Never raises - real-time processing must not break the stream.
579
+ """
580
+ try:
581
+ buffer = self.real_time_data.get(symbol)
582
+ if buffer is None:
583
+ buffer = pd.DataFrame(columns=["open", "high", "low", "close", "volume"])
584
+ buffer.index.name = "timestamp"
585
+
586
+ row = {key: bar[key] for key in ("open", "high", "low", "close", "volume")}
587
+ # Warm-up history arrives localized; a streamed bar may not be. One naive
588
+ # timestamp in an otherwise aware index makes every subsequent comparison
589
+ # raise, and the guard below would turn that into permanent silence.
590
+ timestamp = match_index_tz(timestamp, buffer.index)
591
+ buffer = pd.concat([buffer, pd.DataFrame([row], index=[timestamp])])
592
+ if len(buffer) > self.max_buffer_size:
593
+ buffer = buffer.tail(self.max_buffer_size)
594
+ self.real_time_data[symbol] = buffer
595
+
596
+ if len(buffer) < self.config.get("required_lookback_periods", 20):
597
+ return None
598
+
599
+ processed = self.process_data(buffer.copy())
600
+ self.last_processed_data[symbol] = processed
601
+
602
+ emitted, direction = self._walk_scores(processed)
603
+ latest = self._reaffirm(symbol, self._latest_signal(emitted), direction)
604
+ return latest if self.validate_signal(latest, symbol, bar["close"]) else signals.HOLD
605
+ except Exception: # noqa: BLE001 - never break the stream
606
+ # Swallowing keeps the stream alive, but a strategy that raises on every
607
+ # bar emits nothing and looks exactly like one with no opinion. Say so.
608
+ logger.warning("Discarding bar for %s: the strategy raised", symbol, exc_info=True)
609
+ return None
610
+
611
+ def process_real_time_data(
612
+ self, symbol: str, price: float, volume: float, timestamp: datetime
613
+ ) -> Optional[str]:
614
+ """Tick-style convenience wrapper: treat a single price as a flat bar."""
615
+ flat_bar = {"open": price, "high": price, "low": price, "close": price, "volume": volume}
616
+ return self.process_bar(symbol, flat_bar, timestamp)
617
+
618
+ def warm_up(self, symbol: str, processed_history: pd.DataFrame) -> None:
619
+ """Seed a symbol's real-time buffer with pre-processed historical bars."""
620
+ if not processed_history.empty:
621
+ self.real_time_data[symbol] = processed_history.tail(self.max_buffer_size).copy()
622
+
623
+ @staticmethod
624
+ def _latest_signal(generated) -> str:
625
+ """Extract the most recent signal from a dict/Series/list of signals."""
626
+ if isinstance(generated, dict) and generated:
627
+ return generated[max(generated.keys())]
628
+ if hasattr(generated, "iloc") and len(generated) > 0:
629
+ return generated.iloc[-1]
630
+ if isinstance(generated, (list, tuple)) and generated:
631
+ return generated[-1]
632
+ return signals.HOLD
633
+
634
+ def get_real_time_buffer(self, symbol: str) -> Optional[pd.DataFrame]:
635
+ return self.real_time_data.get(symbol)
636
+
637
+ def clear_real_time_buffer(self, symbol: Optional[str] = None) -> None:
638
+ if symbol is None:
639
+ self.real_time_data.clear()
640
+ self.last_processed_data.clear()
641
+ else:
642
+ self.real_time_data.pop(symbol, None)
643
+ self.last_processed_data.pop(symbol, None)
@@ -0,0 +1,28 @@
1
+ """The signal vocabulary shared by strategies, the engine, and execution.
2
+
3
+ Signals are plain strings so they can live directly in pandas objects and be
4
+ compared cheaply. Keeping them in one module avoids the classic bug where one
5
+ layer emits ``"buy"`` and another checks for ``"BUY"``.
6
+ """
7
+
8
+ from typing import FrozenSet
9
+
10
+ # Entry signals
11
+ BUY = "BUY"
12
+ SELL = "SELL"
13
+
14
+ # Explicit position-closing signals (emitted by exit logic / strategies)
15
+ CLOSE_BUY = "CLOSE_BUY"
16
+ CLOSE_SELL = "CLOSE_SELL"
17
+
18
+ # No action
19
+ HOLD = "HOLD"
20
+
21
+ #: Signals that open a new position.
22
+ ENTRY_SIGNALS: FrozenSet[str] = frozenset({BUY, SELL})
23
+
24
+ #: Signals that close an existing position.
25
+ EXIT_SIGNALS: FrozenSet[str] = frozenset({CLOSE_BUY, CLOSE_SELL})
26
+
27
+ #: Every actionable (non-HOLD) signal.
28
+ ACTIONABLE_SIGNALS: FrozenSet[str] = ENTRY_SIGNALS | EXIT_SIGNALS