diff --git a/README.md b/README.md index e7a008d..2201e5f 100644 --- a/README.md +++ b/README.md @@ -162,10 +162,15 @@ pineforge-backtest \ --timeframe 15m \ --start 2026-07-01T00:00:00Z \ --end 2026-07-08T00:00:00Z \ + --warmup-bars 500 \ --output report.json \ --pretty ``` +`--warmup-bars` fetches additional source bars before `--start` to initialize +indicators and higher-timeframe state. Order execution remains disabled until +`--start`, and the report records both requested and loaded warmup counts. + The first local invocation pulls an immutable, multi-architecture `pineforge-release` image pinned by both version and OCI digest. It never builds engine or codegen locally. Use `--pull-policy never` for offline runs or opt in diff --git a/docs/backtesting.md b/docs/backtesting.md index 863e2ea..bbc3e50 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -39,7 +39,7 @@ pineforge-backtest \ | Group | Options | |---|---| | Strategy | `--pine`, `--strategy-params`, `--strategy-overrides` | -| Data source | `--provider`, `--venue`/`--exchange`, `--symbol`, `--timeframe`, `--start`, `--end`, `--limit`, `--provider-config` | +| Data source | `--provider`, `--venue`/`--exchange`, `--symbol`, `--timeframe`, `--start`, `--end`, `--limit`, `--warmup-bars`, `--provider-config` | | Pine context | `--timezone`, `--session`, `--engine-timeframe`, `--script-timeframe` | | Fill modeling | `--bar-magnifier`, `--magnifier-samples` | | Local runtime | `--runtime-image`/`--image`, `--pull-policy`, `--execution-timeout` | @@ -51,6 +51,34 @@ end is exclusive. `--engine-timeframe` defaults to a Pine-compatible conversion of the provider timeframe, and `--script-timeframe` defaults to the engine timeframe. +### Indicator warmup + +Use `--warmup-bars` to load source bars before `--start` without allowing the +strategy to place orders during that earlier interval: + +```bash +pineforge-backtest \ + --pine strategy.pine \ + --provider ccxt \ + --venue kraken \ + --symbol BTC/USD \ + --timeframe 15m \ + --start 2025-07-01T00:00:00Z \ + --end 2025-07-08T00:00:00Z \ + --warmup-bars 500 +``` + +The warmup bars initialize indicators, higher-timeframe feeds, and persistent +Pine variables. PineForge suppresses order commands until `--start`, so broker +state and trade counters begin at the requested backtest boundary. The default +is `0` for backward compatibility. + +Providers can return fewer warmup bars when history is unavailable or the +market has gaps. The report records `warmup_bars_requested`, +`warmup_bars_loaded`, the expanded `provider_start_ms`, and the effective +`trade_start_time_ms`. When `--limit` is set, warmup capacity is added to that +limit so it does not consume the requested-window allowance. + ## Configuration files Provider constructor configuration: @@ -140,9 +168,14 @@ The harness combines provider provenance with the release report: "data": { "requested_start_ms": 1751328000000, "requested_end_ms": 1751932800000, - "first_bar_ms": 1751328000000, + "provider_start_ms": 1746828000000, + "first_bar_ms": 1746828000000, "last_bar_ms": 1751931900000, - "bars": 672 + "bars": 1172, + "requested_bars": 672, + "warmup_bars_requested": 500, + "warmup_bars_loaded": 500, + "trade_start_time_ms": 1751328000000 }, "runtime": { "mode": "local-container", diff --git a/docs/server.md b/docs/server.md index 77a620c..7efea1f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -18,6 +18,11 @@ Clients may supply `X-Request-ID`; otherwise the server creates one. When `Authorization: Bearer `. Health endpoints intentionally remain unauthenticated for container orchestration. +The request option `trade_start_time_ms` suppresses order execution before the +given Unix-millisecond boundary while still processing every submitted bar. +The CLI harness sets this automatically when `--warmup-bars` is nonzero; direct +API clients must submit both the warmup bars and the boundary explicitly. + ## Concurrency and overload behavior Run one Uvicorn worker per container. The service owns a process-wide semaphore; diff --git a/src/pineforge_data/backtest.py b/src/pineforge_data/backtest.py index 2fe82e7..42a7a94 100644 --- a/src/pineforge_data/backtest.py +++ b/src/pineforge_data/backtest.py @@ -47,6 +47,7 @@ class BacktestOptions: magnifier_distribution: MagnifierDistribution = MagnifierDistribution.ENDPOINTS trace_enabled: bool = False chart_timezone: str | None = None + trade_start_time_ms: int | None = None def __post_init__(self) -> None: if self.magnifier_samples <= 0: @@ -57,6 +58,8 @@ def __post_init__(self) -> None: raise ValueError("script_timeframe must not be whitespace") if self.chart_timezone is not None and not self.chart_timezone.strip(): raise ValueError("chart_timezone must not be empty") + if self.trade_start_time_ms is not None and not 0 <= self.trade_start_time_ms <= 2**63 - 1: + raise ValueError("trade_start_time_ms must be a non-negative signed 64-bit integer") @dataclass(frozen=True, slots=True) @@ -368,6 +371,9 @@ def _configure_signatures(self) -> None: if hasattr(library, "strategy_set_syminfo_session"): library.strategy_set_syminfo_session.argtypes = [ctypes.c_void_p, ctypes.c_char_p] library.strategy_set_syminfo_session.restype = None + if hasattr(library, "strategy_set_trade_start_time"): + library.strategy_set_trade_start_time.argtypes = [ctypes.c_void_p, ctypes.c_int64] + library.strategy_set_trade_start_time.restype = None def _apply_context( self, state: int | ctypes.c_void_p, instrument: Instrument, options: BacktestOptions @@ -382,6 +388,12 @@ def _apply_context( library.strategy_set_syminfo_timezone(state, instrument.timezone.encode()) if instrument.session and hasattr(library, "strategy_set_syminfo_session"): library.strategy_set_syminfo_session(state, instrument.session.encode()) + if options.trade_start_time_ms is not None: + if not hasattr(library, "strategy_set_trade_start_time"): + raise EngineBacktestError( + "strategy library does not expose strategy_set_trade_start_time" + ) + library.strategy_set_trade_start_time(state, options.trade_start_time_ms) def _last_error(self, state: int | ctypes.c_void_p) -> str: if not hasattr(self._library, "strategy_get_last_error"): diff --git a/src/pineforge_data/cli/backtest.py b/src/pineforge_data/cli/backtest.py index a3858e6..18f45b8 100644 --- a/src/pineforge_data/cli/backtest.py +++ b/src/pineforge_data/cli/backtest.py @@ -57,6 +57,32 @@ def source_timeframe_to_pine(timeframe: str) -> str: return f"{count}{unit.upper() if unit != 'M' else unit}" +def warmup_request_start_ms(start_ms: int, timeframe: str, warmup_bars: int) -> int: + """Return an inclusive provider start that can contain ``warmup_bars`` bars.""" + + if warmup_bars < 0: + raise ValueError("warmup_bars must be non-negative") + if warmup_bars == 0: + return start_ms + match = re.fullmatch(r"([1-9][0-9]*)([smhdwM])", timeframe) + if match is None: + raise ValueError(f"cannot calculate warmup for source timeframe: {timeframe}") + count = int(match.group(1)) + unit = match.group(2) + # A calendar month has no fixed duration. Thirty-one days deliberately + # over-fetches; run_harness trims the result to the requested bar count. + unit_ms = { + "s": 1_000, + "m": 60_000, + "h": 3_600_000, + "d": 86_400_000, + "w": 604_800_000, + "M": 2_678_400_000, + }[unit] + duration_ms = count * unit_ms * warmup_bars + return max(0, start_ms - duration_ms) + + # Backward-compatible import for callers of the CCXT-only bootstrap API. ccxt_timeframe_to_pine = source_timeframe_to_pine @@ -123,6 +149,12 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--start", type=parse_timestamp, required=True) parser.add_argument("--end", type=parse_timestamp, required=True) parser.add_argument("--limit", type=int) + parser.add_argument( + "--warmup-bars", + type=int, + default=0, + help="source bars before --start used for indicator warmup; trading stays disabled", + ) parser.add_argument("--timezone", default="UTC", help="IANA chart and exchange timezone") parser.add_argument("--session", default="24x7", help="PineForge syminfo session") parser.add_argument( @@ -174,6 +206,13 @@ def build_parser() -> argparse.ArgumentParser: async def run_harness(args: argparse.Namespace) -> dict[str, JsonValue]: """Execute the provider-to-engine pipeline for parsed CLI arguments.""" + if args.warmup_bars < 0: + raise ValueError("--warmup-bars must be non-negative") + if args.end <= args.start: + raise ValueError("--end must be later than --start") + if args.limit is not None and args.limit <= 0: + raise ValueError("--limit must be positive") + provider_config = _load_json_object(args.provider_config) strategy_params = _load_json_object(args.strategy_params) strategy_overrides = _load_json_object(args.strategy_overrides) @@ -185,19 +224,27 @@ async def run_harness(args: argparse.Namespace) -> dict[str, JsonValue]: timezone=args.timezone, session=args.session, ) + provider_start_ms = warmup_request_start_ms(args.start, args.timeframe, args.warmup_bars) + request_limit = None if args.limit is None else args.limit + args.warmup_bars request = BarRequest( instrument, args.timeframe, - args.start, + provider_start_ms, args.end, - limit=args.limit, + limit=request_limit, ) - bars = await provider.fetch_bars(request) + fetched_bars = list(await provider.fetch_bars(request)) provider_name = provider.name finally: await provider.close() - if not bars: + if not fetched_bars: raise RuntimeError("provider returned no confirmed bars for the requested interval") + requested_bars = [bar for bar in fetched_bars if bar.timestamp_ms >= args.start] + if not requested_bars: + raise RuntimeError("provider returned no confirmed bars at or after --start") + warmup_candidates = [bar for bar in fetched_bars if bar.timestamp_ms < args.start] + warmup = warmup_candidates[-args.warmup_bars :] if args.warmup_bars else [] + bars = [*warmup, *requested_bars] engine_timeframe = args.engine_timeframe or source_timeframe_to_pine(args.timeframe) options = BacktestOptions( @@ -207,6 +254,7 @@ async def run_harness(args: argparse.Namespace) -> dict[str, JsonValue]: magnifier_samples=args.magnifier_samples, trace_enabled=args.trace, chart_timezone=args.timezone, + trade_start_time_ms=args.start if args.warmup_bars else None, ) pine_path = args.pine.expanduser().resolve() if not pine_path.is_file(): @@ -262,9 +310,14 @@ async def run_harness(args: argparse.Namespace) -> dict[str, JsonValue]: "data": { "requested_start_ms": args.start, "requested_end_ms": args.end, + "provider_start_ms": provider_start_ms, "first_bar_ms": bars[0].timestamp_ms, "last_bar_ms": bars[-1].timestamp_ms, "bars": len(bars), + "requested_bars": len(requested_bars), + "warmup_bars_requested": args.warmup_bars, + "warmup_bars_loaded": len(warmup), + "trade_start_time_ms": options.trade_start_time_ms, }, "strategy": { "pine": str(pine_path), diff --git a/src/pineforge_data/release_contract.py b/src/pineforge_data/release_contract.py index 8b98806..50f2b29 100644 --- a/src/pineforge_data/release_contract.py +++ b/src/pineforge_data/release_contract.py @@ -91,7 +91,7 @@ def release_environment( raise ReleaseContractError("pineforge-release 0.1.12 does not expose trace collection") if options.bar_magnifier and options.magnifier_samples < 2: raise ReleaseContractError("bar magnifier requires at least two samples") - return { + environment = { "PINEFORGE_IN_DIR": input_directory, "PINEFORGE_INPUTS": json.dumps( dict(strategy_params or {}), separators=(",", ":"), allow_nan=False @@ -109,6 +109,9 @@ def release_environment( "PINEFORGE_DATA_SYMBOL": instrument.symbol, "PINEFORGE_DATA_VENUE": instrument.venue, } + if options.trade_start_time_ms is not None: + environment["PINEFORGE_TRADE_START_MS"] = str(options.trade_start_time_ms) + return environment def parse_release_report(stdout: str) -> dict[str, object]: diff --git a/src/pineforge_data/server.py b/src/pineforge_data/server.py index 3f5b902..dc0321a 100644 --- a/src/pineforge_data/server.py +++ b/src/pineforge_data/server.py @@ -86,6 +86,7 @@ class ApiBacktestOptions(BaseModel): magnifier_distribution: MagnifierName = "endpoints" trace_enabled: bool = False chart_timezone: str | None = Field(default=None, max_length=128) + trade_start_time_ms: int | None = Field(default=None, ge=0, le=2**63 - 1) class BacktestApiRequest(BaseModel): @@ -143,6 +144,7 @@ def domain_values( ], trace_enabled=self.options.trace_enabled, chart_timezone=self.options.chart_timezone, + trade_start_time_ms=self.options.trade_start_time_ms, ) return self.pine_source, bars, instrument, options @@ -519,6 +521,8 @@ async def _execute_release(self, request: BacktestApiRequest) -> ExecutionResult "--chart-tz", options.chart_timezone or "", ] + if options.trade_start_time_ms is not None: + command.extend(("--trade-start-ms", str(options.trade_start_time_ms))) try: stdout = await self._run_command( command, diff --git a/src/pineforge_data/server_client.py b/src/pineforge_data/server_client.py index cde6371..905d0e6 100644 --- a/src/pineforge_data/server_client.py +++ b/src/pineforge_data/server_client.py @@ -83,6 +83,7 @@ def run( "magnifier_distribution": options.magnifier_distribution.name.lower(), "trace_enabled": options.trace_enabled, "chart_timezone": options.chart_timezone, + "trade_start_time_ms": options.trade_start_time_ms, }, "strategy_params": _scalar_inputs(strategy_params, "strategy_params"), "strategy_overrides": _scalar_inputs(strategy_overrides, "strategy_overrides"), diff --git a/tests/test_backtest.py b/tests/test_backtest.py index 10d2671..5a8abdc 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import ctypes import json from math import nan @@ -20,8 +21,11 @@ build_parser, ccxt_timeframe_to_pine, parse_timestamp, + run_harness, source_timeframe_to_pine, + warmup_request_start_ms, ) +from pineforge_data.models import MarketListing class FakeFunction: @@ -51,6 +55,7 @@ def __init__(self, *, abi: int = 2, error: bytes = b"") -> None: self.strategy_set_chart_timezone = FakeFunction() self.strategy_set_syminfo_timezone = FakeFunction() self.strategy_set_syminfo_session = FakeFunction() + self.strategy_set_trade_start_time = FakeFunction() self.run_backtest_full = FakeFunction(callback=self._fill_report) self._trades = (_PfTrade * 1)( _PfTrade(1_000, 2_000, 10.0, 12.0, 20.0, 20.0, 1, 2.5, 0.5, 10.0, 0.0, 0, 1) @@ -101,6 +106,7 @@ def test_runner_returns_detached_json_safe_report() -> None: script_timeframe="1", trace_enabled=True, chart_timezone="UTC", + trade_start_time_ms=1_500, ), strategy_params={"length": 14}, ) @@ -116,6 +122,7 @@ def test_runner_returns_detached_json_safe_report() -> None: assert fake.strategy_set_trace_enabled.calls == [(123, 1)] assert fake.strategy_set_input.calls == [(123, b"length", b"14")] assert fake.strategy_set_syminfo_session.calls == [(123, b"24x7")] + assert fake.strategy_set_trade_start_time.calls == [(123, 1_500)] def test_runner_surfaces_engine_error_and_releases_owners() -> None: @@ -152,6 +159,17 @@ def test_timestamp_parser_accepts_unix_ms_and_iso_8601() -> None: assert parse_timestamp("1970-01-01T00:00:01Z") == 1_000 +def test_warmup_start_uses_source_bar_count_and_clamps_at_epoch() -> None: + assert warmup_request_start_ms(10 * 60_000, "1m", 3) == 7 * 60_000 + assert warmup_request_start_ms(60_000, "1m", 3) == 0 + assert warmup_request_start_ms(60_000, "1m", 0) == 60_000 + + +def test_backtest_options_reject_negative_trade_start() -> None: + with pytest.raises(ValueError, match="trade_start_time_ms"): + BacktestOptions(trade_start_time_ms=-1) + + def test_cli_requires_raw_pine_instead_of_shared_library() -> None: args = build_parser().parse_args( [ @@ -217,8 +235,104 @@ def test_cli_can_route_harness_to_concurrent_server() -> None: "http://127.0.0.1:8000", "--execution-timeout", "60", + "--warmup-bars", + "200", ] ) assert args.server_url == "http://127.0.0.1:8000" assert args.execution_timeout == 60 + assert args.warmup_bars == 200 + + +def test_harness_loads_warmup_bars_and_gates_trading( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + instrument = Instrument("BTC/USD", venue="fixture") + + class FakeProvider: + name = "fixture" + request: object = None + + async def resolve_market(self, _symbol: str) -> MarketListing: + return MarketListing(instrument) + + async def fetch_bars(self, request: object) -> list[Bar]: + self.request = request + return [ + Bar(instrument, timestamp, 10, 11, 9, 10, 1, "fixture") + for timestamp in (60_000, 120_000, 180_000, 240_000, 300_000) + ] + + async def close(self) -> None: + return None + + class FakeRuntime: + def __init__(self) -> None: + self.received_bars: list[Bar] = [] + self.received_options: BacktestOptions | None = None + + def run( + self, + _pine_source: str, + runtime_bars: list[Bar], + **kwargs: object, + ) -> dict[str, object]: + self.received_bars = runtime_bars + options = kwargs["options"] + assert isinstance(options, BacktestOptions) + self.received_options = options + return {"runtime": {}, "backtest": {}} + + provider = FakeProvider() + runtime = FakeRuntime() + monkeypatch.setattr( + "pineforge_data.cli.backtest.create_provider", lambda *_args, **_kwargs: provider + ) + monkeypatch.setattr( + "pineforge_data.cli.backtest.DockerBacktestRuntime", lambda **_kwargs: runtime + ) + pine = tmp_path / "strategy.pine" + pine.write_text("//@version=6\nstrategy('warmup')\n", encoding="utf-8") + args = build_parser().parse_args( + [ + "--pine", + str(pine), + "--provider", + "fixture", + "--venue", + "fixture", + "--symbol", + "BTC/USD", + "--timeframe", + "1m", + "--start", + "180000", + "--end", + "360000", + "--limit", + "10", + "--warmup-bars", + "2", + ] + ) + + report = asyncio.run(run_harness(args)) + + request = provider.request + assert request is not None + assert request.start_ms == 60_000 # type: ignore[attr-defined] + assert request.limit == 12 # type: ignore[attr-defined] + assert [bar.timestamp_ms for bar in runtime.received_bars] == [ + 60_000, + 120_000, + 180_000, + 240_000, + 300_000, + ] + assert runtime.received_options is not None + assert runtime.received_options.trade_start_time_ms == 180_000 + data = report["data"] + assert isinstance(data, dict) + assert data["warmup_bars_requested"] == 2 + assert data["warmup_bars_loaded"] == 2 diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index e54936e..7f5a91e 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -51,6 +51,7 @@ def test_release_environment_maps_runtime_options() -> None: script_timeframe="60", bar_magnifier=True, magnifier_samples=8, + trade_start_time_ms=1_700_000_000_000, ), {"Length": 14}, {"commission_value": 0.1}, @@ -61,6 +62,7 @@ def test_release_environment_maps_runtime_options() -> None: assert environment["PINEFORGE_INPUT_TF"] == "15" assert environment["PINEFORGE_SCRIPT_TF"] == "60" assert environment["PINEFORGE_SYMINFO"] == "/in/syminfo.json" + assert environment["PINEFORGE_TRADE_START_MS"] == "1700000000000" def test_release_contract_rejects_unsupported_trace() -> None: diff --git a/tests/test_server.py b/tests/test_server.py index 701254f..2f5e2e2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -160,3 +160,16 @@ def test_request_rejects_unsorted_bars() -> None: with pytest.raises(ValueError, match="strictly increasing"): BacktestApiRequest.model_validate(payload) + + +def test_request_maps_trade_start_to_domain_options() -> None: + payload = request_payload() + options = payload["options"] + assert isinstance(options, dict) + options["trade_start_time_ms"] = 1_000 + + _pine, _bars, _instrument, domain_options = BacktestApiRequest.model_validate( + payload + ).domain_values() + + assert domain_options.trade_start_time_ms == 1_000 diff --git a/tests/test_server_client.py b/tests/test_server_client.py index 5c67185..1e7f8a9 100644 --- a/tests/test_server_client.py +++ b/tests/test_server_client.py @@ -48,7 +48,7 @@ def fake_urlopen(request: Request, timeout: float) -> FakeResponse: bars, instrument=instrument, source="ccxt:kraken", - options=BacktestOptions(input_timeframe="1"), + options=BacktestOptions(input_timeframe="1", trade_start_time_ms=1_000), strategy_params={"Length": 14}, ) @@ -58,6 +58,7 @@ def fake_urlopen(request: Request, timeout: float) -> FakeResponse: body = json.loads(cast(bytes, observed[0].data)) assert body["bars"][0]["timestamp_ms"] == 1_000 assert body["strategy_params"] == {"Length": 14} + assert body["options"]["trade_start_time_ms"] == 1_000 def test_client_rejects_non_scalar_strategy_values() -> None: