Files
Auto-Trader/agent/backtest/runner.py
T
Haozhe Wu d3c76819c3 fix: complete October 11-12 maintenance and research updates
Fix live option guards, grounding operand evidence, source-bound benchmarks, trade artifacts, attribution, drawdowns, valuation tolerances, and SEC point-in-time factor inputs.

Add Brazil research data and market support, 13 fundamental and valuation factors, channel progress forwarding, and opt-in ZimaOS templates. Update seven README News sections with three visible dates.

Validation: 17,635 backend tests passed; 1,084 final targeted tests and 103 frontend tests passed. Frontend build, package build, documentation checks, and CI gates passed.
2026-10-12 02:29:46 +08:00

2159 lines
85 KiB
Python

"""Fixed backtest entrypoint: read config.json, select loader by source, import signal_engine, run engine.
Supports ``source="auto"`` to route codes to loaders by symbol format.
Supports ``interval`` for bar size (1m/5m/15m/30m/1H/4H/1D/1W/1M, default 1D;
1W and 1M are built from daily bars, see ``loaders.base.resample_bars``).
Supports ``engine`` for backtest engine (daily/options, default daily).
Usage: ``python -m backtest.runner <run_dir>``
"""
import ast
import copy
import importlib.util
import inspect
import json
import logging
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, List, Literal, Optional
import pandas as pd
from pydantic import BaseModel, ConfigDict, Field, model_validator, field_validator
from backtest.loaders.registry import (
FALLBACK_CHAINS,
LOADER_REGISTRY,
VALID_SOURCES,
additive_caliber_warning,
get_loader_cls_with_fallback,
is_no_network_fallback_source,
mixed_caliber_warning,
frame_caliber,
resolve_loader,
)
from backtest.loaders.base import (
NoAvailableSourceError,
resample_bars,
source_interval,
validate_ohlc,
)
# Symbol classification lives in ``_market_hooks`` so runner.py and
# composite.py share a single source of truth (audit-2026-05-18 B1+C1+C2).
# ``_detect_market`` is also re-exported here for back-compat with
# ``agent/src/swarm/grounding.py`` and existing tests that import it
# from ``backtest.runner``.
from backtest.engines._market_hooks import ( # noqa: F401 (re-exported)
_detect_market,
_detect_submarket,
_is_china_futures,
hk_counter_currency,
strip_local_prefix,
)
from backtest.rebalance_mask import RebalanceMask, validate_rebalance_mask
logger = logging.getLogger(__name__)
_VALID_INTERVALS = {"1m", "5m", "15m", "30m", "1H", "4H", "1D", "1W", "1M"}
_VALID_ENGINES = {"daily", "options"}
_PRICE_PANEL_COLUMNS = ("open", "high", "low", "close", "volume", "vwap", "amount")
_FUND_PREFIX = "fund:"
_VAL_PREFIX = "val:"
@dataclass(frozen=True)
class DataFetchResult:
"""Market data plus the routing metadata selected by the central registry."""
data_map: Dict[str, pd.DataFrame]
codes: List[str]
source: str
loader: Any
effective_sources: List[str]
# Mixed-caliber warning for the served basket (#1301), None when every
# served symbol shares one comparable caliber (or none is measurable).
caliber_warning: str | None = None
class BacktestConfigSchema(BaseModel):
"""Validates backtest config.json before execution."""
model_config = ConfigDict(extra="allow")
codes: List[str]
start_date: str
end_date: str
source: str = "tushare"
interval: str = "1D"
engine: str = "daily"
position_adjustment: Literal["hold", "rebalance"] = "hold"
rebalance_mask: RebalanceMask = None
# Under "rebalance", a resize executes only once the held weight has
# drifted further than this fraction of its target -- the tolerance band
# practitioners describe as "rebalance when weights move more than X".
# 0.0 is the historical behaviour and stays the default: without a band the
# resize test is decided by the slippage width alone, which re-pins a
# position on a one-basis-point move. A CHANGED target breaches any sane
# band on its own, so target changes always execute whatever this is set to.
rebalance_tolerance: float = Field(default=0.0, ge=0.0, allow_inf_nan=False)
# Returns divide by initial_cash, so a non-positive value yields inf/NaN
# metrics (total_return, annual_return, ...). Reject it at the config
# boundary instead of letting the run produce non-finite results.
initial_cash: float = Field(default=1_000_000, gt=0, allow_inf_nan=False)
fundamental_fields: Optional[Dict[str, List[str]]] = None
event_feeds: Optional[List[Dict[str, Any]]] = None
# An indicator with a long lookback needs bars from before the period the
# user asked about. Declaring the boundary keeps those bars out of the
# performance: either as a bar count, or as the date evaluation starts.
# Only the shapes are checked here -- whether the boundary leaves anything
# to evaluate depends on the loaded calendar, so the one rule that decides
# it lives in `engines.base.evaluation_start_index`, which every engine and
# every direct-API caller passes through.
warmup_bars: Optional[int] = Field(default=None, ge=0)
evaluation_start_date: Optional[str] = None
@field_validator("codes")
@classmethod
def codes_not_empty(cls, v: List[str]) -> List[str]:
if not v:
raise ValueError("codes must be a non-empty list")
if any(not c.strip() for c in v):
raise ValueError("codes must not contain empty strings")
return v
@field_validator("start_date", "end_date")
@classmethod
def valid_date(cls, v: str) -> str:
try:
pd.Timestamp(v)
except Exception:
raise ValueError(f"invalid date format: {v!r} (expected YYYY-MM-DD)")
return v
@field_validator("evaluation_start_date")
@classmethod
def valid_evaluation_start(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
try:
pd.Timestamp(v)
except Exception:
raise ValueError(
f"invalid evaluation_start_date: {v!r} (expected YYYY-MM-DD)"
) from None
return v
@field_validator("rebalance_mask")
@classmethod
def valid_rebalance_mask(cls, v: RebalanceMask) -> RebalanceMask:
return validate_rebalance_mask(v)
@field_validator("interval")
@classmethod
def valid_interval(cls, v: str) -> str:
if v not in _VALID_INTERVALS:
raise ValueError(f"unsupported interval {v!r}, must be one of {_VALID_INTERVALS}")
return v
@field_validator("engine")
@classmethod
def valid_engine(cls, v: str) -> str:
if v not in _VALID_ENGINES:
raise ValueError(f"unsupported engine {v!r}, must be one of {_VALID_ENGINES}")
return v
@field_validator("source")
@classmethod
def valid_source(cls, v: str) -> str:
if v not in VALID_SOURCES:
raise ValueError(f"unsupported source {v!r}, must be one of {VALID_SOURCES}")
return v
@field_validator("fundamental_fields")
@classmethod
def valid_fundamental_fields(
cls,
v: Optional[Dict[str, List[str]]],
) -> Optional[Dict[str, List[str]]]:
if v is None:
return v
for table, fields in v.items():
if not table.strip():
raise ValueError("fundamental_fields table names must be non-empty strings")
if any(not field.strip() for field in fields):
raise ValueError("fundamental_fields field names must be non-empty strings")
return v
@field_validator("event_feeds")
@classmethod
def valid_event_feeds(cls, v: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]:
if v is None:
return v
for entry in v:
if not isinstance(entry, dict):
raise ValueError(
"each event_feeds entry must be an object with name/route_template/event_type"
)
for key in ("name", "route_template", "event_type"):
if not str(entry.get(key, "")).strip():
raise ValueError(f"event_feeds entry missing required field: {key}")
return v
@model_validator(mode="after")
def start_before_end(self) -> "BacktestConfigSchema":
if self.rebalance_mask is not None and self.position_adjustment != "rebalance":
raise ValueError(
"rebalance_mask requires position_adjustment='rebalance'"
)
if pd.Timestamp(self.start_date) > pd.Timestamp(self.end_date):
raise ValueError(
f"start_date ({self.start_date}) must be <= end_date ({self.end_date})"
)
return self
def _load_module_from_file(file_path: Path, module_name: str):
"""Load a Python module from a file path via importlib.
Args:
file_path: Path to the ``.py`` file.
module_name: Logical module name.
Returns:
Loaded module object.
"""
_validate_signal_engine_source(file_path)
spec = importlib.util.spec_from_file_location(module_name, file_path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _is_literal_node(node: ast.AST) -> bool:
"""Return whether an AST node is made only from literal values."""
if isinstance(node, ast.Constant):
return True
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
# Signed numbers (e.g. a mined ``-0.08`` return threshold) parse as a
# UnaryOp over a Constant rather than a bare Constant. They are
# compile-time constants: no name lookup, call, or attribute access
# runs when the definition is imported, so they stay import-time safe.
operand = node.operand
return (
isinstance(operand, ast.Constant)
and isinstance(operand.value, (int, float, complex))
and not isinstance(operand.value, bool)
)
if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
return all(_is_literal_node(item) for item in node.elts)
if isinstance(node, ast.Dict):
return all(
(key is None or _is_literal_node(key)) and _is_literal_node(value)
for key, value in zip(node.keys, node.values)
)
return False
def _is_safe_constant_assignment(node: ast.AST) -> bool:
"""Return whether a top-level assignment is literal-only."""
if isinstance(node, ast.Assign):
return _is_literal_node(node.value)
if isinstance(node, ast.AnnAssign):
return node.value is None or _is_literal_node(node.value)
return False
def _is_safe_reference(node: ast.AST | None) -> bool:
"""Return whether an annotation/base expression cannot call code."""
if node is None:
return True
if isinstance(node, (ast.Name, ast.Attribute, ast.Constant)):
return True
if isinstance(node, ast.Subscript):
return _is_safe_reference(node.value) and _is_safe_reference(node.slice)
if isinstance(node, ast.Tuple):
return all(_is_safe_reference(item) for item in node.elts)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
return _is_safe_reference(node.left) and _is_safe_reference(node.right)
return False
def _validate_function_def(node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
"""Reject import-time execution in function definitions."""
if node.decorator_list:
raise ValueError(f"Decorators are not allowed on function {node.name!r}")
for default in [*node.args.defaults, *[d for d in node.args.kw_defaults if d]]:
if not _is_literal_node(default):
raise ValueError(f"Non-literal default is not allowed on function {node.name!r}")
annotations = [node.returns]
annotations.extend(arg.annotation for arg in node.args.posonlyargs)
annotations.extend(arg.annotation for arg in node.args.args)
annotations.extend(arg.annotation for arg in node.args.kwonlyargs)
annotations.append(node.args.vararg.annotation if node.args.vararg else None)
annotations.append(node.args.kwarg.annotation if node.args.kwarg else None)
for annotation in annotations:
if not _is_safe_reference(annotation):
raise ValueError(f"Unsafe annotation is not allowed on function {node.name!r}")
def _validate_class_body(node: ast.ClassDef) -> None:
"""Reject import-time execution inside class bodies."""
if node.decorator_list:
raise ValueError(f"Decorators are not allowed on class {node.name!r}")
for base in node.bases:
if not _is_safe_reference(base):
raise ValueError(f"Unsafe base class is not allowed on class {node.name!r}")
if node.keywords:
raise ValueError(f"Class keywords are not allowed on class {node.name!r}")
for child in node.body:
if isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant):
continue
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
_validate_function_def(child)
continue
if _is_safe_constant_assignment(child):
continue
if isinstance(child, ast.Pass):
continue
raise ValueError(
f"Executable class-level statement {type(child).__name__} is not allowed"
)
# --- Runtime-reachable operation scrubber (VT-001 defense-in-depth) ---
#
# The structural checks above only reject *import-time* execution. The real
# exposure is that once ``SignalEngine`` is instantiated and ``.generate()`` is
# called, arbitrary code inside its method bodies runs with no runtime sandbox.
# This scrubber walks the code that actually executes during a backtest — every
# ``SignalEngine`` method plus any module-level helper transitively called from
# one — and rejects network / process-spawn / dynamic-exec / filesystem-write
# operations there.
#
# It is deliberately scoped to *reachable* code, not the whole file: the bundled
# skill examples (agent/src/skills/*/example_signal_engine.py) legitimately carry
# ``import requests`` + ``requests.get`` inside standalone ``_fetch_okx`` helpers
# and ``if __name__ == "__main__"`` demo blocks that the runner never executes
# (it does ``import module; SignalEngine().generate(data_map)``). Blocking those
# imports file-wide would reject strategies generated from ~12 shipped skills, so
# we block the dangerous *use* along the executed path instead of a harmless
# unused top-level import. Direct ``getattr``/``setattr``/``delattr`` indirection
# onto ``os`` / forbidden modules is now rejected (see _reject_forbidden_getattr),
# and a renamed binding no longer hides the root (see
# _module_level_forbidden_aliases). This is defense-in-depth, not a kernel-level
# guarantee: an AST denylist cannot be complete.
#
# MEASURED RESIDUAL, so it is not left implied. After the lists below, these
# still reach the executed path: ``inspect``, ``operator``, ``threading``,
# ``codeop``, ``tempfile``, and ``os.makedirs``. None of them reaches a broker
# on its own — ``inspect``/``operator`` need an object you already hold,
# ``threading`` runs code that is still in this file and gets scanned if it is
# reachable, ``codeop`` yields a code object that needs the already-blocked
# ``eval``/``exec`` to run, and the last two are filesystem, not the red line.
# They are named here so the next person extends this list from evidence rather
# than from a fresh survey.
_FORBIDDEN_IMPORT_MODULES = frozenset(
{
"socket",
"socketserver",
"subprocess",
"urllib",
"urllib2",
"urllib3",
"http",
"requests",
"httpx",
"aiohttp",
"ftplib",
"smtplib",
"telnetlib",
"multiprocessing",
"ctypes",
# Modules that resolve or execute a module BY NAME. Blocking the
# ``src.trading`` prefix is worth nothing while any of these can fetch
# the same module from a string, and every one of them was measured
# ACCEPTED against the live scanner:
# importlib.import_module("src.trading.service")
# builtins.__import__("src.trading.service")
# sys.modules["src.trading.service"]
# pkgutil.resolve_name("src.trading.service:place_order")
# runpy.run_module("src.trading.service")
# ``pickle``/``marshal`` belong here for the same reason: a crafted
# payload imports and calls through ``__reduce__`` without naming the
# module in the source at all. None has a use in a signal engine, which
# receives a data_map and returns signals.
"importlib",
"builtins",
"sys",
"pkgutil",
"runpy",
"pickle",
"marshal",
# Process spawn and filesystem reach, alongside the subprocess entry
# already above.
"shutil",
"webbrowser",
"gc",
}
)
# Project-internal subtrees that reach a broker. The red line is that no
# research or backtest path can arrive at a connector's ``place_order``, and
# until now that separation rested on the subprocess never being handed
# credentials rather than on the scanner refusing the import. It refuses now.
#
# These are matched on the dotted PREFIX rather than the root package, because
# the root is ``src`` — which also holds ``src.quantlib``, the finance-math
# layer strategies are explicitly meant to import (see the credit-analysis
# skill). Blocking the root would take that away to close this.
_FORBIDDEN_IMPORT_PREFIXES = (
"src.trading",
"src.live",
"agent.src.trading",
"agent.src.live",
"trading.connectors",
"live.sdk_order_gate",
)
def _is_forbidden_module_path(name: str) -> bool:
"""Return True when a dotted module path names a forbidden module or subtree.
Args:
name: Dotted module path, e.g. ``socket`` or ``src.trading.service``.
Returns:
True when the root package is forbidden outright, or the path falls
inside a forbidden subtree.
"""
if not name:
return False
if name.split(".")[0] in _FORBIDDEN_IMPORT_MODULES:
return True
return any(
name == prefix or name.startswith(prefix + ".")
for prefix in _FORBIDDEN_IMPORT_PREFIXES
)
# ``os`` itself is allowed (os.path etc.), but these attributes shell out, spawn,
# or read the process environment — none has a place in a signal engine.
_FORBIDDEN_OS_ATTRS = frozenset(
{
"system",
"popen",
"popen2",
"popen3",
"popen4",
"fork",
"forkpty",
"putenv",
"unsetenv",
"getenv",
"environ",
"environb",
"startfile",
# Filesystem mutation, alongside the open()/pathlib write guards. A
# signal engine returns signals; deleting or renaming files is not part
# of that contract, and os.remove was measured ACCEPTED.
"remove",
"unlink",
"rmdir",
"removedirs",
"rename",
"renames",
"replace",
"truncate",
"chmod",
"chown",
"symlink",
"link",
}
)
# The object graph is the last structural route to a module the import checks
# refuse: ``().__class__.__mro__[1].__subclasses__()`` reaches every loaded
# class without naming one, and ``(lambda: 0).__globals__['__builtins__']``
# hands back the builtins mapping that ``_FORBIDDEN_BUILTINS`` exists to gate.
# Both were measured ACCEPTED.
#
# Only the traversal attributes are listed, not every dunder: a signal engine
# has no reason to walk ``__mro__`` or read ``__globals__``, but banning dunders
# wholesale would reject ordinary code. This does not make the sandbox complete
# — see the residual note on _FORBIDDEN_IMPORT_MODULES.
_FORBIDDEN_DUNDER_ATTRS = frozenset(
{
"__class__",
"__bases__",
"__base__",
"__mro__",
"__subclasses__",
"__globals__",
"__builtins__",
"__code__",
"__closure__",
"__func__",
"__self__",
"__reduce__",
"__reduce_ex__",
"__getattribute__",
"__init_subclass__",
"__subclasshook__",
}
)
_FORBIDDEN_BUILTINS = frozenset(
{"eval", "exec", "compile", "__import__", "globals", "locals", "vars", "breakpoint"}
)
# getattr/setattr/delattr can indirect around the attribute scanner
# (``getattr(os, "system")("id")``). We reject them ONLY when the target object
# is ``os`` or a forbidden module — keyed off the target, not the attribute
# string, so ``getattr(os, "sys" + "tem")`` is caught too. Legitimate dynamic
# access on user objects (``getattr(tech, name, None)``, ``getattr(self, x)``)
# is unaffected.
_GETATTR_INDIRECTION = frozenset({"getattr", "setattr", "delattr"})
_OPEN_WRITE_MODE_CHARS = frozenset("wax+")
_SCRUB_MSG = "is not allowed inside generated strategy code"
def _is_forbidden_os_attr(attr: str) -> bool:
"""Return whether ``os.<attr>`` shells out, spawns, execs, or reads env."""
return attr in _FORBIDDEN_OS_ATTRS or attr.startswith(("spawn", "exec"))
def _attribute_root_name(node: ast.Attribute) -> str | None:
"""Return the leftmost ``Name`` id of an attribute chain (``a.b.c`` -> ``a``)."""
current: ast.AST = node
while isinstance(current, ast.Attribute):
current = current.value
return current.id if isinstance(current, ast.Name) else None
def _attribute_dotted_path(node: ast.Attribute) -> str | None:
"""Rebuild the dotted path of an attribute chain (``a.b.c`` -> ``a.b.c``).
The root name alone cannot decide a forbidden-subtree check: ``src`` is
shared by the blocked ``src.trading`` and the permitted ``src.quantlib``.
Args:
node: Attribute node at the end of the chain.
Returns:
The dotted path, or None when the chain is not rooted in a plain name
(e.g. it starts from a call or subscript).
"""
parts: list[str] = []
current: ast.AST = node
while isinstance(current, ast.Attribute):
parts.append(current.attr)
current = current.value
if not isinstance(current, ast.Name):
return None
parts.append(current.id)
return ".".join(reversed(parts))
def _reject_forbidden_open(node: ast.Call) -> None:
"""Reject ``open()`` used to write files or read a non-relative-literal path."""
func = node.func
is_builtin_open = isinstance(func, ast.Name) and func.id == "open"
is_io_os_open = (
isinstance(func, ast.Attribute)
and func.attr == "open"
and isinstance(func.value, ast.Name)
and func.value.id in {"io", "os"}
)
if not (is_builtin_open or is_io_os_open):
return
mode_node: ast.AST | None = node.args[1] if len(node.args) >= 2 else None
for kw in node.keywords:
if kw.arg == "mode":
mode_node = kw.value
if mode_node is not None:
if not (isinstance(mode_node, ast.Constant) and isinstance(mode_node.value, str)):
raise ValueError(f"open() with a non-literal mode {_SCRUB_MSG}")
if any(ch in _OPEN_WRITE_MODE_CHARS for ch in mode_node.value):
raise ValueError(f"Writing files via open(mode={mode_node.value!r}) {_SCRUB_MSG}")
path_node = node.args[0] if node.args else None
if not (isinstance(path_node, ast.Constant) and isinstance(path_node.value, str)):
raise ValueError(f"open() with a non-literal path {_SCRUB_MSG}")
path = path_node.value
if path.startswith(("/", "~", "\\")) or ".." in path or (len(path) > 1 and path[1] == ":"):
raise ValueError(f"open() with a non-relative path {path!r} {_SCRUB_MSG}")
def _reject_forbidden_pathlib_write(node: ast.Call) -> None:
"""Reject the pathlib spellings of a file write that ``open()`` already bars.
``_reject_forbidden_open`` refuses ``open(path, "w")``, but the identical
write reaches disk through ``Path(p).write_text()``, ``.write_bytes()`` and
``Path(p).open("w")``, none of which that check can see — it only matches a
bare ``open`` or ``io``/``os.open``. The guard therefore did not do the one
thing it says it does. Measured against the live scanner,
``Path('/tmp/x').write_text('x')`` was ACCEPTED.
Matching is on the method name rather than on proving the receiver is a
``Path``: the receiver is usually a call expression, and a signal engine
that defines its own ``write_text`` is not a pattern worth preserving. A
strategy is handed a data_map and returns signals; it writes nothing.
Args:
node: Call node on the executed path.
Raises:
ValueError: If the call writes to the filesystem.
"""
func = node.func
if not isinstance(func, ast.Attribute):
return
if func.attr in {"write_text", "write_bytes"}:
raise ValueError(f"Writing files via .{func.attr}() {_SCRUB_MSG}")
if func.attr != "open":
return
if isinstance(func.value, ast.Name) and func.value.id in {"io", "os"}:
return # module-level open(path, mode) — _reject_forbidden_open owns it
# A BOUND ``.open()`` takes the mode first: the receiver is not an argument,
# so the index is 0 here where the module-level form uses 1.
mode_node: ast.AST | None = node.args[0] if node.args else None
for kw in node.keywords:
if kw.arg == "mode":
mode_node = kw.value
if mode_node is None:
return
if not (isinstance(mode_node, ast.Constant) and isinstance(mode_node.value, str)):
raise ValueError(f".open() with a non-literal mode {_SCRUB_MSG}")
if any(ch in _OPEN_WRITE_MODE_CHARS for ch in mode_node.value):
raise ValueError(f"Writing files via .open(mode={mode_node.value!r}) {_SCRUB_MSG}")
def _reject_forbidden_getattr(node: ast.Call) -> None:
"""Reject getattr/setattr/delattr indirection onto ``os`` / forbidden modules.
Closes the documented bypass ``getattr(os, "system")("id")`` — and computed
variants like ``getattr(os, "sys" + "tem")`` — by keying off the *target*
object (first positional arg), not the attribute string. Dynamic access on
ordinary user objects (``getattr(tech, name, None)``) is left untouched.
"""
func = node.func
if not (isinstance(func, ast.Name) and func.id in _GETATTR_INDIRECTION):
return
if not node.args:
return
target = node.args[0]
if isinstance(target, ast.Name):
root: str | None = target.id
elif isinstance(target, ast.Attribute):
root = _attribute_root_name(target)
dotted = _attribute_dotted_path(target)
if dotted and _is_forbidden_module_path(dotted):
raise ValueError(f"{func.id}() indirection onto {dotted!r} {_SCRUB_MSG}")
else:
root = None
if root == "os" or root in _FORBIDDEN_IMPORT_MODULES:
raise ValueError(f"{func.id}() indirection onto {root!r} {_SCRUB_MSG}")
def _reject_forbidden_node(node: ast.AST) -> None:
"""Raise ``ValueError`` if a single AST node performs a forbidden operation."""
if isinstance(node, ast.Import):
for alias in node.names:
if _is_forbidden_module_path(alias.name):
raise ValueError(f"Import of {alias.name!r} {_SCRUB_MSG}")
elif isinstance(node, ast.ImportFrom):
module = node.module or ""
root = module.split(".")[0]
if _is_forbidden_module_path(module):
raise ValueError(f"Import from {node.module!r} {_SCRUB_MSG}")
# "from src.trading import service" names the subtree in the alias,
# not the module, so the prefix check above cannot see it.
for alias in node.names:
if _is_forbidden_module_path(f"{module}.{alias.name}" if module else alias.name):
raise ValueError(f"Import of {module}.{alias.name!r} {_SCRUB_MSG}")
if root == "os":
for alias in node.names:
if _is_forbidden_os_attr(alias.name):
raise ValueError(f"Import of os.{alias.name} {_SCRUB_MSG}")
elif isinstance(node, ast.Attribute):
root = _attribute_root_name(node)
if root in _FORBIDDEN_IMPORT_MODULES:
raise ValueError(f"Use of {root}.{node.attr} {_SCRUB_MSG}")
dotted = _attribute_dotted_path(node)
if dotted and _is_forbidden_module_path(dotted):
raise ValueError(f"Use of {dotted} {_SCRUB_MSG}")
if root == "os" and _is_forbidden_os_attr(node.attr):
raise ValueError(f"Use of os.{node.attr} {_SCRUB_MSG}")
if node.attr in _FORBIDDEN_DUNDER_ATTRS:
raise ValueError(f"Object-graph traversal via .{node.attr} {_SCRUB_MSG}")
elif isinstance(node, ast.Name):
if node.id in _FORBIDDEN_BUILTINS:
raise ValueError(f"Use of {node.id!r} {_SCRUB_MSG}")
elif isinstance(node, ast.Call):
_reject_forbidden_open(node)
_reject_forbidden_pathlib_write(node)
_reject_forbidden_getattr(node)
def _module_level_forbidden_aliases(tree: ast.Module) -> dict[str, str]:
"""Map module-level names that are bound to a forbidden module.
Module-level imports are deliberately not rejected outright (see the
``_FORBIDDEN_IMPORT_MODULES`` comment): the shipped skill examples carry an
unused ``import requests`` beside a helper the runner never reaches, and a
file-wide import block would reject strategies generated from them. The
compensating check is on the *use* along the executed path — but that check
matches dotted chains rooted in the module's own name, so a binding that
renames it slipped past both::
from socket import socket as S -> S()
import socket as sk -> sk.socket()
from src.trading.service import place_order -> place_order(1)
This recovers the binding so the use site can be judged by what the name
actually refers to, which closes the aliasing half of the VT-001 residual
without touching the harmless unused import.
Args:
tree: Parsed signal engine module.
Returns:
Bound name -> the dotted path it refers to, for forbidden targets only.
"""
aliases: dict[str, str] = {}
for node in tree.body:
if isinstance(node, ast.Import):
# A plain ``import socket`` needs no entry: its use is a dotted
# chain rooted in ``socket``, which the attribute check already
# rejects. Only a rename hides that root.
for alias in node.names:
if alias.asname and _is_forbidden_module_path(alias.name):
aliases[alias.asname] = alias.name
elif isinstance(node, ast.ImportFrom):
module = node.module or ""
for alias in node.names:
target = f"{module}.{alias.name}" if module else alias.name
if (
_is_forbidden_module_path(module)
or _is_forbidden_module_path(target)
or (module.split(".")[0] == "os" and _is_forbidden_os_attr(alias.name))
):
aliases[alias.asname or alias.name] = target
return aliases
def _scan_runtime_reachable(tree: ast.Module) -> None:
"""Reject forbidden ops in ``SignalEngine`` methods + their transitive callees.
Entry points are every method defined directly on the ``SignalEngine`` class.
From there, any bare-name call that resolves to a module-level function is
followed and scanned too, so a payload hidden in a helper that ``generate()``
calls is still caught. Module-level functions never reached from a
``SignalEngine`` method (standalone data-fetch helpers, ``__main__`` demos)
are intentionally left unscanned.
"""
engine_cls = next(
(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "SignalEngine"),
None,
)
if engine_cls is None:
return
module_funcs = {
n.name: n
for n in tree.body
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
}
worklist: list[ast.AST] = [
m for m in engine_cls.body if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef))
]
aliases = _module_level_forbidden_aliases(tree)
visited: set[int] = set()
while worklist:
fn = worklist.pop()
if id(fn) in visited:
continue
visited.add(id(fn))
for node in ast.walk(fn):
_reject_forbidden_node(node)
if isinstance(node, ast.Name) and node.id in aliases:
raise ValueError(
f"Use of {node.id!r}, bound at module level to "
f"{aliases[node.id]!r}, {_SCRUB_MSG}"
)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
target = module_funcs.get(node.func.id)
if target is not None:
worklist.append(target)
def _validate_signal_engine_source(file_path: Path) -> None:
"""Reject import-time executable statements before loading signal_engine.py."""
try:
tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
except SyntaxError as exc:
raise ValueError(f"Invalid signal_engine.py syntax: {exc}") from exc
for node in tree.body:
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant):
continue
if isinstance(node, ast.ImportFrom) and node.module == "signal_engine":
raise ValueError(
"Circular import: 'from signal_engine import ...' imports the file from itself. "
"Remove this import — SignalEngine is defined in this same file."
)
if isinstance(node, (ast.Import, ast.ImportFrom)):
continue
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
_validate_function_def(node)
continue
if isinstance(node, ast.ClassDef):
_validate_class_body(node)
continue
if _is_safe_constant_assignment(node):
continue
raise ValueError(
f"Executable top-level statement {type(node).__name__} is not allowed"
)
# Deep pass: the structural loop above only guards import-time execution;
# this walks the code that runs on SignalEngine().generate() (VT-001).
_scan_runtime_reachable(tree)
def _validate_signal_engine_class(engine_cls) -> None:
"""Pre-flight check: SignalEngine can be instantiated with no args and has generate()."""
sig = inspect.signature(engine_cls.__init__)
required = [
p.name for p in sig.parameters.values()
if p.name != "self" and p.default is inspect.Parameter.empty
and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
]
if required:
raise ValueError(
f"SignalEngine.__init__() has required arguments {required}. "
"All parameters must have default values so the runner can call SignalEngine()."
)
if not callable(getattr(engine_cls, "generate", None)):
raise ValueError(
"SignalEngine must have a callable 'generate' method. "
"Expected: def generate(self, data_map: Dict[str, pd.DataFrame]) -> Dict[str, pd.Series]"
)
# --- Market detection ---
# ``_MARKET_PATTERNS``, ``_detect_market``, ``_is_china_futures``,
# ``_detect_submarket`` are imported from ``_market_hooks`` above and
# re-exported here for back-compat (swarm/grounding.py, tests).
# Back-compat: market type -> legacy source name (for engine selection & metrics)
_MARKET_TO_SOURCE = {
"a_share": "tushare",
"us_equity": "yfinance",
"hk_equity": "yfinance",
"india_equity": "yahoo",
"kr_equity": "pykrx",
"ca_equity": "yahoo",
"ar_equity": "yahoo",
"br_equity": "yahoo",
"uk_equity": "yahoo",
"vietnam_equity": "yahoo",
"crypto": "okx",
"futures": "tushare",
"fund": "tushare",
"macro": "akshare",
"forex": "akshare",
"index": "yahoo",
}
def _detect_source(code: str) -> str:
"""Infer legacy source name from symbol (back-compat for metrics/engine).
Args:
code: Ticker / symbol string.
Returns:
Source name (tushare/okx/yfinance/akshare).
"""
market = _detect_market(code)
return _MARKET_TO_SOURCE.get(market, "tushare")
def _group_codes_by_market(codes: List[str]) -> Dict[str, List[str]]:
"""Group symbols by detected market type.
Args:
codes: List of symbol strings.
Returns:
Mapping market_type -> list of codes.
"""
groups: Dict[str, List[str]] = {}
for code in codes:
market = _detect_market(code)
groups.setdefault(market, []).append(code)
return groups
def _group_codes_by_source(codes: List[str]) -> Dict[str, List[str]]:
"""Group symbols by inferred source (back-compat).
Args:
codes: List of symbol strings.
Returns:
Mapping source -> list of codes.
"""
groups: Dict[str, List[str]] = {}
for code in codes:
src = _detect_source(code)
groups.setdefault(src, []).append(code)
return groups
def _get_loader(source: str):
"""Return a DataLoader class for a source name, with fallback.
Args:
source: Source name (tushare/okx/yfinance/akshare/ccxt).
Returns:
DataLoader class.
"""
try:
return get_loader_cls_with_fallback(source)
except NoAvailableSourceError:
# Ultimate fallback for unknown sources
if "tushare" in LOADER_REGISTRY:
return LOADER_REGISTRY["tushare"]
raise
def _normalize_codes(codes: List[str], source: str) -> List[str]:
"""Normalize symbol strings for a source.
Args:
codes: Raw code list.
source: Data source.
Returns:
Normalized codes.
"""
if source in ("okx", "ccxt"):
return [c.replace("/", "-").upper() for c in codes]
return codes
def _restore_original_codes(
data_map: dict,
original_codes: List[str],
normalized_codes: List[str],
) -> dict:
"""Map provider-normalized result keys back to requested symbols."""
aliases = dict(zip(normalized_codes, original_codes))
return {aliases.get(code, code): frame for code, frame in data_map.items()}
def _columns_required_from_factor_spec(spec: Any) -> list[str]:
"""Extract ``columns_required`` from supported factor spec shapes.
Args:
spec: Factor metadata as a dict, an Alpha-like object with ``meta``, an
object with ``columns_required``, or a raw string alpha id.
Returns:
Declared panel columns, or an empty list when the shape has none.
"""
if isinstance(spec, dict):
meta = spec.get("meta")
if isinstance(meta, dict):
return [str(c) for c in meta.get("columns_required", [])]
return [str(c) for c in spec.get("columns_required", [])]
meta = getattr(spec, "meta", None)
if isinstance(meta, dict):
return [str(c) for c in meta.get("columns_required", [])]
columns = getattr(spec, "columns_required", None)
if columns is not None:
return [str(c) for c in columns]
return []
def _selected_factor_specs(config: dict) -> list[Any]:
"""Return selected factor metadata configured for the run.
The current runner has no dedicated factor-zoo execution path, so this
accepts the shapes used by callers that already know factor metadata
(``selected_factors``/``factors``/``alpha_metas``) and alpha-id lists that
can be resolved through the registry.
Args:
config: Backtest config.
Returns:
Factor specs or metadata dictionaries.
"""
specs: list[Any] = []
for key in ("selected_factors", "factors", "alpha_metas"):
raw = config.get(key)
if isinstance(raw, list):
specs.extend(raw)
elif raw:
specs.append(raw)
alpha_ids: list[str] = []
for key in ("alpha_ids", "factor_ids", "alphas"):
raw = config.get(key)
if raw is None:
continue
if isinstance(raw, str):
alpha_ids.append(raw)
else:
alpha_ids.extend(str(item) for item in raw)
if alpha_ids:
from src.factors.registry import get_default_registry
registry = get_default_registry()
for alpha_id in alpha_ids:
try:
specs.append(registry.get(alpha_id).meta)
except KeyError:
logger.warning("selected alpha_id %r is not registered; skipping", alpha_id)
return specs
def _fund_columns_required(selected_factors: Iterable[Any]) -> list[str]:
"""Collect requested ``fund:*`` panel columns from selected factors.
Args:
selected_factors: Factor metadata/spec objects.
Returns:
Stable, de-duplicated ``fund:*`` column names.
"""
seen: set[str] = set()
out: list[str] = []
for spec in selected_factors:
for column in _columns_required_from_factor_spec(spec):
if not column.startswith(_FUND_PREFIX) or column in seen:
continue
seen.add(column)
out.append(column)
return out
def _val_columns_required(selected_factors: Iterable[Any]) -> list[str]:
"""Collect requested ``val:*`` panel columns from selected factors.
Args:
selected_factors: Factor metadata/spec objects.
Returns:
Stable, de-duplicated ``val:*`` column names.
"""
seen: set[str] = set()
out: list[str] = []
for spec in selected_factors:
for column in _columns_required_from_factor_spec(spec):
if not column.startswith(_VAL_PREFIX) or column in seen:
continue
seen.add(column)
out.append(column)
return out
def _build_price_panel(data_map: dict[str, pd.DataFrame]) -> dict[str, pd.DataFrame]:
"""Convert ``code -> OHLCV frame`` rows into the factor panel shape.
Args:
data_map: Backtest loader output.
Returns:
``column -> dates x symbols`` panel for price columns present in the
data map.
"""
panel: dict[str, pd.DataFrame] = {}
for column in _PRICE_PANEL_COLUMNS:
series_by_symbol = {
symbol: frame[column]
for symbol, frame in data_map.items()
if column in frame.columns
}
if series_by_symbol:
panel[column] = pd.DataFrame(series_by_symbol)
return panel
def _nan_fundamental_frame(
index: pd.DatetimeIndex,
symbols: list[str],
) -> pd.DataFrame:
"""Build an all-NaN fundamental panel frame."""
return pd.DataFrame(float("nan"), index=index, columns=symbols)
def _inject_fundamental_panel(
panel: dict[str, pd.DataFrame],
*,
symbols: list[str],
fund_columns: Iterable[str],
start: str,
end: str,
freq: str = "ttm",
pit: bool = True,
source: str = "auto",
index: pd.DatetimeIndex | None = None,
) -> dict[str, pd.DataFrame]:
"""Inject required fundamental fields into a factor panel.
Args:
panel: Existing factor panel keyed by column name.
symbols: Symbols to load.
fund_columns: Requested ``fund:*`` columns.
start: Start date string.
end: End date string.
freq: Fundamental frequency. Defaults to ``ttm``.
pit: Whether point-in-time loading is enforced.
source: Fundamental data source route.
index: Optional price index to align to. Defaults to ``panel["close"]``.
Returns:
The same panel dictionary with ``fund:<field>`` frames added.
"""
fields = [column[len(_FUND_PREFIX):] for column in fund_columns if column.startswith(_FUND_PREFIX)]
fields = list(dict.fromkeys(fields))
if not fields:
return panel
price_index = index
if price_index is None:
close = panel.get("close")
price_index = close.index if close is not None else pd.DatetimeIndex([])
try:
from backtest.loaders.fundamentals_loader import load_fundamental_panel
loaded = load_fundamental_panel(
symbols=symbols,
fields=fields,
start=start,
end=end,
freq=freq,
pit=pit,
source=source,
index=price_index,
)
except Exception as exc: # noqa: BLE001 - data-source failure must not kill backtest
logger.warning(
"fundamental panel load failed for fields=%s symbols=%s: %s; injecting NaN frames",
fields,
symbols,
exc,
exc_info=True,
)
loaded = {}
for field in fields:
frame = loaded.get(field)
if not isinstance(frame, pd.DataFrame):
frame = _nan_fundamental_frame(price_index, symbols)
else:
frame = frame.reindex(index=price_index, columns=symbols)
panel[f"{_FUND_PREFIX}{field}"] = frame
return panel
def _inject_valuation_panel(
panel: dict[str, pd.DataFrame],
*,
symbols: list[str],
val_columns: Iterable[str],
start: str,
end: str,
index: pd.DatetimeIndex | None = None,
) -> dict[str, pd.DataFrame]:
"""Inject required ``val:*`` daily valuation fields into a factor panel.
Source is tushare ``daily_basic`` (CN A-share only): daily market data
keyed by ``trade_date``, so PIT-safe by construction — no filing lag.
Non-CN symbols and missing dates stay NaN; nothing is fabricated, and
factors declaring ``val:`` columns SkipAlpha or NaN-out naturally where
there is no CN supply.
Args:
panel: Existing factor panel keyed by column name.
symbols: Symbols to load.
val_columns: Requested ``val:*`` columns.
start: Start date string.
end: End date string.
index: Optional price index to align to. Defaults to ``panel["close"]``.
Returns:
The same panel dictionary with ``val:<field>`` frames added.
Raises:
ValueError: If a declared ``val:`` field is outside the daily_basic
whitelist (pe, pe_ttm, pb, ps_ttm, dv_ttm).
"""
fields = [column[len(_VAL_PREFIX):] for column in val_columns if column.startswith(_VAL_PREFIX)]
fields = list(dict.fromkeys(fields))
if not fields:
return panel
from backtest.loaders.valuation_loader import (
load_valuation_panel,
validate_valuation_fields,
)
# Whitelist rejection is a caller-fixable contract error, so it must
# propagate rather than degrade into NaN frames like a provider outage.
fields = validate_valuation_fields(fields)
price_index = index
if price_index is None:
close = panel.get("close")
price_index = close.index if close is not None else pd.DatetimeIndex([])
try:
loaded = load_valuation_panel(
symbols=symbols,
fields=fields,
start=start,
end=end,
index=price_index,
)
except Exception as exc: # noqa: BLE001 - data-source failure must not kill backtest
logger.warning(
"valuation panel load failed for fields=%s symbols=%s: %s; injecting NaN frames",
fields,
symbols,
exc,
exc_info=True,
)
loaded = {}
for field in fields:
frame = loaded.get(field)
if not isinstance(frame, pd.DataFrame):
frame = _nan_fundamental_frame(price_index, symbols)
else:
frame = frame.reindex(index=price_index, columns=symbols)
panel[f"{_VAL_PREFIX}{field}"] = frame
return panel
def _project_panel_fields_to_data_map(
data_map: dict[str, pd.DataFrame],
panel: dict[str, pd.DataFrame],
fund_columns: Iterable[str],
) -> dict[str, pd.DataFrame]:
"""Copy injected panel fields back to per-symbol backtest frames."""
out = {symbol: frame.copy() for symbol, frame in data_map.items()}
for column in fund_columns:
frame = panel.get(column)
if frame is None:
continue
for symbol, symbol_frame in out.items():
if symbol in frame.columns:
symbol_frame[column] = frame[symbol].reindex(symbol_frame.index)
else:
symbol_frame[column] = float("nan")
return out
def _maybe_inject_fundamentals_for_factor_panel(
data_map: dict[str, pd.DataFrame],
config: dict,
) -> dict[str, pd.DataFrame]:
"""Inject ``fund:*``/``val:*`` factor dependencies when selected factors request them."""
selected_factors = _selected_factor_specs(config)
fund_columns = _fund_columns_required(selected_factors)
val_columns = _val_columns_required(selected_factors)
if not fund_columns and not val_columns:
return data_map
panel = _build_price_panel(data_map)
close = panel.get("close")
price_index = close.index if close is not None else pd.DatetimeIndex([])
symbols = list(data_map)
if fund_columns:
_inject_fundamental_panel(
panel,
symbols=symbols,
fund_columns=fund_columns,
start=config.get("start_date", ""),
end=config.get("end_date", ""),
freq="ttm",
pit=True,
source="auto",
index=price_index,
)
if val_columns:
_inject_valuation_panel(
panel,
symbols=symbols,
val_columns=val_columns,
start=config.get("start_date", ""),
end=config.get("end_date", ""),
index=price_index,
)
return _project_panel_fields_to_data_map(
data_map, panel, [*fund_columns, *val_columns]
)
# --- Main entry ---
def main(run_dir: Path) -> None:
"""Load config, fetch data, run the selected backtest engine.
With ``source="auto"``, routes each code through the appropriate loader.
Args:
run_dir: Run directory containing ``config.json`` and ``code/signal_engine.py``.
The path is validated against the allowed run roots
(``VIBE_TRADING_ALLOWED_RUN_ROOTS`` plus the defaults) before any
file is read so an arbitrary filesystem location cannot be used
to source ``code/signal_engine.py``.
"""
# Loading `.env` belongs to the process that RUNS a backtest, not to
# importing this module. At import time it ran during pytest collection --
# before the per-test os.environ snapshot exists -- so a developer's own
# LANGCHAIN_MODEL_NAME leaked into every later test and could not be undone
# by any fixture, which is how seven redaction tests failed locally while
# CI (with no .env checked out) stayed green.
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
# Guard the CLI entry point with the same root whitelist the MCP
# ``backtest`` tool already uses (src/tools/backtest_tool.py:23). Without
# this, ``python -m backtest.runner /tmp/attacker_path`` would happily
# import ``signal_engine.py`` from anywhere on disk; the AST scrubber
# below blocks executable top-level statements but a method body still
# runs on instantiation. See ``safe_run_dir`` for the policy.
from src.tools.path_utils import safe_run_dir
try:
run_dir = safe_run_dir(str(run_dir))
except ValueError as exc:
print(json.dumps({"error": str(exc)}))
sys.exit(1)
config_path = run_dir / "config.json"
if not config_path.exists():
print(json.dumps({"error": "config.json not found"}))
sys.exit(1)
raw_config = json.loads(config_path.read_text(encoding="utf-8"))
# Validate config schema
try:
BacktestConfigSchema(**raw_config)
except Exception as exc:
errors = str(exc)
print(json.dumps({"error": f"Invalid config: {errors}"}))
sys.exit(1)
config = raw_config
source = config.get("source", "tushare")
codes = config.get("codes", [])
# Load signal engine
signal_path = run_dir / "code" / "signal_engine.py"
if not signal_path.exists():
print(json.dumps({"error": "code/signal_engine.py not found"}))
sys.exit(1)
try:
signal_module = _load_module_from_file(signal_path, "signal_engine")
except ValueError as exc:
# Source-level AST validation (circular self-import, unsafe imports,
# decorators, top-level statements) raises ValueError. Surface it as a
# clean JSON envelope instead of a raw traceback so the agent gets an
# actionable message.
print(json.dumps({"error": f"SignalEngine source error: {exc}"}))
sys.exit(1)
engine_cls = getattr(signal_module, "SignalEngine", None)
if engine_cls is None:
print(json.dumps({"error": "SignalEngine class not found in signal_engine.py"}))
sys.exit(1)
try:
_validate_signal_engine_class(engine_cls)
except ValueError as exc:
print(json.dumps({"error": f"SignalEngine interface error: {exc}"}))
sys.exit(1)
fetch_result = fetch_data_map(config)
data_map = fetch_result.data_map
codes = fetch_result.codes
source = fetch_result.source
loader = fetch_result.loader
config["codes"] = codes
config["_run_card_effective_sources"] = fetch_result.effective_sources
if fetch_result.caliber_warning:
config["_run_card_caliber_warning"] = fetch_result.caliber_warning
interval = config.get("interval", "1D")
if not data_map:
print(json.dumps({"error": "No data fetched"}))
sys.exit(1)
data_map = _maybe_inject_fundamentals_for_factor_panel(data_map, config)
# Engine
engine_type = config.get("engine", "daily")
signal_engine = engine_cls()
# Annualization bars
effective_source = _detect_primary_source(codes, source)
# Cross-market: use calendar-day annualization (bars_per_year=None)
market_types = {_detect_market(c) for c in codes}
if len(market_types) > 1:
bars_per_year = None
else:
annualisation_warnings: list[str] = []
bars_per_year = _annualisation_bars(
interval, effective_source, data_map, codes, warnings=annualisation_warnings
)
if annualisation_warnings:
config["_run_card_annualisation_warning"] = annualisation_warnings[0]
# Every source has already been fetched, sanitized, and enriched above.
# Reuse that exact snapshot so provider costs and run-card provenance stay
# aligned with the data consumed by the engine. A source that prohibits
# fallback keeps its own loader behind the snapshot: a declared benchmark
# is usually outside ``codes``, but must still come from that same source.
if is_no_network_fallback_source(source):
loader = _AutoLoader(
data_map,
inner=loader,
name=str(getattr(loader, "name", None) or source),
)
else:
loader = _AutoLoader(data_map)
if engine_type == "options":
from backtest.engines.options_portfolio import run_options_backtest
run_options_backtest(config, loader, signal_engine, run_dir, bars_per_year=bars_per_year)
else:
market_engine = _create_market_engine(effective_source, config, codes)
market_engine.run_backtest(config, loader, signal_engine, run_dir, bars_per_year=bars_per_year)
#: Bar spacing, in seconds, of every interval the runner accepts
#: (:data:`_VALID_INTERVALS`). Seconds rather than ``Timedelta`` so the
#: comparison below is a ratio of two numbers.
_INTERVAL_SECONDS: dict[str, float] = {
"1m": 60.0,
"5m": 300.0,
"15m": 900.0,
"30m": 1_800.0,
"1H": 3_600.0,
"4H": 14_400.0,
"1D": 86_400.0,
"1W": 604_800.0,
# A mean month (365.25 / 12 days): a calendar month runs 28 to 31, so a
# monthly series' median spacing sits within 1.1x of it either way.
"1M": 2_629_800.0,
}
#: How far the served bar spacing may sit from the declared interval's spacing
#: before the declaration is treated as wrong. The closest pair of intervals
#: differs by 2x (``30m`` -> ``1H``), while a correctly served series measures
#: its own spacing exactly, so 1.5 separates the two cases with room to spare.
_SPACING_MISMATCH_RATIO = 1.5
#: Fewest bars whose median spacing is trustworthy. Sessions leave gaps -- a
#: daily series jumps three days over a weekend -- and the median only absorbs
#: them once they are outnumbered. Three differences survive one gap
#: (``[1, 1, 3]`` -> 1 day); two do not (``[1, 3]`` -> 2 days).
_MIN_BARS_FOR_SPACING = 4
#: Spacing at least this many times a day that matches no supported interval
#: (a fortnightly or quarterly file) is a coarse series, not a daily one with
#: gaps: a daily index over a holiday week measures a median of two or three
#: days. Such a series has no trading-day table to look up and needs none --
#: its bars per year is the calendar's.
_WIDER_THAN_DAILY_RATIO = 4.0
_CALENDAR_YEAR_SECONDS = 365.25 * 86_400.0
def _observed_spacing(data_map: dict, codes: List[str]) -> float | None:
"""Median spacing of the served price bars in seconds, or None when
unmeasurable.
The median, not the span: a session index is mostly regular with occasional
gaps (weekends, overnight, a trading halt), and the median reports the
regular part. Only the instrument codes are measured, so an injected
fundamental panel cannot decide the annualisation.
"""
indexes = [
data_map[code].index
for code in codes
if code in data_map and len(data_map[code]) >= _MIN_BARS_FOR_SPACING
]
if not indexes:
return None
index = max(indexes, key=len)
spacing = pd.Series(index).diff().dropna().median()
if pd.isna(spacing):
return None
seconds = spacing.total_seconds()
return seconds if seconds > 0 else None
def _annualisation_bars(
interval: str,
source: str,
data_map: dict,
codes: List[str],
warnings: list[str] | None = None,
) -> int:
"""Bars per year for a single-market run, checked against the served bars.
``interval`` is what the caller asked for, not a fact about what arrived. A
loader may legitimately serve coarser bars than requested -- the local
loader cannot upsample a daily file to ``1H`` and says so only in a log
warning -- and annualising at the declared rate then scales CAGR, Sharpe and
the annualised volatility by the ratio between the two.
The comparison is on bar **spacing**, not on bars per calendar year. A
calendar-year count is a property of the window as much as of the data:
weekends and overnight gaps dominate a short span, so five daily bars
starting on a Monday measure 456 against a declared 252 while the same five
bars starting on a Tuesday measure 304 -- one trips a 1.5 gate and the other
does not, for the same correctly served series. Median spacing is one day
for a daily series whether the window holds five bars or five years, and one
hour for hourly bars regardless of session length or 24x7 trading.
On a mismatch the count still comes from
:func:`~backtest.metrics.calc_bars_per_year` -- looked up with the interval
the spacing actually matches -- so the per-source trading-day table keeps
producing the number and a run card never picks up a window-dependent one.
A weekly or monthly file declared ``1D`` matches ``1W`` / ``1M`` (52 / 12).
Bars spaced wider than daily that match no supported interval (a quarterly
file) are annualised from the calendar instead, 4 for a quarterly series; a
spacing that matches nothing in either direction keeps the declaration.
Args:
interval: Bar size the caller declared.
source: Primary source name, for the per-source trading-day table.
data_map: Fetched ``code -> frame`` map.
codes: The instrument codes.
warnings: When given, a mismatch report is appended here as well as
logged, so the run card carries it next to the caliber warning.
Returns:
Bars per year for the declared interval; for the interval the served
spacing matches when the two disagree; or the calendar count of a
spacing wider than every supported interval.
"""
from backtest.metrics import _normalize_interval, calc_bars_per_year
def _report(message: str) -> None:
logger.warning("%s", message)
if warnings is not None:
warnings.append(message)
declared = calc_bars_per_year(interval, source)
declared_spacing = _INTERVAL_SECONDS.get(_normalize_interval(interval))
observed = _observed_spacing(data_map, codes)
if declared_spacing is None or observed is None:
return declared
if max(declared_spacing, observed) / min(declared_spacing, observed) < _SPACING_MISMATCH_RATIO:
return declared
matched = min(
_INTERVAL_SECONDS,
key=lambda name: max(_INTERVAL_SECONDS[name], observed)
/ min(_INTERVAL_SECONDS[name], observed),
)
matched_spacing = _INTERVAL_SECONDS[matched]
if max(matched_spacing, observed) / min(matched_spacing, observed) >= _SPACING_MISMATCH_RATIO:
if observed >= _WIDER_THAN_DAILY_RATIO * _INTERVAL_SECONDS["1D"]:
calendar_bars = max(1, round(_CALENDAR_YEAR_SECONDS / observed))
_report(
f"interval={interval} declares bars spaced {declared_spacing:.0f}s but the "
f"served data is spaced {observed:.0f}s, wider than any supported interval; "
f"annualising at {calendar_bars} bars/year from that spacing instead of "
f"{declared}."
)
return calendar_bars
# Between two supported intervals, or finer than a minute: no count to
# look up, so the declaration stands. A daily series over a holiday
# week lands here with a two-day median, so the report states the
# spacings and nothing more.
_report(
f"interval={interval} declares bars spaced {declared_spacing:.0f}s but the "
f"served data is spaced {observed:.0f}s, which matches no supported interval; "
f"annualising at the declared rate ({declared} bars/year)."
)
return declared
resolved = calc_bars_per_year(matched, source)
_report(
f"interval={interval} declares bars spaced {declared_spacing:.0f}s but the served "
f"data is spaced {observed:.0f}s; annualising as {matched} ({resolved} bars/year) "
f"instead of {declared}. The loader served bars coarser or finer than requested; "
f"set interval to the granularity the source has if that was not intended."
)
return resolved
def _create_market_engine(source: str, config: dict, codes: List[str]):
"""Create the appropriate market engine based on data source and market type.
Routing priority:
1. Detect market type from symbol patterns (futures, forex, etc.)
2. Fall back to source-based routing (okx->crypto, tushare->china_a, etc.)
Args:
source: Data source (okx/ccxt/tushare/akshare/yfinance).
config: Backtest configuration.
codes: Instrument codes.
Returns:
BaseEngine subclass instance.
"""
# Detect dominant market type from codes
markets = {_detect_market(c) for c in codes} if codes else set()
# The Hong Kong pool books in HKD. HKEX numbers its RMB and USD counters
# in their own code ranges, and no source in the hk_equity chain but Yahoo
# declares a currency, so the code decides before any engine prices them.
foreign_counters = sorted(
f"{c} ({hk_counter_currency(c)})"
for c in codes
if _detect_market(c) == "hk_equity" and hk_counter_currency(c) not in (None, "HKD")
)
if foreign_counters:
raise ValueError(
"Hong Kong backtests book in HKD, but these codes are HKEX counters traded "
f"in another currency: {', '.join(foreign_counters)}. Use the HKD-traded "
"counter of the same security instead."
)
# Cross-market -> CompositeEngine
if len(markets) > 1:
from backtest.engines.composite import CompositeEngine
return CompositeEngine(config, codes)
# Futures routing (Wave 2)
if "futures" in markets:
# Distinguish China vs global futures by exchange suffix
if any(_is_china_futures(c) for c in codes):
from backtest.engines.china_futures import ChinaFuturesEngine
return ChinaFuturesEngine(config)
from backtest.engines.global_futures import GlobalFuturesEngine
return GlobalFuturesEngine(config)
# Forex routing (Wave 2)
if "forex" in markets:
from backtest.engines.forex import ForexEngine
return ForexEngine(config)
# India equity routing — must precede source-based routing because India's
# effective source is ``yahoo``, which has no Wave-1 branch and would
# otherwise fall through to the crypto default.
if "india_equity" in markets:
from backtest.engines.india_equity import IndiaEquityEngine
return IndiaEquityEngine(config)
# Korea equity routing — same reason as India: its effective source
# (``pykrx``) has no Wave-1 branch and would fall through to the default.
if "kr_equity" in markets:
from backtest.engines.korea_equity import KoreaEquityEngine
return KoreaEquityEngine(config)
# Vietnam equity routing — same reason as India and Korea: its effective
# source (``yahoo``) has no Wave-1 branch and would fall through to the
# default.
if "vietnam_equity" in markets:
from backtest.engines.vietnam_equity import VietnamEquityEngine
return VietnamEquityEngine(config)
# Brazil (B3) routing — same reason as India: ``yahoo`` has no Wave-1
# branch and would otherwise fall through to the crypto default.
if "br_equity" in markets:
from backtest.engines.brazil_equity import BrazilEquityEngine
return BrazilEquityEngine(config)
# Argentina market-data routing is supported, but BYMA execution rules
# are not modeled yet. Fail closed instead of silently applying US/crypto
# commissions, lot sizes, settlement, or short-selling assumptions.
if "ar_equity" in markets:
raise ValueError(
"Argentina .BA market data is supported, but Argentina backtest "
"execution rules are not modeled yet"
)
# Index symbols (^SPX, ^FTSE, ...) — priced like a US/global-listed
# instrument (GlobalEquityEngine, US rules) and never the China/crypto
# default the source-based fallback would pick.
if "index" in markets:
from backtest.engines.global_equity import GlobalEquityEngine
return GlobalEquityEngine(config, market=_detect_submarket(codes))
# Original routing (Wave 1)
if source in ("okx", "ccxt"):
from backtest.engines.crypto import CryptoEngine
return CryptoEngine(config)
elif source in ("tushare", "akshare"):
if markets & {"us_equity", "hk_equity", "ca_equity", "uk_equity"}:
from backtest.engines.global_equity import GlobalEquityEngine
market = _detect_submarket(codes)
return GlobalEquityEngine(config, market=market)
from backtest.engines.china_a import ChinaAEngine
return ChinaAEngine(config)
elif source == "yfinance":
# yfinance serves crypto pairs (BTC-USDT, BTC-USD) next to equities,
# so route on the instrument market here too. Handing crypto to
# GlobalEquityEngine applies zero-commission equity rules while the
# CryptoEngine fee keys (taker_rate/maker_rate/slippage) sit ignored
# in the config, and nothing warns.
if "crypto" in markets:
from backtest.engines.crypto import CryptoEngine
return CryptoEngine(config)
from backtest.engines.global_equity import GlobalEquityEngine
market = _detect_submarket(codes)
return GlobalEquityEngine(config, market=market)
else:
# Sources without a dedicated branch (local, stooq, tencent, ...):
# follow the instrument market rather than the loader name, so e.g. a
# local AAPL.US dataset gets US-equity execution rules instead of crypto.
if markets & {"us_equity", "hk_equity", "ca_equity", "uk_equity"}:
from backtest.engines.global_equity import GlobalEquityEngine
market = _detect_submarket(codes)
return GlobalEquityEngine(config, market=market)
# A-shares need the same treatment. Every branchless source that serves
# them -- local, tencent, eastmoney, baostock, mootdx, sina -- used to
# land here and fall through to the crypto default, which applies none
# of the A-share rules (stamp tax, T+1, price limits, 100-share lots)
# and does charge an 8-hourly perpetual funding fee against the
# position. The run still succeeds, which is what makes it dangerous.
if "a_share" in markets:
from backtest.engines.china_a import ChinaAEngine
return ChinaAEngine(config)
from backtest.engines.crypto import CryptoEngine
return CryptoEngine(config)
def _detect_primary_source(codes: List[str], source: str) -> str:
"""Pick primary source for annualization (e.g. bars per year).
Args:
codes: All symbols.
source: Config ``source`` field.
Returns:
Dominant source name.
"""
if source != "auto":
return source
groups = _group_codes_by_source(codes)
if len(groups) == 1:
return list(groups.keys())[0]
# Mixed: use the source with the most symbols
return max(groups, key=lambda s: len(groups[s]))
def _fetch_auto(codes: List[str], config: dict, interval: str = "1D") -> dict:
"""Auto mode: route each market group through fallback chain.
Args:
codes: All symbols.
config: Backtest config dict. On success the loaders that actually
returned rows are recorded under the private key
``_actual_sources`` so the caller can report true provenance
instead of the symbol-pattern guess (``_group_codes_by_source``
names the *head* of each chain, which lies whenever that head is an
optional package that is not installed).
interval: Bar interval string.
Returns:
Merged ``code -> DataFrame`` map, keyed by bare symbol: a ``local:``
code is served only by the local loader and keyed without the prefix.
"""
merged = {}
served_by: set[str] = set()
caliber_stamps: dict[str, tuple[str, str]] = {}
start_date = config.get("start_date", "")
end_date = config.get("end_date", "")
# A ``local:`` code names the user's own dataset. It is served by the local
# loader or not at all: routing it by market would send ``local:AAPL.US``
# down the US network chain, which the README promises never happens (#1467).
local_codes = [code for code in codes if strip_local_prefix(code) != code]
if local_codes:
# ``local`` never degrades to a network loader; an unconfigured Data
# Bridge raises here with its own hint.
local_loader = get_loader_cls_with_fallback("local")()
stripped = [strip_local_prefix(code) for code in local_codes]
local_result = local_loader.fetch(stripped, start_date, end_date, interval=interval)
missing_local = [code for code in local_codes if strip_local_prefix(code) not in local_result]
if missing_local:
raise NoAvailableSourceError(
f"incomplete data for source=local; missing symbols: {missing_local}"
)
local_name = str(getattr(local_loader, "name", "local") or "local")
served_by.add(local_name)
for code in local_result:
caliber_stamps[code] = (
local_name,
frame_caliber(local_result[code], local_name, _detect_market(code), code),
)
merged.update(local_result)
market_groups = _group_codes_by_market([code for code in codes if code not in set(local_codes)])
for market, market_codes in market_groups.items():
try:
loader = resolve_loader(market)
except NoAvailableSourceError as exc:
# Fallback: try legacy source mapping
legacy_src = _MARKET_TO_SOURCE.get(market, "tushare")
logger.warning("Fallback chain failed for %s: %s — trying %s", market, exc, legacy_src)
LoaderCls = _get_loader(legacy_src)
loader = LoaderCls()
src_name = getattr(loader, "name", "unknown")
normalized_codes = _normalize_codes(market_codes, src_name)
fields = config.get("extra_fields") if src_name == "tushare" else None
result = loader.fetch(
normalized_codes,
start_date,
end_date,
fields=fields,
interval=interval,
)
market_result = _restore_original_codes(
result, market_codes, normalized_codes
)
if market_result:
served_by.add(src_name)
for code in market_result:
caliber_stamps[code] = (src_name, frame_caliber(market_result[code], src_name, market, code))
missing = [code for code in market_codes if code not in market_result]
# Retry only missing symbols so a partial primary response does not
# silently shrink the requested universe or refetch successful data.
for fb_name in FALLBACK_CHAINS.get(market, []):
if not missing:
break
if fb_name == src_name or fb_name not in LOADER_REGISTRY:
continue
fb_loader = LOADER_REGISTRY[fb_name]()
if not fb_loader.is_available():
continue
fb_codes = _normalize_codes(missing, fb_name)
fallback_result = fb_loader.fetch(
fb_codes, start_date, end_date, interval=interval
)
mapped = _restore_original_codes(fallback_result, missing, fb_codes)
if mapped:
market_result.update(mapped)
missing = [code for code in missing if code not in mapped]
fb_served_by = str(getattr(fb_loader, "name", fb_name) or fb_name)
served_by.add(fb_served_by)
for code in mapped:
caliber_stamps[code] = (fb_served_by, frame_caliber(mapped[code], fb_served_by, market, code))
logger.info(
"Runtime fallback: %s -> %s for %s", src_name, fb_name, market
)
if missing:
served = sorted(set(merged) - set(missing))
raise NoAvailableSourceError(
f"incomplete data for {market}; missing symbols: {missing}; "
f"served symbols: {served}"
)
merged.update(market_result)
config["_actual_sources"] = sorted(served_by)
config["_caliber_stamps"] = caliber_stamps
return merged
def fetch_data_map(config: dict) -> DataFetchResult:
"""Fetch and sanitize bars through the canonical loader registry.
This is the shared entry point for backtest execution and reconstruction
consumers. It preserves auto routing and the runtime fallback chain.
Args:
config: Backtest configuration containing codes, dates, source, and interval.
Returns:
Data and effective routing metadata. The input config is not mutated.
A ``local:`` code comes back as its bare symbol in both ``codes`` and
``data_map``.
Raises:
ValueError: If a ``local:`` code is requested from a source other than
``local`` / ``auto``, or one symbol is requested both with and
without the prefix.
NoAvailableSourceError: If a requested symbol cannot be served.
"""
config = copy.deepcopy(config)
source = str(config.get("source") or "tushare")
codes = list(config.get("codes") or [])
# Weekly and monthly bars are built from daily ones after the fetch, so
# every loader below is asked for daily bars (#1479).
requested_interval = str(config.get("interval") or "1D")
interval = source_interval(requested_interval)
# ``local:`` picks the loader; the instrument is the bare symbol. Everything
# downstream (engine, signals, artifacts, run card) sees the bare symbol, so
# a contradictory request is refused here rather than half-served.
prefixed = [code for code in codes if strip_local_prefix(code) != code]
if prefixed and source not in ("local", "auto"):
raise ValueError(
f"local: codes need source='local' or 'auto', not {source!r}: {prefixed}"
)
bare = [strip_local_prefix(code) for code in codes]
repeated = sorted({symbol for symbol in bare if bare.count(symbol) > 1})
if repeated:
raise ValueError(f"symbols requested more than once (with and without local:): {repeated}")
caliber_stamps: dict[str, tuple[str, str]] = {}
if source == "auto":
data_map = _fetch_auto(codes, config, interval)
codes = bare
loader: Any = _AutoLoader(data_map)
# Prefer the loaders that actually served rows; the symbol-pattern guess
# is only a fallback for a stubbed/patched fetcher that recorded nothing.
recorded = config.pop("_actual_sources", None)
caliber_stamps = config.pop("_caliber_stamps", None) or {}
used_sources: list[str] = [
str(name) for name in recorded or [] if str(name).strip()
] or sorted(_group_codes_by_source(codes))
else:
codes = _normalize_codes(codes, source)
primary_source = source
loader = _get_loader(source)()
# ``_get_loader`` may hand back a *different* loader when the requested
# one is unavailable (e.g. an optional package like pykrx is missing, so
# the kr_equity chain resolves to yahoo). Record who actually served the
# bars, never the name that was asked for — a run card that claims
# ``pykrx`` while Yahoo supplied the data is a provenance lie.
served_by = str(getattr(loader, "name", source) or source)
if served_by != source:
logger.warning(
"source=%s is unavailable; %s served this request",
source,
served_by,
)
data_map = loader.fetch(
codes,
config.get("start_date", ""),
config.get("end_date", ""),
fields=config.get("extra_fields") or None,
interval=interval,
)
# The local loader keys ``local:AAPL.US`` by ``AAPL.US``. Compare and
# continue with the bare symbol, or a served symbol is counted as
# missing and sent down a network chain (#1467).
codes = [strip_local_prefix(code) for code in codes]
for code in data_map:
caliber_stamps[code] = (
served_by,
frame_caliber(data_map[code], served_by, _detect_market(code), code),
)
used_sources = [served_by] if data_map else []
missing = [code for code in codes if code not in data_map]
# With the prefix stripped, a network chain could serve the bare symbol
# as if the dataset held it. A ``local:`` code is the dataset's or nothing.
unserved_local = [code for code in prefixed if strip_local_prefix(code) in missing]
if unserved_local:
raise NoAvailableSourceError(
f"incomplete data for source=local; missing symbols: {unserved_local}"
)
if missing:
logger.warning(
"source=%s returned data for %d/%d symbols; missing: %s",
source,
len(data_map),
len(codes),
missing,
)
# ``is_no_network_fallback_source`` means "an explicit request for this
# source must never silently degrade." It used to be checked only when
# a loader was unavailable as a whole; per-symbol gaps still got filled
# from a network source — a source="local" request could return half
# its rows from a snapshot and half from Tencent, leaving only two log
# lines as a trace. Callers want snapshot provenance, not a padded row
# count.
if missing and not is_no_network_fallback_source(primary_source):
missing_by_market: dict[str, list[str]] = {}
for code in missing:
missing_by_market.setdefault(_detect_market(code), []).append(code)
for market, market_missing in missing_by_market.items():
for fallback_source in FALLBACK_CHAINS.get(market, []):
if not market_missing:
break
if (
fallback_source == primary_source
or fallback_source not in LOADER_REGISTRY
):
continue
fallback_loader = LOADER_REGISTRY[fallback_source]()
if not fallback_loader.is_available():
continue
fallback_codes = _normalize_codes(market_missing, fallback_source)
fallback_result = fallback_loader.fetch(
fallback_codes,
config.get("start_date", ""),
config.get("end_date", ""),
interval=interval,
)
mapped = _restore_original_codes(
fallback_result, market_missing, fallback_codes
)
if mapped:
data_map.update(mapped)
market_missing = [code for code in market_missing if code not in mapped]
fb_served_by = str(
getattr(fallback_loader, "name", fallback_source)
or fallback_source
)
for code in mapped:
caliber_stamps[code] = (
fb_served_by,
frame_caliber(mapped[code], fb_served_by, market, code),
)
if not used_sources:
source = fb_served_by
loader = fallback_loader
used_sources.append(fb_served_by)
logger.info(
"Runtime fallback: %s -> %s", primary_source, fb_served_by
)
missing = [code for code in missing if code not in data_map]
if missing:
raise NoAvailableSourceError(
f"incomplete data for source={primary_source}; missing symbols: {missing}; "
f"served symbols: {sorted(data_map)}"
)
data_map = {
code: resample_bars(frame, requested_interval)
for code, frame in _sanitize_data_map(data_map).items()
}
caliber_stamps = {
code: stamp for code, stamp in caliber_stamps.items() if code in data_map
}
# Both warnings can apply at once (a tencent+baostock basket mixes calibers
# *and* serves an additive one), and each says something the other does not,
# so they are reported together rather than one shadowing the other.
caliber_warning = (
"\n".join(
warning
for warning in (
mixed_caliber_warning(caliber_stamps),
additive_caliber_warning(caliber_stamps),
)
if warning
)
or None
)
if caliber_warning:
logger.warning("%s", caliber_warning)
return DataFetchResult(
data_map=data_map,
codes=codes,
source=source,
loader=loader,
effective_sources=used_sources,
caliber_warning=caliber_warning,
)
def _sanitize_data_map(data_map: dict) -> dict:
"""Drop structurally-invalid OHLC bars from every fetched frame.
Each loader only drops NaN rows, so a bar that violates the OHLC
invariants (``high < low``, a non-positive price, or a high/low that fails
to bracket open/close) can still reach the backtest and surface as NaN/inf
metrics. Applying :func:`validate_ohlc` here — the single point every
fetched map converges through — guards every source uniformly (``auto``,
single-source, runtime fallback, and any future loader), so the per-loader
checks no longer have to be added one at a time.
Args:
data_map: ``code -> DataFrame`` map as returned by a loader fetch.
Returns:
The same mapping with each frame's invalid bars removed.
"""
return {code: validate_ohlc(frame) for code, frame in data_map.items()}
class _AutoLoader:
"""Loader adapter that returns a pre-fetched data map.
With an ``inner`` loader, symbols outside the snapshot are fetched through
it, and ``name`` reports that loader's source so benchmark resolution can
tell the adapter apart from a network loader swapped in by a fallback.
"""
def __init__(
self,
data_map: dict,
inner: Any = None,
name: str | None = None,
) -> None:
"""Wrap a fetched snapshot.
Args:
data_map: ``code -> DataFrame`` snapshot already consumed by the run.
inner: Loader that served the snapshot; consulted only for symbols
the snapshot does not hold. ``None`` serves the snapshot alone.
name: Source name of ``inner``. When ``None`` the adapter exposes no
``name``: a bare snapshot does not stand for any one source.
"""
self._data = data_map
self._inner = inner
if name is not None:
self.name = name
def fetch(
self,
codes: List[str],
start_date: str,
end_date: str,
fields: Optional[List[str]] = None,
interval: str = "1D",
) -> dict:
"""Return snapshot rows, fetching symbols it lacks through ``inner``.
Frames from ``inner`` go through the same OHLC sanitisation as the
snapshot, so a benchmark is held to the bar quality the strategy was.
Args:
codes: Requested symbols.
start_date: Window start, passed through to ``inner``.
end_date: Window end, passed through to ``inner``.
fields: Extra fields, passed through to ``inner``.
interval: Bar interval, passed through to ``inner``.
Returns:
``code -> DataFrame`` for every requested symbol that could be served.
"""
served = {c: df for c, df in self._data.items() if c in codes}
missing = [c for c in codes if c not in served]
if missing and self._inner is not None:
source = str(getattr(self, "name", "") or "")
requested = _normalize_codes(missing, source)
fetched = self._inner.fetch(
requested, start_date, end_date, fields=fields, interval=interval
)
if isinstance(fetched, dict):
restored = _restore_original_codes(fetched, missing, requested)
served.update(_sanitize_data_map(restored))
return served
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python -m backtest.runner <run_dir>")
sys.exit(1)
main(Path(sys.argv[1]))