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,48 @@
1
+ """Refusals shared across layers.
2
+
3
+ Here rather than in a service because the optimization layer refuses runs too, and it
4
+ must not import the services above it.
5
+ """
6
+
7
+ from datetime import datetime
8
+ from typing import Sequence
9
+
10
+
11
+ class RunRefused(ValueError):
12
+ """A run refused before it read or recorded anything, with what to change.
13
+
14
+ A ``ValueError`` so every caller that already handles one keeps working; its own
15
+ type so the CLI can end on the message rather than a stack trace.
16
+ """
17
+
18
+
19
+ def no_bars_refusal(symbols: Sequence[str], start: datetime, end: datetime) -> RunRefused:
20
+ """The refusal for a run whose universe had no bars at all.
21
+
22
+ Such a run returned a clean-looking result - every ratio 0.0 - and was journaled.
23
+ Zero is a measurement; a run over no bars measured nothing.
24
+ """
25
+ named = ", ".join(symbols) if symbols else "an empty universe"
26
+ return RunRefused(
27
+ f"No bars for any of {named} between {start:%Y-%m-%d} and {end:%Y-%m-%d}, so there is "
28
+ "nothing to measure and nothing was recorded. Check the symbols and the window."
29
+ )
30
+
31
+
32
+ class GeneratedCodeRefused(RunRefused):
33
+ """A config or strategy name that points at code the research agent wrote.
34
+
35
+ A saved config names its strategy, and a generated strategy has no registered name -
36
+ only a ``draft:<Class>:<hash>`` identity. Resolving that identity must never mean
37
+ "find the source and run it": loading a config is not a review.
38
+ """
39
+
40
+ def __init__(self, name: str):
41
+ from quantred.settings import DOCS_URL
42
+
43
+ super().__init__(
44
+ f"{name!r} is a strategy the research agent wrote, and generated code is never run by "
45
+ "loading a config. Review its source (recorded in the config's "
46
+ "provenance.generated_code and on the journal's trial line), add it to a strategy "
47
+ f"pack, and point the config at the registered name: {DOCS_URL}/usage/private-strategies"
48
+ )
@@ -0,0 +1,330 @@
1
+ """The optional extras: what each provides, whether it is installed, how to add one.
2
+
3
+ One map and one recovery text. Every "this needs the X extra" message in the package
4
+ comes from here, because the right command depends on how the software was installed
5
+ and the wrong one is a dead end: ``uv sync`` needs a checkout an installed reader does
6
+ not have, and ``uv tool install --force 'pkg[X]'`` *replaces* the tool environment, so
7
+ on its own it removes every other extra and every ``--with`` package the reader had
8
+ added.
9
+
10
+ Depends only on :mod:`quantred.settings` and the standard library, so any layer can
11
+ call it - the optimizer and allocator report a missing dependency from below the
12
+ services layer.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib.util
18
+ import shlex
19
+ import sys
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any, Dict, List, Optional, Tuple
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Extra:
27
+ """One published extra."""
28
+
29
+ name: str #: the extra's name in ``pyproject.toml``
30
+ modules: Tuple[str, ...] #: every import it provides - all must be present
31
+ unlocks: str #: one line on what the reader gets
32
+ required_for: Tuple[str, ...] = () #: tasks that cannot run without it; () = optional everywhere
33
+
34
+
35
+ EXTRAS: Dict[str, Extra] = {
36
+ e.name: e
37
+ for e in (
38
+ Extra(
39
+ "store",
40
+ ("pyarrow", "polars", "duckdb"),
41
+ "the bar cache (`--cache`/`--offline`) and the Parquet bar store",
42
+ ),
43
+ Extra(
44
+ "viz", ("matplotlib",), "charts in `--chart` and in HTML reports (reports fall back to tables)"
45
+ ),
46
+ Extra("mcp", ("mcp",), "the MCP server for AI clients", ("mcp",)),
47
+ Extra("optimize", ("sklearn",), "`optimize --method bayesian`"),
48
+ Extra("portfolio", ("ortools",), "`allocate` (the constraint solver)"),
49
+ Extra("ai", ("anthropic",), "`research --provider anthropic`"),
50
+ Extra("openai", ("openai",), "`research --provider openai`"),
51
+ )
52
+ }
53
+
54
+
55
+ def installed(extra: str) -> bool:
56
+ """Whether every module the extra provides can be imported here.
57
+
58
+ All of them, not the first: ``store`` is three packages, and having one of the
59
+ three leaves the Parquet store or the lazy compute path failing at first use.
60
+ """
61
+ return all(importlib.util.find_spec(module) is not None for module in EXTRAS[extra].modules)
62
+
63
+
64
+ def installed_extras() -> List[str]:
65
+ """Names of the extras fully satisfied in this environment, sorted."""
66
+ return sorted(name for name in EXTRAS if installed(name))
67
+
68
+
69
+ def _in_uv_tool_env() -> bool:
70
+ """Whether this interpreter lives in a ``uv tool`` environment (a heuristic).
71
+
72
+ The installer cannot be read off the running process, so this looks at where the
73
+ environment sits: under ``UV_TOOL_DIR`` when set, otherwise in a ``uv/tools``
74
+ directory (the default on every platform uv supports).
75
+ """
76
+ import os
77
+
78
+ prefix = Path(sys.prefix)
79
+ override = os.environ.get("UV_TOOL_DIR")
80
+ if override:
81
+ try:
82
+ prefix.resolve().relative_to(Path(override).resolve())
83
+ return True
84
+ except ValueError:
85
+ pass
86
+ parts = [p.lower() for p in prefix.parts]
87
+ return any(a == "uv" and b == "tools" for a, b in zip(parts, parts[1:]))
88
+
89
+
90
+ def _pack_distributions() -> List[str]:
91
+ """Distributions, other than this one, that register strategies or scanners.
92
+
93
+ These are what a reader installed with ``--with`` and what a rebuilt tool
94
+ environment will not have unless they are named again. Read from the running
95
+ environment's entry points, so nothing about anyone's packs is written down here.
96
+ """
97
+ from importlib import metadata
98
+
99
+ from quantred.settings import (
100
+ SCANNER_ENTRY_POINT_GROUP,
101
+ STRATEGY_ENTRY_POINT_GROUP,
102
+ entry_point_groups,
103
+ is_own_distribution,
104
+ )
105
+
106
+ names = set()
107
+ try:
108
+ entry_points = metadata.entry_points()
109
+ for group in (
110
+ *entry_point_groups(STRATEGY_ENTRY_POINT_GROUP),
111
+ *entry_point_groups(SCANNER_ENTRY_POINT_GROUP),
112
+ ):
113
+ if hasattr(entry_points, "select"):
114
+ selected = list(entry_points.select(group=group))
115
+ else: # pragma: no cover - the dict-shaped API of older Pythons
116
+ selected = list(entry_points.get(group, ()))
117
+ for entry_point in selected:
118
+ dist = getattr(entry_point, "dist", None)
119
+ name = getattr(dist, "name", None) if dist is not None else None
120
+ if name and not is_own_distribution(name):
121
+ names.add(name)
122
+ except Exception: # noqa: BLE001 - advice about installing must not fail over its own lookup
123
+ return []
124
+ return sorted(names)
125
+
126
+
127
+ def _tool_receipt() -> Optional[Dict[str, Any]]:
128
+ """The ``uv tool`` receipt of this environment, or ``None``.
129
+
130
+ ``uv tool install`` writes ``uv-receipt.toml`` at the root of the tool environment:
131
+ exactly the requirements, extras and ``--with`` entries it was built from. That is
132
+ what a rebuild has to restate, so reading it beats guessing from the packages that
133
+ happen to be importable. Absent for any other installer, and unreadable on a Python
134
+ without :mod:`tomllib` (3.10) - in both cases the caller falls back to advice.
135
+ """
136
+ receipt = Path(sys.prefix) / "uv-receipt.toml"
137
+ try:
138
+ import tomllib
139
+
140
+ data = tomllib.loads(receipt.read_text(encoding="utf-8"))
141
+ except (ImportError, OSError, ValueError):
142
+ return None
143
+ return data if isinstance(data.get("tool"), dict) else None
144
+
145
+
146
+ def _quote(text: str) -> str:
147
+ return shlex.quote(text)
148
+
149
+
150
+ def _with_flags(requirements: List[Dict[str, Any]]) -> List[str]:
151
+ """``--with`` arguments that restate every requirement but this distribution."""
152
+ from quantred.settings import is_own_distribution
153
+
154
+ flags: List[str] = []
155
+ for req in requirements:
156
+ name = str(req.get("name", ""))
157
+ if not name or is_own_distribution(name):
158
+ continue
159
+ extras_part = f"[{','.join(req['extras'])}]" if req.get("extras") else ""
160
+ if req.get("editable"):
161
+ flags.append(f"--with-editable {_quote(str(req['editable']))}")
162
+ elif req.get("directory") or req.get("path"):
163
+ flags.append(f"--with {_quote(str(req.get('directory') or req['path']))}")
164
+ elif req.get("git"):
165
+ flags.append(f"--with {_quote(f'{name}{extras_part} @ git+' + str(req['git']))}")
166
+ elif req.get("url"):
167
+ flags.append(f"--with {_quote(f'{name}{extras_part} @ ' + str(req['url']))}")
168
+ else:
169
+ flags.append(f"--with {_quote(name + extras_part + str(req.get('specifier', '')))}")
170
+ return flags
171
+
172
+
173
+ #: What the printed ``uv tool install`` command calls the exclusions file it asks for.
174
+ EXCLUDES_FILE = "excludes.txt"
175
+
176
+
177
+ def _uv_tool_command(wanted: List[str], extra_flags: Tuple[str, ...] = ()):
178
+ """``(command, receipt, note)`` that rebuilds this tool environment with ``wanted``.
179
+
180
+ With a receipt the command restates every extra and ``--with`` package the tool was
181
+ built from, including any ``--excludes``. Without one it cannot, and ``note`` says
182
+ what to add by hand.
183
+ """
184
+ import quantred
185
+ from quantred.settings import (
186
+ DISTRIBUTION_NAME,
187
+ LEGACY_DISTRIBUTION_NAMES,
188
+ invoked_legacy_name,
189
+ is_own_distribution,
190
+ )
191
+
192
+ receipt = _tool_receipt()
193
+ requirements = list(((receipt or {}).get("tool") or {}).get("requirements") or [])
194
+ recorded: set = set()
195
+ installed_as = None
196
+ for req in requirements:
197
+ name = str(req.get("name", ""))
198
+ if is_own_distribution(name):
199
+ recorded.update(req.get("extras") or ())
200
+ installed_as = installed_as or name.lower().replace("_", "-")
201
+ if installed_as is None and invoked_legacy_name():
202
+ installed_as = LEGACY_DISTRIBUTION_NAMES[0]
203
+ union = sorted({*installed_extras(), *recorded, *wanted})
204
+ spec = f"[{','.join(union)}]"
205
+ if installed_as in LEGACY_DISTRIBUTION_NAMES:
206
+ # The tool that exists is the one installed under the old name; installing
207
+ # `quantred` would build a second tool and leave this one without the extra.
208
+ # That release forwards every extra to quantred and requires a current one, so
209
+ # its own version is not the running engine's and is not pinned to it.
210
+ command = f"uv tool install --force '{installed_as}{spec}>=2.3.1'"
211
+ else:
212
+ command = f"uv tool install --force '{DISTRIBUTION_NAME}{spec}=={quantred.__version__}'"
213
+ note = None
214
+ if receipt is not None:
215
+ excludes = [str(name) for name in ((receipt.get("tool") or {}).get("excludes") or [])]
216
+ flags = [*_with_flags(requirements), *extra_flags]
217
+ prefix = ""
218
+ if excludes:
219
+ # Restated for the same reason as the extras: the tool environment is rebuilt
220
+ # and would otherwise lose a choice the reader made. It is their choice, not
221
+ # ours, so it is carried and then described. uv takes exclusions from a file,
222
+ # not inline, so the command creates the file first - pasted from anywhere.
223
+ flags.append(f"--excludes {EXCLUDES_FILE}")
224
+ names = " ".join(_quote(name) for name in excludes)
225
+ prefix = f"printf '%s\\n' {names} > {EXCLUDES_FILE} && "
226
+ if any(name.lower().replace("_", "-") in LEGACY_DISTRIBUTION_NAMES for name in excludes):
227
+ note = (
228
+ "(this keeps your exclusion of the old tradeflow-engine; a pack that still imports "
229
+ "`tradeflow` needs it installed, so drop that exclusion for such a pack)"
230
+ )
231
+ command = prefix + " ".join([command, *flags])
232
+ else:
233
+ packs = _pack_distributions()
234
+ # Silent when nothing was detected outside a uv tool environment: "re-add your
235
+ # --with packs" is advice about a mechanism that venv does not have.
236
+ if packs:
237
+ note = f"(re-add any packs you installed with --with: {', '.join(packs)})"
238
+ elif _in_uv_tool_env():
239
+ note = "(re-add any packs you installed with --with: none detected)"
240
+ return command, receipt, note
241
+
242
+
243
+ #: Said under a pack install command. The old distribution's final release is a thin
244
+ #: alias, so keeping it is harmless, and a pack nobody can edit still imports `tradeflow`.
245
+ _OLD_ENGINE_NOTE = (
246
+ "Keep tradeflow-engine>=2.3.1 installed: a pack that still imports `tradeflow` works through "
247
+ "the alias it provides, and without it that pack is dropped. If the pack pins an older "
248
+ "tradeflow-engine, override the pin rather than excluding it (a pin below 2.3.1 would install "
249
+ "the whole previous engine, whose classes this one rejects). Excluding it, or {skip}, is only "
250
+ "for a pack that never imports `tradeflow`; {skip} also skips the pack's other dependencies."
251
+ )
252
+
253
+
254
+ def pack_install_hint(path: str) -> str:
255
+ """How to install a pack from a directory, without removing anything already there.
256
+
257
+ A checkout installs it into its own environment (additive). A ``uv tool`` copy has
258
+ to rebuild the tool environment, so the command carries everything it already had
259
+ (including any exclusion the reader chose, created first so the line runs anywhere).
260
+ Elsewhere it is pip. Each is followed by what to do about a pack that still depends
261
+ on the old distribution name.
262
+ """
263
+ from quantred.settings import running_from_checkout
264
+
265
+ quoted = shlex.quote(path)
266
+ if running_from_checkout():
267
+ command = f"uv pip install -e {quoted}"
268
+ else:
269
+ command, receipt, note = _uv_tool_command([], (f"--with-editable {quoted}",))
270
+ if receipt is not None:
271
+ return f"{command}\n{note}" if note else command
272
+ command = f"python -m pip install -e {quoted}"
273
+ return f"{command}\n({_OLD_ENGINE_NOTE.format(skip='--no-deps')})"
274
+
275
+
276
+ def install_hint(*extras: str) -> str:
277
+ """The recovery text for adding ``extras``, phrased for how this copy was reached.
278
+
279
+ A checkout gets one ``uv sync --inexact`` line: plain ``uv sync`` is *exact* and
280
+ uninstalls whatever the lock does not name for the extras given - the ``dev`` tools
281
+ and any pack installed with ``uv pip install -e`` - while ``--inexact`` only adds.
282
+
283
+ An installed copy gets both ways to add an extra, because which installer made the
284
+ environment cannot be known for certain. The ``uv tool`` form replaces the whole
285
+ environment, so it restates the extras and ``--with`` packs the tool was built from
286
+ (read from its receipt) and is pinned to the running version, since adding a
287
+ capability is not an upgrade.
288
+ """
289
+ import quantred
290
+ from quantred.settings import DISTRIBUTION_NAME, DOCS_URL, running_from_checkout
291
+
292
+ wanted = sorted(set(extras))
293
+
294
+ if running_from_checkout():
295
+ return " uv sync --inexact " + " ".join(f"--extra {name}" for name in wanted)
296
+
297
+ version = quantred.__version__
298
+ uv_form, receipt, note = _uv_tool_command(wanted)
299
+ pip_form = f"python -m pip install '{DISTRIBUTION_NAME}[{','.join(wanted)}]=={version}'"
300
+
301
+ detected = receipt is not None or _in_uv_tool_env()
302
+ uv_label = "With uv tool (detected):" if detected else "With uv tool:"
303
+ pip_label = "In an activated venv:"
304
+ width = max(len(uv_label), len(pip_label)) + 1
305
+ lines = [f" {uv_label:<{width}}{uv_form}"]
306
+ if note:
307
+ lines.append(f" {'':<{width}}{note}")
308
+ lines.append(f" {pip_label:<{width}}{pip_form}")
309
+ lines.append(f" Extras guide: {DOCS_URL}/usage/extras")
310
+ return "\n".join(lines)
311
+
312
+
313
+ def missing_extra_message(what: str, extra: str) -> str:
314
+ """``what`` needs ``extra``, and the one way to get it."""
315
+ return f"{what} needs the '{extra}' extra.\n{install_hint(extra)}"
316
+
317
+
318
+ class MissingExtra(RuntimeError):
319
+ """A capability was reached without the extra that provides it.
320
+
321
+ Raised where the capability starts rather than where its first import happens to
322
+ sit, and carrying the install text, so every caller ends on the same instruction -
323
+ the CLI prints it, the MCP server returns it as the tool error.
324
+ """
325
+
326
+
327
+ def require_extra(what: str, extra: str) -> None:
328
+ """Raise :class:`MissingExtra` unless ``extra`` is installed."""
329
+ if not installed(extra):
330
+ raise MissingExtra(missing_extra_message(what, extra))
@@ -0,0 +1,33 @@
1
+ """Centralized logging configuration.
2
+
3
+ A single ``setup_logging`` entry point keeps log formatting consistent across
4
+ every module (engine, scanner, strategies) without each one re-running
5
+ ``logging.basicConfig`` and clobbering the others.
6
+ """
7
+
8
+ import logging
9
+
10
+ _CONFIGURED = False
11
+
12
+ # An aligned, greppable, pipe-delimited line:
13
+ # 2026-06-19 14:03:22 | INFO | quantred.engine.backtest | message
14
+ # The fixed-width level and logger columns keep multi-line runs easy to scan.
15
+ _LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)-24s | %(message)s"
16
+ _DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
17
+
18
+
19
+ def setup_logging(level: int = logging.INFO) -> None:
20
+ """Configure root logging once per process.
21
+
22
+ Safe to call from module import time in many modules; only the first call
23
+ actually configures handlers.
24
+
25
+ Args:
26
+ level: Minimum log level for the root logger.
27
+ """
28
+ global _CONFIGURED
29
+ if _CONFIGURED:
30
+ return
31
+
32
+ logging.basicConfig(level=level, format=_LOG_FORMAT, datefmt=_DATE_FORMAT)
33
+ _CONFIGURED = True
@@ -0,0 +1,46 @@
1
+ """Small numeric helpers shared across layers.
2
+
3
+ Centralized so price/quantity rounding and step math are defined once and reused
4
+ by execution, the optimizer, and reporting - rather than re-derived inline.
5
+ """
6
+
7
+ import math
8
+ from typing import Union
9
+
10
+
11
+ def round_price(price: float, places: int = 2) -> float:
12
+ """Round a price to a tick size.
13
+
14
+ Sub-dollar instruments are quoted to more decimals, so prices under $1 are
15
+ rounded to 4 places by default.
16
+ """
17
+ if price < 1.0:
18
+ places = max(places, 4)
19
+ return round(float(price), places)
20
+
21
+
22
+ def round_quantity(qty: float, allow_fractional: bool = False) -> float:
23
+ """Round an order quantity, flooring to whole shares unless fractional is allowed."""
24
+ if allow_fractional:
25
+ return float(qty)
26
+ return float(math.floor(qty))
27
+
28
+
29
+ def safe_float(value, default: float = 0.0) -> float:
30
+ """Best-effort float conversion that never raises."""
31
+ try:
32
+ return float(value)
33
+ except (TypeError, ValueError):
34
+ return default
35
+
36
+
37
+ def step_decimals(step: Union[int, float]) -> int:
38
+ """Number of decimal places implied by a step size (capped at 6).
39
+
40
+ e.g. ``0.05 -> 2``, ``1 -> 0``. Used by the optimizer to keep generated
41
+ parameter values aligned to their declared grid.
42
+ """
43
+ text = str(step)
44
+ if "." not in text:
45
+ return 0
46
+ return min(len(text.split(".")[-1]), 6)