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,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