Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 36 additions & 3 deletions docs/backtesting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ Clients may supply `X-Request-ID`; otherwise the server creates one. When
`Authorization: Bearer <token>`. 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;
Expand Down
12 changes: 12 additions & 0 deletions src/pineforge_data/backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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"):
Expand Down
61 changes: 57 additions & 4 deletions src/pineforge_data/cli/backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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():
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion src/pineforge_data/release_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]:
Expand Down
4 changes: 4 additions & 0 deletions src/pineforge_data/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/pineforge_data/server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading