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.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- 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
|