nlalpha-backtest 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ """nlalpha-backtest: Backtest execution MCP tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import version as _distribution_version
6
+
7
+ from typing import Callable
8
+
9
+ from nlalpha.core.backtest import BacktestResult, BatchBacktestInput
10
+ from nlalpha.core.contracts.market import Bar
11
+ from nlalpha_backtest.execution import BacktestExecutor, run_batch_strategy_code
12
+
13
+ __version__ = _distribution_version("nlalpha-backtest")
14
+
15
+ __all__ = [
16
+ "run",
17
+ "run_batch",
18
+ "run_batch_strategy_code",
19
+ "BacktestExecutor",
20
+ ]
21
+
22
+
23
+ async def run(
24
+ job_id: str,
25
+ strategy,
26
+ data: list[Bar],
27
+ execution_config: dict | None = None,
28
+ capital_config: dict | None = None,
29
+ contract_config: dict | None = None,
30
+ progress_callback: Callable[[float, str], None] | None = None,
31
+ engine: str | None = None,
32
+ ) -> BacktestResult:
33
+ """Execute a backtest. Main entry point for backtest execution."""
34
+ service = BacktestExecutor()
35
+ return await service.run(
36
+ job_id=job_id,
37
+ strategy=strategy,
38
+ data=data,
39
+ execution_config=execution_config,
40
+ capital_config=capital_config,
41
+ contract_config=contract_config,
42
+ progress_callback=progress_callback,
43
+ engine=engine,
44
+ )
45
+
46
+
47
+ async def run_batch(
48
+ job_id: str,
49
+ inputs: BatchBacktestInput,
50
+ execution_config: dict | None = None,
51
+ capital_config: dict | None = None,
52
+ contract_config: dict | None = None,
53
+ progress_callback: Callable[[float, str], None] | None = None,
54
+ engine: str | None = None,
55
+ ) -> BacktestResult:
56
+ """Execute one batch-signals run using an explicitly versioned input."""
57
+ service = BacktestExecutor()
58
+ return await service.run_batch(
59
+ job_id=job_id,
60
+ inputs=inputs,
61
+ execution_config=execution_config,
62
+ capital_config=capital_config,
63
+ contract_config=contract_config,
64
+ progress_callback=progress_callback,
65
+ engine=engine,
66
+ )
@@ -0,0 +1,5 @@
1
+ """Compatibility import for the shared event-driven backtest context."""
2
+
3
+ from nlalpha.core.backtest import BacktestContext as BacktestContext
4
+
5
+ __all__ = ["BacktestContext"]
@@ -0,0 +1,33 @@
1
+ """Backtest data models: MarketContext and BacktestResult."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+
8
+ from nlalpha.core.contracts.market import Bar, Position
9
+ from nlalpha.core.backtest import BacktestResult as BacktestResult
10
+
11
+ @dataclass
12
+ class MarketContext:
13
+ """Strategy input: a stable, branch-free data structure."""
14
+
15
+ bars: dict[str, Bar] = field(default_factory=dict)
16
+ series: dict[str, dict[str, list[float]]] = field(default_factory=dict)
17
+ positions: dict[str, Position] = field(default_factory=dict)
18
+ equity: float = 0.0
19
+ cash: float = 0.0
20
+ timestamp: datetime | None = None
21
+ step: int = 0
22
+
23
+ def get(self, symbol: str, field: str = "close") -> float | None:
24
+ """Get current bar field for a symbol."""
25
+ bar = self.bars.get(symbol)
26
+ return getattr(bar, field, None) if bar else None
27
+
28
+ def get_series(self, symbol: str, field: str = "close") -> list[float]:
29
+ """Get historical series for a symbol."""
30
+ return self.series.get(symbol, {}).get(field, [])
31
+
32
+
33
+ __all__ = ["BacktestResult", "MarketContext"]
@@ -0,0 +1,254 @@
1
+ """Engine registry and compatibility imports for shared backtest contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from dataclasses import dataclass, replace
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from nlalpha_backtest.execution.backend import EngineCapability, EngineDiagnostic, ExecutionCapabilities
12
+
13
+ from nlalpha.core.backtest import (
14
+ BATCH_SIGNALS,
15
+ EVENT_DRIVEN,
16
+ BacktestContext as BacktestContext,
17
+ BacktestEngineProvider as BacktestEngineProvider,
18
+ BacktestResult as BacktestResult,
19
+ EngineManifest as EngineManifest,
20
+ )
21
+ from nlalpha.core.errors import StrategyRuntimeError
22
+
23
+ logger = logging.getLogger("nlalpha_backtest.engine")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class _EngineRecord:
28
+ """One cached provider load and its manifest, guidance, or failure diagnostic."""
29
+
30
+ engine_id: str | None
31
+ provider: BacktestEngineProvider | None
32
+ manifest: EngineManifest | None
33
+ capability: EngineCapability | None
34
+ diagnostic: EngineDiagnostic | None
35
+
36
+
37
+ class EngineRegistry:
38
+ """Discover each installed engine once and select only verified providers."""
39
+
40
+ _records: list[_EngineRecord] = []
41
+ _default_order: list[str] = ["nautilus", "vector"]
42
+ _DISCOVERY_DONE = False
43
+ _lock = threading.RLock()
44
+
45
+ @classmethod
46
+ def register(cls, name: str, provider: BacktestEngineProvider) -> None:
47
+ """Register an explicit provider after validating its identity and guidance."""
48
+ record = cls._provider_record(name, provider, None, None, f"registered:{name}")
49
+ with cls._lock:
50
+ cls._append(record)
51
+
52
+ @classmethod
53
+ def _provider_record(cls, name, provider, package, version, entry_point):
54
+ """Cache one provider manifest and its exact guidance after identity validation."""
55
+ from nlalpha_backtest.execution.backend import EngineCapability, EngineDiagnostic
56
+
57
+ manifest = provider.manifest()
58
+ if not isinstance(manifest, EngineManifest) or manifest.engine_id != name:
59
+ raise ValueError("provider manifest engine_id must match the registered engine identity")
60
+ if not manifest.available:
61
+ diagnostic = EngineDiagnostic("unavailable", manifest.unavailable_reason or "provider reports unavailable",
62
+ name, entry_point, package, version)
63
+ return _EngineRecord(name, provider, manifest, None, diagnostic)
64
+ confession = provider.confess()
65
+ capability = EngineCapability(manifest, package, version, confession, entry_point)
66
+ return _EngineRecord(name, provider, manifest, capability, None)
67
+
68
+ @classmethod
69
+ def _append(cls, record: _EngineRecord) -> None:
70
+ """Retain a load record and disable every conflicting engine identity."""
71
+ from nlalpha_backtest.execution.backend import EngineDiagnostic
72
+
73
+ duplicates = [index for index, item in enumerate(cls._records)
74
+ if record.engine_id is not None and item.engine_id == record.engine_id]
75
+ if duplicates:
76
+ reason = f"duplicate engine identity: {record.engine_id}"
77
+ for index in duplicates:
78
+ prior = cls._records[index]
79
+ origin = prior.capability or prior.diagnostic
80
+ diagnostic = EngineDiagnostic("load_failed", reason, record.engine_id,
81
+ origin.entry_point, origin.package, origin.version)
82
+ cls._records[index] = replace(prior, capability=None, diagnostic=diagnostic)
83
+ origin = record.capability or record.diagnostic
84
+ record = replace(record, capability=None, diagnostic=EngineDiagnostic(
85
+ "load_failed", reason, record.engine_id, origin.entry_point, origin.package, origin.version))
86
+ cls._records.append(record)
87
+ if record.diagnostic:
88
+ logger.warning("engine unavailable | engine_id=%s entry_point=%s package=%s status=%s reason=%s",
89
+ record.engine_id, record.diagnostic.entry_point, record.diagnostic.package,
90
+ record.diagnostic.status, record.diagnostic.reason)
91
+
92
+ @classmethod
93
+ def _record(cls, name: str) -> _EngineRecord | None:
94
+ """Find the cached load record for an engine identity."""
95
+ return next((item for item in cls._records if item.engine_id == name), None)
96
+
97
+ @classmethod
98
+ def get(cls, name: str) -> BacktestEngineProvider:
99
+ """Return a provider whose cached discovery record is available."""
100
+ record = cls._record(name)
101
+ if record is None or record.capability is None:
102
+ raise KeyError(f"Engine {name!r} is not available. Available: {[item.engine_id for item in cls.list_available()]}")
103
+ return record.provider
104
+
105
+ @classmethod
106
+ def get_default(cls, *, runtime_mode: str = EVENT_DRIVEN, input_schema: str | None = None) -> BacktestEngineProvider:
107
+ """Select a verified compatible provider in the explicit preference order."""
108
+ cls._validate_request(runtime_mode, input_schema)
109
+ manifests = []
110
+ for name in dict.fromkeys(cls._default_order):
111
+ record = cls._record(name)
112
+ if record is None:
113
+ continue
114
+ if record.capability is not None and record.manifest.supports(runtime_mode, input_schema):
115
+ return record.provider
116
+ if record.manifest is not None:
117
+ manifests.append(record.manifest.to_dict())
118
+ raise StrategyRuntimeError(
119
+ f"No available backtest engine supports runtime mode {runtime_mode!r} with input schema {input_schema!r}.",
120
+ details={"reason": "no_compatible_engine", "runtime_mode": runtime_mode,
121
+ "input_schema": input_schema, "engines": manifests},
122
+ )
123
+
124
+ @classmethod
125
+ def resolve(cls, engine_id: str | None = None, *, runtime_mode: str = EVENT_DRIVEN,
126
+ input_schema: str | None = None) -> BacktestEngineProvider:
127
+ """Resolve an explicit verified provider or apply the configured preference order."""
128
+ cls._validate_request(runtime_mode, input_schema)
129
+ if engine_id is None:
130
+ return cls.get_default(runtime_mode=runtime_mode, input_schema=input_schema)
131
+ details = {"engine_id": engine_id, "runtime_mode": runtime_mode, "input_schema": input_schema}
132
+ record = cls._record(engine_id)
133
+ if record is None:
134
+ raise StrategyRuntimeError(f"Engine {engine_id!r} is not registered.", details={
135
+ **details, "reason": "engine_not_registered", "registered_engines": list(dict.fromkeys(
136
+ item.engine_id for item in cls._records if item.engine_id is not None))})
137
+ if record.manifest is not None:
138
+ details["manifest"] = record.manifest.to_dict()
139
+ if record.capability is None:
140
+ raise StrategyRuntimeError(f"Engine {engine_id!r} is unavailable: {record.diagnostic.reason}",
141
+ details={**details, "reason": "engine_unavailable",
142
+ "diagnostic": record.diagnostic.to_dict()})
143
+ if not record.manifest.supports(runtime_mode, input_schema):
144
+ raise StrategyRuntimeError(
145
+ f"Engine {engine_id!r} does not support runtime mode {runtime_mode!r} with input schema {input_schema!r}.",
146
+ details={**details, "reason": "engine_incompatible"})
147
+ return record.provider
148
+
149
+ @staticmethod
150
+ def _validate_request(runtime_mode: str, input_schema: str | None) -> None:
151
+ """Reject invalid runtime and input-schema combinations before selection."""
152
+ details = {"runtime_mode": runtime_mode, "input_schema": input_schema}
153
+ if runtime_mode not in (EVENT_DRIVEN, BATCH_SIGNALS):
154
+ raise StrategyRuntimeError(
155
+ f"Unknown backtest runtime mode: {runtime_mode!r}.",
156
+ details={
157
+ **details,
158
+ "reason": "invalid_runtime_mode",
159
+ "supported_runtime_modes": [EVENT_DRIVEN, BATCH_SIGNALS],
160
+ },
161
+ )
162
+ if runtime_mode == BATCH_SIGNALS and (
163
+ not isinstance(input_schema, str) or not input_schema.strip()
164
+ ):
165
+ raise StrategyRuntimeError(
166
+ "Batch signal execution requires a non-empty input schema.",
167
+ details={**details, "reason": "input_schema_required"},
168
+ )
169
+ if runtime_mode == EVENT_DRIVEN and input_schema is not None:
170
+ raise StrategyRuntimeError(
171
+ "Event-driven execution does not accept an input schema.",
172
+ details={**details, "reason": "unexpected_input_schema"},
173
+ )
174
+
175
+ @classmethod
176
+ def list_available(cls) -> list[EngineManifest]:
177
+ """List manifests from the same verified records used for provider selection."""
178
+ return [record.manifest for record in cls._records if record.capability is not None]
179
+
180
+ @classmethod
181
+ def list_all(cls) -> list[EngineManifest]:
182
+ """List every successfully read manifest with its effective availability."""
183
+ return [record.manifest if record.capability is not None else replace(
184
+ record.manifest, available=False, unavailable_reason=record.diagnostic.reason)
185
+ for record in cls._records if record.manifest is not None]
186
+
187
+ @classmethod
188
+ def capabilities(cls) -> ExecutionCapabilities:
189
+ """Return the installed engine directory and retained load diagnostics."""
190
+ from nlalpha_backtest.execution.backend import ExecutionCapabilities
191
+
192
+ cls.discover_engines()
193
+ return ExecutionCapabilities(
194
+ tuple(record.capability for record in cls._records if record.capability is not None),
195
+ tuple(record.diagnostic for record in cls._records if record.diagnostic is not None),
196
+ {"kind": "local"},
197
+ )
198
+
199
+ @classmethod
200
+ def discover_engines(cls) -> None:
201
+ """Load installed entry points once while retaining each plugin's failure evidence."""
202
+ from importlib.metadata import entry_points
203
+ from nlalpha_backtest.execution.backend import EngineDiagnostic
204
+
205
+ with cls._lock:
206
+ if cls._DISCOVERY_DONE:
207
+ return
208
+ entries = list(entry_points(group="nlalpha.engines"))
209
+ for entry in sorted(entries, key=lambda item: (item.name, item.value)):
210
+ package = version = engine_id = None
211
+ source = f"{entry.name}={entry.value}"
212
+ manifest = None
213
+ provider = None
214
+ try:
215
+ package = entry.dist.metadata["Name"]
216
+ version = entry.dist.version
217
+ plugin = entry.load()()
218
+ engine_id = plugin.engine_id
219
+ if not isinstance(engine_id, str) or not engine_id.strip() or engine_id != engine_id.strip():
220
+ raise ValueError("plugin engine_id must be a nonempty trimmed string")
221
+ available = plugin.is_available()
222
+ if not isinstance(available, bool):
223
+ raise TypeError("plugin is_available must return a boolean")
224
+ declared = plugin.get_manifest()
225
+ if not isinstance(declared, dict):
226
+ raise TypeError("plugin get_manifest must return a manifest object")
227
+ fields = {"engine_id", "engine_name", "available", "unavailable_reason", "runtime_modes", "input_schemas"}
228
+ if not fields.issubset(declared):
229
+ raise ValueError("plugin get_manifest is missing manifest fields")
230
+ manifest = EngineManifest(**{name: declared[name] for name in fields})
231
+ if manifest.engine_id != engine_id:
232
+ raise ValueError("plugin manifest engine_id does not match plugin identity")
233
+ if not available or not manifest.available:
234
+ diagnostic = EngineDiagnostic("unavailable", manifest.unavailable_reason or "plugin reports unavailable",
235
+ engine_id, source, package, version)
236
+ record = _EngineRecord(engine_id, None, manifest, None, diagnostic)
237
+ else:
238
+ provider = plugin.get_provider()
239
+ record = cls._provider_record(engine_id, provider, package, version, source)
240
+ if record.manifest != manifest:
241
+ raise ValueError("plugin and provider manifests disagree")
242
+ except Exception as exc:
243
+ if not isinstance(engine_id, str) or not engine_id.strip() or engine_id != engine_id.strip():
244
+ engine_id = None
245
+ if (not isinstance(package, str) or not package.strip() or package != package.strip()
246
+ or not isinstance(version, str) or not version.strip() or version != version.strip()):
247
+ package = version = None
248
+ diagnostic = EngineDiagnostic("load_failed", f"{type(exc).__name__}: {exc}".strip(),
249
+ engine_id, source, package, version)
250
+ record = _EngineRecord(engine_id, provider, manifest, None, diagnostic)
251
+ cls._append(record)
252
+ cls._DISCOVERY_DONE = True
253
+ logger.info("engine discovery completed | available=%d diagnostics=%d",
254
+ len(cls.list_available()), sum(item.diagnostic is not None for item in cls._records))
@@ -0,0 +1,50 @@
1
+ """Backtest execution contracts, local backend, runner, and runtime."""
2
+
3
+ from nlalpha_backtest.execution.backtest_executor import BacktestExecutor
4
+ from nlalpha_backtest.execution.backend import (
5
+ BatchBacktestRequest,
6
+ BacktestRequest,
7
+ CancelOutcome,
8
+ CancelStatus,
9
+ ExecutionBackend,
10
+ ExecutionCapabilities,
11
+ EngineCapability,
12
+ EngineDiagnostic,
13
+ ExecutionCancelled,
14
+ ExecutionError,
15
+ ExecutionFailed,
16
+ ExecutionObservation,
17
+ ExecutionOutcome,
18
+ ExecutionRequest,
19
+ ExecutionTimedOut,
20
+ OperationStatus,
21
+ )
22
+ from nlalpha_backtest.execution.local_executor import LocalExecutor
23
+ from nlalpha_backtest.execution.runner import BacktestRunner
24
+ from nlalpha_backtest.execution.runtime import run_batch_strategy_code, run_strategy_code
25
+ from nlalpha_backtest.execution.validation import validate_runtime_mode
26
+
27
+ __all__ = [
28
+ "BacktestExecutor",
29
+ "BacktestRequest",
30
+ "BatchBacktestRequest",
31
+ "BacktestRunner",
32
+ "CancelOutcome",
33
+ "CancelStatus",
34
+ "ExecutionBackend",
35
+ "ExecutionCapabilities",
36
+ "EngineCapability",
37
+ "EngineDiagnostic",
38
+ "ExecutionCancelled",
39
+ "ExecutionError",
40
+ "ExecutionFailed",
41
+ "ExecutionObservation",
42
+ "ExecutionOutcome",
43
+ "ExecutionRequest",
44
+ "ExecutionTimedOut",
45
+ "LocalExecutor",
46
+ "OperationStatus",
47
+ "run_batch_strategy_code",
48
+ "run_strategy_code",
49
+ "validate_runtime_mode",
50
+ ]