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,114 @@
|
|
|
1
|
+
"""Pluggable position sizing for live execution - i.e. how much to bet, which is at
|
|
2
|
+
least as important as what to bet on and far easier to get catastrophically wrong.
|
|
3
|
+
|
|
4
|
+
`LiveTrader` asks a :class:`PositionSizer` "how many units of this symbol should I
|
|
5
|
+
buy at this price?" - it doesn't care *how* the answer is reached. Two strategies
|
|
6
|
+
ship:
|
|
7
|
+
|
|
8
|
+
* :class:`RiskBasedSizer` - the default; sizes each entry from the strategy's
|
|
9
|
+
risk-per-trade / stop-loss config (position-by-position).
|
|
10
|
+
* :class:`PortfolioWeightSizer` - sizes to a precomputed target weight per symbol,
|
|
11
|
+
e.g. from the OR-Tools :class:`~quantred.portfolio.allocator.PortfolioAllocator`
|
|
12
|
+
(portfolio-level).
|
|
13
|
+
|
|
14
|
+
This keeps sizing policy out of `LiveTrader` and lets the portfolio manager drive
|
|
15
|
+
live sizing without the executor knowing anything about it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from typing import Dict
|
|
20
|
+
|
|
21
|
+
from quantred.brokers.base import AccountSnapshot
|
|
22
|
+
from quantred.strategies.base import Strategy
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def below_min_notional(qty: float, price: float, min_notional) -> bool:
|
|
26
|
+
"""Whether a rounded order is too small for the venue to be worth sending.
|
|
27
|
+
|
|
28
|
+
One definition, reached by both clocks. The backtest refused an entry under the
|
|
29
|
+
floor and the live path did not look at it at all, so a config validated with a
|
|
30
|
+
floor traded without one — the same declared book admitting different orders
|
|
31
|
+
depending on which clock was asking, which is the failure mode the two-clock
|
|
32
|
+
separation makes invisible.
|
|
33
|
+
|
|
34
|
+
A floor is an execution fact rather than a preference: an order below it is
|
|
35
|
+
refused at the venue, or its costs swamp what it can earn. Filling one in a
|
|
36
|
+
backtest validates a book that could not be traded; sending one live trades a book
|
|
37
|
+
that was never validated.
|
|
38
|
+
|
|
39
|
+
An undeclared floor bounds nothing — absent is not zero, and a book that never
|
|
40
|
+
named a minimum has no minimum rather than one of zero.
|
|
41
|
+
"""
|
|
42
|
+
return bool(min_notional) and abs(qty) * price < min_notional
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PositionSizer(ABC):
|
|
46
|
+
"""Decides the (pre-rounding) size of a new position."""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def size(self, symbol: str, price: float, account: AccountSnapshot) -> float:
|
|
50
|
+
"""Return the desired position size in units (shares)."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RiskBasedSizer(PositionSizer):
|
|
54
|
+
"""Size each entry from the strategy's risk/stop configuration."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, strategy: Strategy):
|
|
57
|
+
self._strategy = strategy
|
|
58
|
+
|
|
59
|
+
def size(self, symbol: str, price: float, account: AccountSnapshot) -> float:
|
|
60
|
+
return self._strategy.calculate_position_size(account.buying_power, price)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BetaSizer(PositionSizer):
|
|
64
|
+
"""Risk-based sizing scaled inversely by each symbol's beta.
|
|
65
|
+
|
|
66
|
+
Higher beta (more volatile relative to the benchmark) -> smaller position, so
|
|
67
|
+
risk is more even across names. Betas are precomputed per symbol (sizing has
|
|
68
|
+
no data access); unknown symbols fall back to ``default_beta``. The effective
|
|
69
|
+
beta is clamped to ``[min_abs_beta, max_abs_beta]`` to avoid blow-ups near
|
|
70
|
+
zero and to bound leverage for tiny betas; the sign is ignored (a strongly
|
|
71
|
+
negative beta is still volatile).
|
|
72
|
+
|
|
73
|
+
It takes the strategy's normally-sized position (which already respects
|
|
74
|
+
risk-per-trade and position limits) and divides it by the effective beta, so
|
|
75
|
+
the scaling always applies - even when a position-limit cap is the binding
|
|
76
|
+
constraint.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
strategy: Strategy,
|
|
82
|
+
betas: Dict[str, float],
|
|
83
|
+
default_beta: float = 1.0,
|
|
84
|
+
min_abs_beta: float = 0.25,
|
|
85
|
+
max_abs_beta: float = 4.0,
|
|
86
|
+
):
|
|
87
|
+
self._strategy = strategy
|
|
88
|
+
self._betas = betas
|
|
89
|
+
self._default_beta = default_beta
|
|
90
|
+
self._min_abs_beta = min_abs_beta
|
|
91
|
+
self._max_abs_beta = max_abs_beta
|
|
92
|
+
|
|
93
|
+
def size(self, symbol: str, price: float, account: AccountSnapshot) -> float:
|
|
94
|
+
beta = self._betas.get(symbol, self._default_beta)
|
|
95
|
+
effective = min(max(abs(beta), self._min_abs_beta), self._max_abs_beta)
|
|
96
|
+
base_size = self._strategy.calculate_position_size(account.buying_power, price)
|
|
97
|
+
return base_size / effective
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PortfolioWeightSizer(PositionSizer):
|
|
101
|
+
"""Size to a target portfolio weight per symbol (weight x equity / price).
|
|
102
|
+
|
|
103
|
+
Symbols absent from the weight map get zero size, so the live universe is
|
|
104
|
+
effectively the set the allocator chose to fund.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, weights: Dict[str, float]):
|
|
108
|
+
self._weights = weights
|
|
109
|
+
|
|
110
|
+
def size(self, symbol: str, price: float, account: AccountSnapshot) -> float:
|
|
111
|
+
weight = self._weights.get(symbol, 0.0)
|
|
112
|
+
if weight <= 0 or price <= 0:
|
|
113
|
+
return 0.0
|
|
114
|
+
return (weight * account.equity) / price
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Technical indicators implemented with pure pandas/numpy - the squiggly lines we
|
|
2
|
+
all pretend predict the future.
|
|
3
|
+
|
|
4
|
+
This module is the single source of truth for indicator math in the project.
|
|
5
|
+
It deliberately uses **no compiled dependencies** (no TA-Lib, no tulipy): every
|
|
6
|
+
function here is plain pandas/numpy so the project installs with `pip install`
|
|
7
|
+
alone and the Docker image needs no build toolchain.
|
|
8
|
+
|
|
9
|
+
All functions are pure: they take Series/DataFrames in and return new
|
|
10
|
+
Series/DataFrames out, leaving the inputs untouched.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def calculate_sma(data: pd.Series, period: int) -> pd.Series:
|
|
17
|
+
"""Simple Moving Average."""
|
|
18
|
+
return data.rolling(window=period).mean()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def calculate_ema(data: pd.Series, period: int) -> pd.Series:
|
|
22
|
+
"""Exponential Moving Average."""
|
|
23
|
+
return data.ewm(span=period, adjust=False).mean()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def calculate_rsi(data: pd.Series, period: int = 14) -> pd.Series:
|
|
27
|
+
"""Relative Strength Index (Wilder-style smoothing via rolling mean).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
data: Price series (typically close).
|
|
31
|
+
period: Lookback period.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
RSI values in the range [0, 100].
|
|
35
|
+
"""
|
|
36
|
+
delta = data.diff()
|
|
37
|
+
gain = delta.where(delta > 0, 0.0).rolling(window=period).mean()
|
|
38
|
+
loss = (-delta.where(delta < 0, 0.0)).rolling(window=period).mean()
|
|
39
|
+
|
|
40
|
+
rs = gain / loss
|
|
41
|
+
return 100 - (100 / (1 + rs))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def calculate_atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
|
45
|
+
"""Average True Range.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
df: DataFrame with 'high', 'low', 'close' columns.
|
|
49
|
+
period: Lookback period.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
ATR series.
|
|
53
|
+
"""
|
|
54
|
+
high_low = df["high"] - df["low"]
|
|
55
|
+
high_close = (df["high"] - df["close"].shift()).abs()
|
|
56
|
+
low_close = (df["low"] - df["close"].shift()).abs()
|
|
57
|
+
|
|
58
|
+
true_range = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1)
|
|
59
|
+
return true_range.rolling(window=period).mean()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def calculate_beta(symbol_close: pd.Series, benchmark_close: pd.Series) -> float:
|
|
63
|
+
"""Beta of a symbol's returns against a benchmark's returns.
|
|
64
|
+
|
|
65
|
+
Beta = cov(symbol, benchmark) / var(benchmark), computed on aligned
|
|
66
|
+
period-over-period returns. Returns 1.0 (market-neutral) for degenerate input
|
|
67
|
+
(too few overlapping points, or a flat benchmark).
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
symbol_close: Close-price series for the symbol.
|
|
71
|
+
benchmark_close: Close-price series for the benchmark (e.g. SPY).
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The beta coefficient.
|
|
75
|
+
"""
|
|
76
|
+
prices = pd.concat([symbol_close, benchmark_close], axis=1, keys=["symbol", "benchmark"]).dropna()
|
|
77
|
+
if len(prices) < 3:
|
|
78
|
+
return 1.0
|
|
79
|
+
|
|
80
|
+
returns = prices.pct_change().dropna()
|
|
81
|
+
benchmark_var = returns["benchmark"].var()
|
|
82
|
+
if benchmark_var == 0 or returns.empty:
|
|
83
|
+
return 1.0
|
|
84
|
+
|
|
85
|
+
covariance = returns["symbol"].cov(returns["benchmark"])
|
|
86
|
+
return float(covariance / benchmark_var)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def calculate_residual_volatility(
|
|
90
|
+
symbol_close: pd.Series,
|
|
91
|
+
benchmark_close: pd.Series,
|
|
92
|
+
beta: float,
|
|
93
|
+
periods_per_year: float = 252.0,
|
|
94
|
+
) -> float:
|
|
95
|
+
"""Annualized volatility of a symbol's *residual* returns vs a benchmark.
|
|
96
|
+
|
|
97
|
+
Residual return strips out the part of the move that beta to the benchmark
|
|
98
|
+
explains: ``r_resid = r_symbol - beta * r_benchmark``. Its annualized std is the
|
|
99
|
+
``sigma`` the alpha-scaling identity (alpha = sigma * IC * z) needs - the risk
|
|
100
|
+
of the bet that *isn't* just market exposure.
|
|
101
|
+
|
|
102
|
+
Computed on aligned period-over-period returns. Returns 0.0 for degenerate
|
|
103
|
+
input (too few overlapping points). ``periods_per_year`` annualizes to the
|
|
104
|
+
sampling frequency of the series passed in (daily bars -> 252).
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
symbol_close: Close-price series for the symbol.
|
|
108
|
+
benchmark_close: Close-price series for the benchmark (e.g. SPY).
|
|
109
|
+
beta: The symbol's beta to the benchmark (see :func:`calculate_beta`).
|
|
110
|
+
periods_per_year: Annualization factor for the bar frequency.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Annualized residual volatility (a fraction, e.g. 0.20 == 20%/yr).
|
|
114
|
+
"""
|
|
115
|
+
prices = pd.concat([symbol_close, benchmark_close], axis=1, keys=["symbol", "benchmark"]).dropna()
|
|
116
|
+
if len(prices) < 3:
|
|
117
|
+
return 0.0
|
|
118
|
+
|
|
119
|
+
returns = prices.pct_change().dropna()
|
|
120
|
+
if returns.empty:
|
|
121
|
+
return 0.0
|
|
122
|
+
|
|
123
|
+
residual = returns["symbol"] - beta * returns["benchmark"]
|
|
124
|
+
std = residual.std()
|
|
125
|
+
if not (std == std): # NaN guard
|
|
126
|
+
return 0.0
|
|
127
|
+
return float(std * (periods_per_year**0.5))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def calculate_volume_spike(
|
|
131
|
+
volume: pd.Series,
|
|
132
|
+
price: pd.Series,
|
|
133
|
+
volume_ma_period: int = 20,
|
|
134
|
+
volume_threshold: float = 2.0,
|
|
135
|
+
price_change_threshold: float = 0.02,
|
|
136
|
+
) -> pd.Series:
|
|
137
|
+
"""Detect volume spikes that coincide with meaningful price movement.
|
|
138
|
+
|
|
139
|
+
A spike requires both (a) volume above ``volume_threshold`` times its moving
|
|
140
|
+
average and (b) an absolute one-bar price change above
|
|
141
|
+
``price_change_threshold`` (expressed as a fraction, e.g. 0.02 == 2%).
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
volume: Volume series.
|
|
145
|
+
price: Price series (typically close).
|
|
146
|
+
volume_ma_period: Window for the volume moving average baseline.
|
|
147
|
+
volume_threshold: Multiplier over the volume MA that defines a spike.
|
|
148
|
+
price_change_threshold: Minimum absolute fractional price change.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Boolean series, True where a qualifying spike occurred.
|
|
152
|
+
"""
|
|
153
|
+
volume_ma = volume.rolling(window=volume_ma_period).mean()
|
|
154
|
+
price_change = price.pct_change().abs()
|
|
155
|
+
|
|
156
|
+
spikes = (volume > volume_ma * volume_threshold) & (price_change > price_change_threshold)
|
|
157
|
+
# pct_change/rolling produce NaN at the head; treat those as "no spike".
|
|
158
|
+
return spikes.fillna(False)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Market-data provider abstraction.
|
|
2
|
+
|
|
3
|
+
Splitting *data* from *trading* keeps the two concerns independent: a deployment
|
|
4
|
+
can pull bars from one vendor and route orders to another. Implementations
|
|
5
|
+
return plain pandas OHLCV frames so strategies and the engine never see vendor
|
|
6
|
+
types.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Awaitable, Callable, Dict, List, Union
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
from quantred.marketdata.timeframe import Timeframe
|
|
17
|
+
|
|
18
|
+
#: Columns every OHLCV frame returned by a provider must contain.
|
|
19
|
+
OHLCV_COLUMNS = ("open", "high", "low", "close", "volume")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class BarEvent:
|
|
24
|
+
"""A single bar pushed by a live stream."""
|
|
25
|
+
|
|
26
|
+
symbol: str
|
|
27
|
+
timestamp: datetime
|
|
28
|
+
open: float
|
|
29
|
+
high: float
|
|
30
|
+
low: float
|
|
31
|
+
close: float
|
|
32
|
+
volume: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
#: A bar handler may be sync or async; the provider awaits it if needed.
|
|
36
|
+
BarHandler = Callable[[BarEvent], Union[None, Awaitable[None]]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MarketDataProvider(ABC):
|
|
40
|
+
"""Interface every market-data source must implement."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def get_bars(
|
|
44
|
+
self,
|
|
45
|
+
symbols: List[str],
|
|
46
|
+
timeframe: Timeframe,
|
|
47
|
+
start: datetime,
|
|
48
|
+
end: datetime,
|
|
49
|
+
) -> Dict[str, pd.DataFrame]:
|
|
50
|
+
"""Fetch historical bars.
|
|
51
|
+
|
|
52
|
+
Returns a mapping of ``symbol -> OHLCV DataFrame`` indexed by a tz-aware
|
|
53
|
+
timestamp. Symbols with no data are omitted.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
async def stream_bars(self, symbols: List[str], handler: BarHandler) -> None:
|
|
58
|
+
"""Subscribe to live bars and invoke ``handler`` for each, until canceled."""
|
|
59
|
+
|
|
60
|
+
def supports_streaming(self) -> bool:
|
|
61
|
+
"""Whether this provider can stream live bars. Override if not."""
|
|
62
|
+
return True
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""High-level market-data access - the one part of the pipeline that, mercifully,
|
|
2
|
+
just returns bars and doesn't have opinions about whether you'll make money.
|
|
3
|
+
|
|
4
|
+
A thin orchestration layer over a :class:`MarketDataProvider`. It owns the
|
|
5
|
+
friendly timeframe-string API and the live-stream entry point, and is the object
|
|
6
|
+
the engine talks to - so the engine never depends on a concrete data vendor.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Dict, List, Union
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from quantred.marketdata.base import BarHandler, MarketDataProvider
|
|
16
|
+
from quantred.marketdata.timeframe import Timeframe
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
TimeframeLike = Union[str, Timeframe]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MarketDataClient:
|
|
24
|
+
"""Fetches historical bars and dispatches live bars via an injected provider."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, provider: MarketDataProvider):
|
|
27
|
+
self._provider = provider
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def provider(self) -> MarketDataProvider:
|
|
31
|
+
"""The underlying provider - lets a caller detect e.g. a cache wrapper
|
|
32
|
+
without reaching into a private attribute."""
|
|
33
|
+
return self._provider
|
|
34
|
+
|
|
35
|
+
def get_bars(
|
|
36
|
+
self,
|
|
37
|
+
symbols: List[str],
|
|
38
|
+
timeframe: TimeframeLike,
|
|
39
|
+
start: datetime,
|
|
40
|
+
end: datetime,
|
|
41
|
+
) -> Dict[str, pd.DataFrame]:
|
|
42
|
+
"""Return ``{symbol: OHLCV DataFrame}`` for the requested window."""
|
|
43
|
+
tf = self._coerce_timeframe(timeframe)
|
|
44
|
+
data = self._provider.get_bars(symbols, tf, start, end)
|
|
45
|
+
logger.info("Fetched bars for %d/%d symbols (%s)", len(data), len(symbols), tf)
|
|
46
|
+
return data
|
|
47
|
+
|
|
48
|
+
async def stream(self, symbols: List[str], handler: BarHandler) -> None:
|
|
49
|
+
"""Stream live bars for ``symbols``, invoking ``handler`` per bar."""
|
|
50
|
+
if not self._provider.supports_streaming():
|
|
51
|
+
raise RuntimeError("The configured market-data provider does not support streaming")
|
|
52
|
+
await self._provider.stream_bars(symbols, handler)
|
|
53
|
+
|
|
54
|
+
def supports_streaming(self) -> bool:
|
|
55
|
+
return self._provider.supports_streaming()
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _coerce_timeframe(timeframe: TimeframeLike) -> Timeframe:
|
|
59
|
+
return timeframe if isinstance(timeframe, Timeframe) else Timeframe.parse(timeframe)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""A session-scoped, in-memory bar cache.
|
|
2
|
+
|
|
3
|
+
A composite research command runs several analysis steps against one universe and
|
|
4
|
+
one window. Each step fetches its own bars, so the same request is issued many
|
|
5
|
+
times over a single run - the universe scan, the alpha panel, the covariance
|
|
6
|
+
lookback, and the information sampler all want the same frames.
|
|
7
|
+
|
|
8
|
+
:class:`SessionBarCache` wraps a provider and serves a repeated request from
|
|
9
|
+
memory instead of re-issuing it. It is deliberately *exact*: a response is reused
|
|
10
|
+
only for an identical ``(symbols, timeframe, start, end)`` request, so the frames
|
|
11
|
+
a caller sees are byte-identical to the ones the underlying provider would have
|
|
12
|
+
returned. It never slices a wider fetch down to a narrower window - that would
|
|
13
|
+
make the wrapper's output depend on request order, which is precisely the kind of
|
|
14
|
+
silent divergence a research result cannot afford.
|
|
15
|
+
|
|
16
|
+
Scope is one command, not one process: there is no eviction and no persistence.
|
|
17
|
+
For durable, cross-run caching see the Parquet-backed bar cache
|
|
18
|
+
(:class:`~quantred.store.bars.CachedMarketData`), which this composes with rather than
|
|
19
|
+
replaces - wrapping a cache-backed client keeps the cache's own behavior (gap
|
|
20
|
+
fill, vintage stamping) intact underneath.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
import pandas as pd
|
|
28
|
+
|
|
29
|
+
from quantred.marketdata.base import BarHandler, MarketDataProvider
|
|
30
|
+
from quantred.marketdata.timeframe import Timeframe
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
#: The cache key for one fetch: the exact symbols, timeframe, and window asked for.
|
|
35
|
+
_Key = Tuple[Tuple[str, ...], str, str, str]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _stamp(when: Any) -> str:
|
|
39
|
+
"""A stable string for a window bound (``None`` included) so it can key a dict."""
|
|
40
|
+
iso = getattr(when, "isoformat", None)
|
|
41
|
+
return iso() if callable(iso) else str(when)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionBarCache(MarketDataProvider):
|
|
45
|
+
"""Memoize ``get_bars`` for the lifetime of one composite command.
|
|
46
|
+
|
|
47
|
+
``fetches`` counts requests that reached the wrapped provider; ``requests``
|
|
48
|
+
counts every call this wrapper received. The difference is the work saved,
|
|
49
|
+
and both are reported in a composite run's provenance so "one shared fetch"
|
|
50
|
+
is a measured claim rather than a promise.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, provider: MarketDataProvider):
|
|
54
|
+
self._provider = provider
|
|
55
|
+
self._frames: Dict[_Key, Dict[str, pd.DataFrame]] = {}
|
|
56
|
+
self.fetches = 0
|
|
57
|
+
self.requests = 0
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def provider(self) -> MarketDataProvider:
|
|
61
|
+
"""The wrapped provider - so a caller can still detect what is underneath."""
|
|
62
|
+
return self._provider
|
|
63
|
+
|
|
64
|
+
def get_bars(
|
|
65
|
+
self,
|
|
66
|
+
symbols: List[str],
|
|
67
|
+
timeframe: Timeframe,
|
|
68
|
+
start: datetime,
|
|
69
|
+
end: datetime,
|
|
70
|
+
) -> Dict[str, pd.DataFrame]:
|
|
71
|
+
"""Serve ``{symbol: OHLCV}`` from memory when this exact request was already
|
|
72
|
+
made in this session, else fetch it and remember the response."""
|
|
73
|
+
self.requests += 1
|
|
74
|
+
key = (tuple(symbols), str(timeframe), _stamp(start), _stamp(end))
|
|
75
|
+
cached = self._frames.get(key)
|
|
76
|
+
if cached is None:
|
|
77
|
+
self.fetches += 1
|
|
78
|
+
cached = self._provider.get_bars(symbols, timeframe, start, end)
|
|
79
|
+
self._frames[key] = cached
|
|
80
|
+
# Copy on the way out: callers (the feature panel, the engine) mutate the
|
|
81
|
+
# frames they receive, and a cache that hands out its own objects would let
|
|
82
|
+
# one step's edits leak into the next step's inputs.
|
|
83
|
+
return {symbol: frame.copy() for symbol, frame in cached.items()}
|
|
84
|
+
|
|
85
|
+
async def stream_bars(self, symbols: List[str], handler: BarHandler) -> None:
|
|
86
|
+
"""Streaming is passed straight through - there is nothing to memoize."""
|
|
87
|
+
await self._provider.stream_bars(symbols, handler)
|
|
88
|
+
|
|
89
|
+
def supports_streaming(self) -> bool:
|
|
90
|
+
return self._provider.supports_streaming()
|
|
91
|
+
|
|
92
|
+
def stats(self) -> Dict[str, int]:
|
|
93
|
+
"""Request/fetch counts for a run's provenance block."""
|
|
94
|
+
return {"requests": self.requests, "fetches": self.fetches, "distinct": len(self._frames)}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def session_client(data_client) -> Tuple[Any, Optional[SessionBarCache]]:
|
|
98
|
+
"""Wrap ``data_client``'s provider in a :class:`SessionBarCache`.
|
|
99
|
+
|
|
100
|
+
Returns ``(client, cache)`` - the client to hand to every step of a composite
|
|
101
|
+
run, and the cache itself for its stats. If the client exposes no provider
|
|
102
|
+
(an unusual double or a bare stub), it is returned unchanged with ``None``
|
|
103
|
+
stats rather than failing: sharing a fetch is an optimization, never a
|
|
104
|
+
correctness requirement.
|
|
105
|
+
"""
|
|
106
|
+
from quantred.marketdata.client import MarketDataClient
|
|
107
|
+
|
|
108
|
+
provider = getattr(data_client, "provider", None)
|
|
109
|
+
if provider is None:
|
|
110
|
+
return data_client, None
|
|
111
|
+
cache = SessionBarCache(provider)
|
|
112
|
+
return MarketDataClient(cache), cache
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Deterministic synthetic market data for the offline ``demo``.
|
|
2
|
+
|
|
3
|
+
A :class:`MarketDataProvider` that fabricates bars from a seeded random walk, so
|
|
4
|
+
the whole pipeline (backtest, optimize, walk-forward) runs with **no Alpaca keys
|
|
5
|
+
and no network**. Unlike the test fakes, it honors the requested ``[start, end]``
|
|
6
|
+
window and timeframe, so date-based machinery like walk-forward fold splitting
|
|
7
|
+
works exactly as it does on real data.
|
|
8
|
+
|
|
9
|
+
By construction the series has **no real edge** - it's a drift-free random walk.
|
|
10
|
+
That's deliberate: the demo's punchline is watching honest, out-of-sample
|
|
11
|
+
evaluation refuse to promote noise. If a strategy looks great here in-sample and
|
|
12
|
+
then fails the walk-forward gates, that's the system working, not a bug.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Dict, List
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from quantred.marketdata.base import BarHandler, MarketDataProvider
|
|
23
|
+
from quantred.marketdata.timeframe import Timeframe
|
|
24
|
+
from quantred.utils.timeutils import NEW_YORK
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _symbol_seed(symbol: str, base_seed: int) -> int:
|
|
28
|
+
"""A stable per-symbol seed (independent of Python's hash randomization)."""
|
|
29
|
+
return base_seed + sum(ord(ch) for ch in symbol)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SyntheticMarketData(MarketDataProvider):
|
|
33
|
+
"""Serves seeded random-walk OHLCV across the requested window and timeframe."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, seed: int = 42, annual_drift: float = 0.0, annual_vol: float = 0.35):
|
|
36
|
+
self._seed = seed
|
|
37
|
+
self._annual_drift = annual_drift
|
|
38
|
+
self._annual_vol = annual_vol
|
|
39
|
+
|
|
40
|
+
def get_bars(
|
|
41
|
+
self, symbols: List[str], timeframe: Timeframe, start: datetime, end: datetime
|
|
42
|
+
) -> Dict[str, pd.DataFrame]:
|
|
43
|
+
index = pd.date_range(start=start, end=end, freq=timeframe.to_pandas_offset(), tz=NEW_YORK)
|
|
44
|
+
if len(index) == 0:
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
periods_per_year = timeframe.periods_per_year()
|
|
48
|
+
sigma = self._annual_vol / math.sqrt(periods_per_year)
|
|
49
|
+
mu = self._annual_drift / periods_per_year
|
|
50
|
+
|
|
51
|
+
return {symbol: self._make_frame(symbol, index, mu, sigma) for symbol in symbols}
|
|
52
|
+
|
|
53
|
+
def _make_frame(self, symbol: str, index: pd.DatetimeIndex, mu: float, sigma: float) -> pd.DataFrame:
|
|
54
|
+
rng = np.random.default_rng(_symbol_seed(symbol, self._seed))
|
|
55
|
+
n = len(index)
|
|
56
|
+
|
|
57
|
+
close = 100.0 * np.exp(np.cumsum(rng.normal(mu, sigma, n)))
|
|
58
|
+
open_ = close * (1 + rng.normal(0, sigma / 2, n))
|
|
59
|
+
high = np.maximum(open_, close) * (1 + np.abs(rng.normal(0, sigma / 2, n)))
|
|
60
|
+
low = np.minimum(open_, close) * (1 - np.abs(rng.normal(0, sigma / 2, n)))
|
|
61
|
+
|
|
62
|
+
volume = rng.integers(100_000, 500_000, n).astype(float)
|
|
63
|
+
volume[rng.choice(n, size=max(n // 20, 1), replace=False)] *= 5 # occasional spikes
|
|
64
|
+
|
|
65
|
+
return pd.DataFrame(
|
|
66
|
+
{"open": open_, "high": high, "low": low, "close": close, "volume": volume},
|
|
67
|
+
index=index,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def stream_bars(self, symbols: List[str], handler: BarHandler) -> None: # pragma: no cover
|
|
71
|
+
raise NotImplementedError("SyntheticMarketData is for offline backtests, not streaming")
|
|
72
|
+
|
|
73
|
+
def supports_streaming(self) -> bool:
|
|
74
|
+
return False
|