hqbacktest 0.1.4__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 (50) hide show
  1. hqbacktest/__init__.py +104 -0
  2. hqbacktest/__main__.py +13 -0
  3. hqbacktest/cli/__init__.py +7 -0
  4. hqbacktest/cli/__main__.py +109 -0
  5. hqbacktest/cli/config.py +380 -0
  6. hqbacktest/cli/runner.py +347 -0
  7. hqbacktest/data/__init__.py +56 -0
  8. hqbacktest/data/_converters.py +88 -0
  9. hqbacktest/data/cache.py +57 -0
  10. hqbacktest/data/data_view.py +308 -0
  11. hqbacktest/data/errors.py +84 -0
  12. hqbacktest/data/hqdata_portal.py +548 -0
  13. hqbacktest/data/memory_portal.py +246 -0
  14. hqbacktest/data/portal.py +70 -0
  15. hqbacktest/data/validators.py +109 -0
  16. hqbacktest/domain/__init__.py +85 -0
  17. hqbacktest/domain/adjustment.py +60 -0
  18. hqbacktest/domain/bar.py +73 -0
  19. hqbacktest/domain/enums.py +115 -0
  20. hqbacktest/domain/errors.py +16 -0
  21. hqbacktest/domain/fill.py +130 -0
  22. hqbacktest/domain/money.py +79 -0
  23. hqbacktest/domain/order.py +210 -0
  24. hqbacktest/domain/portfolio.py +155 -0
  25. hqbacktest/domain/position.py +124 -0
  26. hqbacktest/domain/serialization.py +50 -0
  27. hqbacktest/domain/snapshot.py +112 -0
  28. hqbacktest/domain/state_machine.py +64 -0
  29. hqbacktest/engine/__init__.py +128 -0
  30. hqbacktest/engine/broker.py +248 -0
  31. hqbacktest/engine/config.py +105 -0
  32. hqbacktest/engine/context.py +626 -0
  33. hqbacktest/engine/corporate_actions.py +294 -0
  34. hqbacktest/engine/cost_model.py +70 -0
  35. hqbacktest/engine/engine.py +789 -0
  36. hqbacktest/engine/errors.py +63 -0
  37. hqbacktest/engine/events.py +65 -0
  38. hqbacktest/engine/intents.py +118 -0
  39. hqbacktest/engine/iterator.py +67 -0
  40. hqbacktest/engine/metrics.py +286 -0
  41. hqbacktest/engine/result.py +312 -0
  42. hqbacktest/engine/rule_set.py +261 -0
  43. hqbacktest/engine/scheduler.py +158 -0
  44. hqbacktest/engine/strategy.py +116 -0
  45. hqbacktest-0.1.4.dist-info/METADATA +202 -0
  46. hqbacktest-0.1.4.dist-info/RECORD +50 -0
  47. hqbacktest-0.1.4.dist-info/WHEEL +5 -0
  48. hqbacktest-0.1.4.dist-info/entry_points.txt +2 -0
  49. hqbacktest-0.1.4.dist-info/licenses/LICENSE +21 -0
  50. hqbacktest-0.1.4.dist-info/top_level.txt +1 -0
hqbacktest/__init__.py ADDED
@@ -0,0 +1,104 @@
1
+ """hqbacktest - A-share quantitative strategy backtest and trading simulation engine."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from hqbacktest.data import (
6
+ CacheKey,
7
+ DataCache,
8
+ DataVersion,
9
+ DataView,
10
+ HqDataCsvPortal,
11
+ InMemoryDataPortal,
12
+ MarketDataPortal,
13
+ resolve_source_location,
14
+ )
15
+ from hqbacktest.domain import (
16
+ AccountSnapshot,
17
+ AdjustmentPolicy,
18
+ Bar,
19
+ CorporateActionAdjustment,
20
+ EventType,
21
+ Fill,
22
+ Order,
23
+ OrderStatus,
24
+ OrderType,
25
+ Portfolio,
26
+ Position,
27
+ PositionSnapshot,
28
+ PriceMode,
29
+ RejectReason,
30
+ Side,
31
+ )
32
+ from hqbacktest.engine import (
33
+ BacktestConfig,
34
+ BacktestEngine,
35
+ BacktestResult,
36
+ BaseStrategy,
37
+ Context,
38
+ CorporateAction,
39
+ CorporateActionProvider,
40
+ CostModel,
41
+ DefaultCostModel,
42
+ EngineEvent,
43
+ EquityPoint,
44
+ EventLog,
45
+ FactorDiagnostic,
46
+ FactorDiagnosticCollector,
47
+ MetricsConfig,
48
+ NullStrategy,
49
+ PerformanceMetrics,
50
+ SimulatedBroker,
51
+ Strategy,
52
+ TradingDayIterator,
53
+ TradingRuleSet,
54
+ )
55
+
56
+ __version__ = version("hqbacktest")
57
+
58
+ __all__ = [
59
+ "__version__",
60
+ "AccountSnapshot",
61
+ "AdjustmentPolicy",
62
+ "BacktestConfig",
63
+ "BacktestEngine",
64
+ "BacktestResult",
65
+ "Bar",
66
+ "BaseStrategy",
67
+ "CacheKey",
68
+ "Context",
69
+ "CorporateAction",
70
+ "CorporateActionAdjustment",
71
+ "CorporateActionProvider",
72
+ "CostModel",
73
+ "DataCache",
74
+ "DataVersion",
75
+ "DataView",
76
+ "DefaultCostModel",
77
+ "EngineEvent",
78
+ "EquityPoint",
79
+ "EventLog",
80
+ "EventType",
81
+ "FactorDiagnostic",
82
+ "FactorDiagnosticCollector",
83
+ "Fill",
84
+ "HqDataCsvPortal",
85
+ "InMemoryDataPortal",
86
+ "MarketDataPortal",
87
+ "MetricsConfig",
88
+ "NullStrategy",
89
+ "Order",
90
+ "OrderStatus",
91
+ "OrderType",
92
+ "PerformanceMetrics",
93
+ "Portfolio",
94
+ "Position",
95
+ "PositionSnapshot",
96
+ "PriceMode",
97
+ "RejectReason",
98
+ "Side",
99
+ "SimulatedBroker",
100
+ "Strategy",
101
+ "TradingDayIterator",
102
+ "TradingRuleSet",
103
+ "resolve_source_location",
104
+ ]
hqbacktest/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Entry point so `python -m hqbacktest run --config ...` works.
2
+
3
+ Delegates to `hqbacktest.cli.__main__:main` and re-raises SystemExit so
4
+ argparse error codes propagate.
5
+ """
6
+
7
+ import sys
8
+
9
+ from hqbacktest.cli.__main__ import main
10
+
11
+
12
+ if __name__ == "__main__":
13
+ sys.exit(main())
@@ -0,0 +1,7 @@
1
+ """Command-line interface for hqbacktest.
2
+
3
+ The CLI is intentionally tiny: it loads a TOML config file, validates it,
4
+ constructs a `BacktestConfig` and a strategy class, runs the backtest, and
5
+ writes the result to an output directory. Heavy lifting lives in
6
+ `engine` / `data` / `domain`; this package only orchestrates.
7
+ """
@@ -0,0 +1,109 @@
1
+ """Command-line entry point: `hqbacktest run --config FILE --output DIR`.
2
+
3
+ Uses stdlib `argparse` (no new dependency). On any user-facing problem
4
+ we print a single readable line on stderr and exit with a non-zero code.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import importlib
11
+ import os
12
+ import sys
13
+ from typing import Optional, Sequence
14
+
15
+ from .config import ConfigError
16
+ from .runner import RunResult, _prepare_sys_path, run_from_file
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(
21
+ prog="hqbacktest",
22
+ description=("Run an A-share backtest from a TOML config file."),
23
+ )
24
+ sub = parser.add_subparsers(dest="command")
25
+
26
+ run = sub.add_parser("run", help="Execute a backtest from a TOML config")
27
+ run.add_argument(
28
+ "--config",
29
+ required=True,
30
+ help="Path to the TOML config file (required).",
31
+ )
32
+ run.add_argument(
33
+ "--output",
34
+ required=False,
35
+ default=None,
36
+ help=(
37
+ "Output directory. When given, overrides [output].directory from "
38
+ "the config. Created if missing; receives config.toml, "
39
+ "run_metadata.json, events.jsonl, equity_curve.csv, orders.csv, "
40
+ "fills.csv, positions.csv, costs.csv and summary.json."
41
+ ),
42
+ )
43
+ run.add_argument(
44
+ "--force",
45
+ action="store_true",
46
+ help=(
47
+ "Overwrite an output directory that already contains " "prior-run files."
48
+ ),
49
+ )
50
+
51
+ return parser
52
+
53
+
54
+ def main(argv: Optional[Sequence[str]] = None) -> int:
55
+ parser = build_parser()
56
+ args = parser.parse_args(argv if argv is not None else sys.argv[1:])
57
+
58
+ if args.command is None:
59
+ parser.print_help(sys.stderr)
60
+ return 1
61
+
62
+ if args.command == "run":
63
+ return _run(args)
64
+
65
+
66
+ def _run(args: argparse.Namespace) -> int:
67
+ # Prepend the config file's directory and the current working directory
68
+ # to `sys.path` so the strategy module can be resolved by name alone,
69
+ # matching the documented "first-mile" workflow. This mirrors what
70
+ # `python -m` would do for an in-tree import and makes the console
71
+ # script behave the same way as `python -m hqbacktest run`.
72
+ _prepare_sys_path(args.config)
73
+ # Honor a test-only env hook so the CLI can be driven against an
74
+ # in-memory portal in subprocess tests without touching the real
75
+ # `~/.hqdata` snapshot.
76
+ _maybe_load_test_bootstrap()
77
+ try:
78
+ result: RunResult = run_from_file(
79
+ args.config, output_dir=args.output, force=args.force
80
+ )
81
+ except ConfigError as exc:
82
+ print(f"hqbacktest: {exc}", file=sys.stderr)
83
+ return 2
84
+
85
+ if result.exit_code != 0:
86
+ print(
87
+ f"hqbacktest: {result.message or 'run failed'}",
88
+ file=sys.stderr,
89
+ )
90
+ return result.exit_code
91
+
92
+ print(f"hqbacktest: wrote results to {result.output_dir}")
93
+ return 0
94
+
95
+
96
+ def _maybe_load_test_bootstrap() -> None:
97
+ """If `HQBACKTEST_CLI_BOOTSTRAP` is set, import that module by name.
98
+
99
+ Test-only hook used by `tests/cli/test_cli_validation.py` to swap
100
+ the portal builder in a subprocess without writing to the real
101
+ `~/.hqdata` snapshot. Production users never set this.
102
+ """
103
+ name = os.environ.get("HQBACKTEST_CLI_BOOTSTRAP")
104
+ if not name:
105
+ return
106
+ importlib.import_module(name)
107
+
108
+
109
+ __all__ = ["build_parser", "main"]
@@ -0,0 +1,380 @@
1
+ """Config file loading and validation.
2
+
3
+ The TOML schema (one example):
4
+
5
+ [start]
6
+ start_date = "20240102" # YYYYMMDD, required
7
+ end_date = "20240104" # YYYYMMDD, required
8
+
9
+ [capital]
10
+ initial_cash = "100000" # Decimal-string, required
11
+
12
+ [data]
13
+ source = "tushare" # name or absolute path, required
14
+ data_root = "~/.hqdata" # optional, defaults to ~/.hqdata
15
+
16
+ [strategy]
17
+ module = "examples.buy_and_hold" # importable Python module, required
18
+ class_name = "BuyAndHold" # optional, defaults to first subclass
19
+ kwargs = {} # optional, passed to constructor
20
+
21
+ [cost_model]
22
+ commission_rate = "0.00025" # optional
23
+ min_commission = "5.00" # optional
24
+ stamp_tax_rate = "0.001" # optional
25
+ transfer_fee_rate = "0.0" # optional
26
+
27
+ [output]
28
+ directory = "results/run-1" # required, will be created
29
+
30
+ Validation rules are deliberately strict: any unknown key is rejected so
31
+ typos surface immediately. The CLI re-emits every user-facing error as
32
+ `ConfigError` which becomes a non-zero exit code with a single readable
33
+ message on stderr.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import importlib
39
+ import inspect
40
+ from dataclasses import dataclass, field
41
+ from decimal import Decimal
42
+ from pathlib import Path
43
+ from typing import Any, Dict, List, Optional, Type
44
+
45
+ import tomli
46
+
47
+ from ..data.hqdata_portal import DEFAULT_DATA_ROOT
48
+ from ..data.validators import validate_yyyymmdd
49
+ from ..domain.enums import OrderType
50
+ from ..engine.cost_model import DefaultCostModel
51
+ from ..engine.rule_set import DEFAULT_V01_RULES, TradingRuleSet
52
+ from ..engine.strategy import BaseStrategy
53
+
54
+
55
+ # Hard-coded allowed top-level sections. Anything else is rejected.
56
+ _ALLOWED_SECTIONS: tuple[str, ...] = (
57
+ "start",
58
+ "capital",
59
+ "data",
60
+ "strategy",
61
+ "cost_model",
62
+ "output",
63
+ )
64
+
65
+
66
+ class ConfigError(ValueError):
67
+ """Raised on any user-facing configuration problem.
68
+
69
+ The CLI catches this and prints a single readable line on stderr
70
+ before exiting with a non-zero status.
71
+ """
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class ConfigFile:
76
+ """Validated view of the TOML config the user passed on the CLI.
77
+
78
+ All fields are required; defaults live in `BacktestConfig`.
79
+ """
80
+
81
+ start_date: str
82
+ end_date: str
83
+ initial_cash: Decimal
84
+ source: str
85
+ strategy_module: str
86
+ output_directory: str
87
+ data_root: str = DEFAULT_DATA_ROOT
88
+ strategy_class: Optional[str] = None
89
+ strategy_kwargs: Dict[str, Any] = field(default_factory=dict)
90
+ cost_overrides: Dict[str, Decimal] = field(default_factory=dict)
91
+ raw_text: str = "" # exact bytes the user provided (for the audit trail)
92
+
93
+
94
+ # --------------------------------------------------------------------- #
95
+ # Loading
96
+ # --------------------------------------------------------------------- #
97
+
98
+
99
+ def load_config_file(path: str) -> ConfigFile:
100
+ """Load and validate a TOML config. Raises `ConfigError` on any issue."""
101
+ file_path = Path(path)
102
+ if not file_path.exists():
103
+ raise ConfigError(f"config file not found: {path}")
104
+ if not file_path.is_file():
105
+ raise ConfigError(f"config path is not a regular file: {path}")
106
+ try:
107
+ raw = file_path.read_bytes()
108
+ except OSError as exc:
109
+ raise ConfigError(f"cannot read config file {path}: {exc}") from exc
110
+ try:
111
+ data = tomli.loads(raw.decode("utf-8"))
112
+ except tomli.TOMLDecodeError as exc:
113
+ raise ConfigError(f"config file {path} is not valid TOML: {exc}") from exc
114
+ return _validate(data, raw_text=raw.decode("utf-8"))
115
+
116
+
117
+ def _validate(data: Dict[str, Any], *, raw_text: str) -> ConfigFile:
118
+ """Validate the parsed TOML dict and build a `ConfigFile`.
119
+
120
+ All section names must be from `_ALLOWED_SECTIONS`; every required key
121
+ must be present and of the right type.
122
+ """
123
+ unknown_sections = sorted(set(data) - set(_ALLOWED_SECTIONS))
124
+ if unknown_sections:
125
+ raise ConfigError(
126
+ f"unknown config sections: {unknown_sections}; "
127
+ f"allowed: {list(_ALLOWED_SECTIONS)}"
128
+ )
129
+
130
+ # ---- [start] ----
131
+ start = data.get("start", {})
132
+ if not isinstance(start, dict):
133
+ raise ConfigError("[start] must be a table")
134
+ start_date = _require_str(start, "start", "start_date")
135
+ end_date = _require_str(start, "end", "end_date")
136
+ try:
137
+ validate_yyyymmdd(start_date, name="start.start_date")
138
+ except Exception as exc:
139
+ raise ConfigError(f"[start].start_date: {exc}") from exc
140
+ try:
141
+ validate_yyyymmdd(end_date, name="start.end_date")
142
+ except Exception as exc:
143
+ raise ConfigError(f"[start].end_date: {exc}") from exc
144
+ if start_date > end_date:
145
+ raise ConfigError(
146
+ f"[start] start_date {start_date} is after end_date {end_date}"
147
+ )
148
+
149
+ # ---- [capital] ----
150
+ capital = data.get("capital", {})
151
+ if not isinstance(capital, dict):
152
+ raise ConfigError("[capital] must be a table")
153
+ initial_cash = _require_decimal(
154
+ capital, "capital", "initial_cash", min_value=Decimal("0")
155
+ )
156
+
157
+ # ---- [data] ----
158
+ data_sec = data.get("data", {})
159
+ if not isinstance(data_sec, dict):
160
+ raise ConfigError("[data] must be a table")
161
+ source = _require_str(data_sec, "data", "source")
162
+ data_root = data_sec.get("data_root")
163
+ if data_root is None:
164
+ data_root = DEFAULT_DATA_ROOT
165
+ elif not isinstance(data_root, str) or not data_root:
166
+ raise ConfigError("[data].data_root must be a non-empty string")
167
+
168
+ # ---- [strategy] ----
169
+ strat = data.get("strategy", {})
170
+ if not isinstance(strat, dict):
171
+ raise ConfigError("[strategy] must be a table")
172
+ strategy_module = _require_str(strat, "strategy", "module")
173
+ strategy_class = strat.get("class_name")
174
+ if strategy_class is not None and not isinstance(strategy_class, str):
175
+ raise ConfigError("[strategy].class_name must be a string")
176
+ strategy_kwargs = strat.get("kwargs", {})
177
+ if strategy_kwargs is None:
178
+ strategy_kwargs = {}
179
+ if not isinstance(strategy_kwargs, dict):
180
+ raise ConfigError("[strategy].kwargs must be a table")
181
+
182
+ # ---- [cost_model] (optional) ----
183
+ cost_sec = data.get("cost_model", {})
184
+ if not isinstance(cost_sec, dict):
185
+ raise ConfigError("[cost_model] must be a table")
186
+ cost_overrides: Dict[str, Decimal] = {}
187
+ for key, attr in (
188
+ ("commission_rate", "commission_rate"),
189
+ ("min_commission", "min_commission"),
190
+ ("stamp_tax_rate", "stamp_tax_rate"),
191
+ ("transfer_fee_rate", "transfer_fee_rate"),
192
+ ):
193
+ if key in cost_sec:
194
+ cost_overrides[attr] = _require_decimal(
195
+ cost_sec, "cost_model", key, min_value=Decimal("0")
196
+ )
197
+
198
+ # ---- [output] ----
199
+ out = data.get("output", {})
200
+ if not isinstance(out, dict):
201
+ raise ConfigError("[output] must be a table")
202
+ output_directory = _require_str(out, "output", "directory")
203
+
204
+ return ConfigFile(
205
+ start_date=start_date,
206
+ end_date=end_date,
207
+ initial_cash=initial_cash,
208
+ source=source,
209
+ strategy_module=strategy_module,
210
+ strategy_class=strategy_class,
211
+ strategy_kwargs=strategy_kwargs,
212
+ cost_overrides=cost_overrides,
213
+ output_directory=output_directory,
214
+ data_root=data_root,
215
+ raw_text=raw_text,
216
+ )
217
+
218
+
219
+ def _require_str(section: Dict[str, Any], section_name: str, key: str) -> str:
220
+ if key not in section:
221
+ raise ConfigError(f"[{section_name}] missing required key {key!r}")
222
+ value = section[key]
223
+ if not isinstance(value, str) or not value:
224
+ raise ConfigError(f"[{section_name}].{key} must be a non-empty string")
225
+ return value
226
+
227
+
228
+ def _require_decimal(
229
+ section: Dict[str, Any],
230
+ section_name: str,
231
+ key: str,
232
+ *,
233
+ min_value: Optional[Decimal] = None,
234
+ ) -> Decimal:
235
+ if key not in section:
236
+ raise ConfigError(f"[{section_name}] missing required key {key!r}")
237
+ value = section[key]
238
+ if isinstance(value, bool):
239
+ raise ConfigError(f"[{section_name}].{key} must be a number, not bool")
240
+ # Float is forbidden at the CLI layer too, matching the engine's
241
+ # contract rule 5. Without this check a TOML like
242
+ # `initial_cash = 100000.0` would silently convert to a Decimal via
243
+ # `Decimal(str(float))`, masking the precision concern.
244
+ if isinstance(value, float):
245
+ raise ConfigError(
246
+ f"[{section_name}].{key} must be int/str/Decimal; float is "
247
+ "forbidden (contract rule 5)"
248
+ )
249
+ if isinstance(value, (int, str)):
250
+ try:
251
+ d = Decimal(str(value))
252
+ except Exception as exc:
253
+ raise ConfigError(
254
+ f"[{section_name}].{key}={value!r} is not a valid number: {exc}"
255
+ ) from exc
256
+ elif isinstance(value, Decimal):
257
+ d = value
258
+ else:
259
+ raise ConfigError(
260
+ f"[{section_name}].{key} must be a number, got {type(value).__name__}"
261
+ )
262
+ # NaN / +Inf / -Inf are technically parseable by `Decimal(str('nan'))`
263
+ # but break every downstream comparison (e.g. `nan < 0` raises
264
+ # InvalidOperation). Reject them here so the user gets a clean
265
+ # single-line ConfigError instead of a traceback.
266
+ if not d.is_finite():
267
+ raise ConfigError(
268
+ f"[{section_name}].{key}={d} must be a finite number "
269
+ "(NaN / +Inf / -Inf are not allowed)"
270
+ )
271
+ if min_value is not None and d < min_value:
272
+ raise ConfigError(f"[{section_name}].{key}={d} must be >= {min_value}")
273
+ return d
274
+
275
+
276
+ # --------------------------------------------------------------------- #
277
+ # Resolving strategy + BacktestConfig
278
+ # --------------------------------------------------------------------- #
279
+
280
+
281
+ def resolve_strategy(config_file: ConfigFile) -> BaseStrategy:
282
+ """Import the user-supplied module and instantiate the strategy class.
283
+
284
+ `class_name` is optional; when omitted we use the first `BaseStrategy`
285
+ subclass exported by the module.
286
+ """
287
+ try:
288
+ module = importlib.import_module(config_file.strategy_module)
289
+ except ImportError as exc:
290
+ raise ConfigError(
291
+ f"could not import strategy module {config_file.strategy_module!r}: {exc}"
292
+ ) from exc
293
+
294
+ cls: Optional[Type[BaseStrategy]] = None
295
+ if config_file.strategy_class is not None:
296
+ candidate = getattr(module, config_file.strategy_class, None)
297
+ if candidate is None:
298
+ raise ConfigError(
299
+ f"module {config_file.strategy_module!r} has no attribute "
300
+ f"{config_file.strategy_class!r}"
301
+ )
302
+ if not (inspect.isclass(candidate) and issubclass(candidate, BaseStrategy)):
303
+ raise ConfigError(
304
+ f"{config_file.strategy_class!r} is not a BaseStrategy subclass"
305
+ )
306
+ cls = candidate
307
+ else:
308
+ for _, obj in inspect.getmembers(module, inspect.isclass):
309
+ if obj is BaseStrategy:
310
+ continue
311
+ if issubclass(obj, BaseStrategy):
312
+ cls = obj
313
+ break
314
+ if cls is None:
315
+ raise ConfigError(
316
+ f"no BaseStrategy subclass found in {config_file.strategy_module!r}; "
317
+ "either define one or set [strategy].class_name"
318
+ )
319
+
320
+ # Constructor kwargs come straight from the user. We do NOT inspect
321
+ # the constructor signature; users are responsible for matching it.
322
+ try:
323
+ return cls(**config_file.strategy_kwargs)
324
+ except TypeError as exc:
325
+ raise ConfigError(
326
+ f"failed to construct {cls.__name__} with kwargs "
327
+ f"{config_file.strategy_kwargs}: {exc}"
328
+ ) from exc
329
+ except Exception as exc: # pragma: no cover - defensive
330
+ raise ConfigError(
331
+ f"unexpected error constructing {cls.__name__}: {exc}"
332
+ ) from exc
333
+
334
+
335
+ def build_backtest_config(
336
+ config_file: ConfigFile,
337
+ ) -> "BacktestConfig": # type: ignore[name-defined]
338
+ """Translate the validated config file into a runtime `BacktestConfig`."""
339
+ from ..engine.config import BacktestConfig # local to break circular import
340
+
341
+ cost_model = DefaultCostModel()
342
+ if config_file.cost_overrides:
343
+ cost_model = DefaultCostModel(
344
+ commission_rate=config_file.cost_overrides.get(
345
+ "commission_rate", cost_model.commission_rate
346
+ ),
347
+ min_commission=config_file.cost_overrides.get(
348
+ "min_commission", cost_model.min_commission
349
+ ),
350
+ stamp_tax_rate=config_file.cost_overrides.get(
351
+ "stamp_tax_rate", cost_model.stamp_tax_rate
352
+ ),
353
+ transfer_fee_rate=config_file.cost_overrides.get(
354
+ "transfer_fee_rate", cost_model.transfer_fee_rate
355
+ ),
356
+ )
357
+
358
+ return BacktestConfig(
359
+ start_date=config_file.start_date,
360
+ end_date=config_file.end_date,
361
+ initial_cash=config_file.initial_cash,
362
+ source=config_file.source,
363
+ data_root=config_file.data_root,
364
+ rule_set=TradingRuleSet(DEFAULT_V01_RULES),
365
+ cost_model=cost_model,
366
+ )
367
+
368
+
369
+ # Re-export for convenience; `OrderType` import keeps linters happy in
370
+ # downstream code that imports from this module.
371
+ __all__ = [
372
+ "ConfigError",
373
+ "ConfigFile",
374
+ "build_backtest_config",
375
+ "load_config_file",
376
+ "resolve_strategy",
377
+ ]
378
+ # `OrderType` is intentionally imported above to keep the public-API
379
+ # surface in sync with the rest of the engine layer.
380
+ _ = OrderType