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.
- hqbacktest/__init__.py +104 -0
- hqbacktest/__main__.py +13 -0
- hqbacktest/cli/__init__.py +7 -0
- hqbacktest/cli/__main__.py +109 -0
- hqbacktest/cli/config.py +380 -0
- hqbacktest/cli/runner.py +347 -0
- hqbacktest/data/__init__.py +56 -0
- hqbacktest/data/_converters.py +88 -0
- hqbacktest/data/cache.py +57 -0
- hqbacktest/data/data_view.py +308 -0
- hqbacktest/data/errors.py +84 -0
- hqbacktest/data/hqdata_portal.py +548 -0
- hqbacktest/data/memory_portal.py +246 -0
- hqbacktest/data/portal.py +70 -0
- hqbacktest/data/validators.py +109 -0
- hqbacktest/domain/__init__.py +85 -0
- hqbacktest/domain/adjustment.py +60 -0
- hqbacktest/domain/bar.py +73 -0
- hqbacktest/domain/enums.py +115 -0
- hqbacktest/domain/errors.py +16 -0
- hqbacktest/domain/fill.py +130 -0
- hqbacktest/domain/money.py +79 -0
- hqbacktest/domain/order.py +210 -0
- hqbacktest/domain/portfolio.py +155 -0
- hqbacktest/domain/position.py +124 -0
- hqbacktest/domain/serialization.py +50 -0
- hqbacktest/domain/snapshot.py +112 -0
- hqbacktest/domain/state_machine.py +64 -0
- hqbacktest/engine/__init__.py +128 -0
- hqbacktest/engine/broker.py +248 -0
- hqbacktest/engine/config.py +105 -0
- hqbacktest/engine/context.py +626 -0
- hqbacktest/engine/corporate_actions.py +294 -0
- hqbacktest/engine/cost_model.py +70 -0
- hqbacktest/engine/engine.py +789 -0
- hqbacktest/engine/errors.py +63 -0
- hqbacktest/engine/events.py +65 -0
- hqbacktest/engine/intents.py +118 -0
- hqbacktest/engine/iterator.py +67 -0
- hqbacktest/engine/metrics.py +286 -0
- hqbacktest/engine/result.py +312 -0
- hqbacktest/engine/rule_set.py +261 -0
- hqbacktest/engine/scheduler.py +158 -0
- hqbacktest/engine/strategy.py +116 -0
- hqbacktest-0.1.4.dist-info/METADATA +202 -0
- hqbacktest-0.1.4.dist-info/RECORD +50 -0
- hqbacktest-0.1.4.dist-info/WHEEL +5 -0
- hqbacktest-0.1.4.dist-info/entry_points.txt +2 -0
- hqbacktest-0.1.4.dist-info/licenses/LICENSE +21 -0
- 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"]
|
hqbacktest/cli/config.py
ADDED
|
@@ -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
|