pineforge-data 0.2.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,124 @@
1
+ """Provider-neutral market and macro data contracts for PineForge."""
2
+
3
+ from .backtest import (
4
+ BacktestOptions,
5
+ BacktestReport,
6
+ EngineBacktestError,
7
+ MagnifierDistribution,
8
+ PineForgeBacktestRunner,
9
+ )
10
+ from .compile_cache import CompileCache
11
+ from .docker_runtime import (
12
+ DockerBacktestRuntime,
13
+ DockerExecutionError,
14
+ DockerPrerequisiteError,
15
+ )
16
+ from .engine import EngineStreamSink, PfBar, PfTradeTick, pack_bars, pack_trade_ticks
17
+ from .errors import EngineStreamError
18
+ from .models import (
19
+ AssetClass,
20
+ Bar,
21
+ ContractSpec,
22
+ Instrument,
23
+ MacroObservation,
24
+ MarketListing,
25
+ MarketType,
26
+ OptionType,
27
+ TradeTick,
28
+ )
29
+ from .providers import (
30
+ BarColumnMapping,
31
+ CcxtCapabilityError,
32
+ CcxtDataError,
33
+ CcxtDependencyError,
34
+ CcxtError,
35
+ CcxtProvider,
36
+ CsvBarProvider,
37
+ HistoricalBarProvider,
38
+ LiveTradeProvider,
39
+ MacroDataProvider,
40
+ MarketCatalogProvider,
41
+ MarketDataProvider,
42
+ MarketNotFoundError,
43
+ ProviderFactory,
44
+ ProviderNotFoundError,
45
+ ProviderRegistry,
46
+ ProviderRegistryError,
47
+ SchemaMappingError,
48
+ SourceColumn,
49
+ SqlAlchemyBarProvider,
50
+ SqlAlchemyDependencyError,
51
+ SqliteBarProvider,
52
+ TabularBarProvider,
53
+ TabularDataError,
54
+ TabularSchema,
55
+ TimestampUnit,
56
+ create_provider,
57
+ default_registry,
58
+ )
59
+ from .release_contract import DEFAULT_RELEASE_IMAGE, ReleaseContractError
60
+ from .requests import BarRequest, MacroRequest, MarketQuery, TradeSubscription
61
+ from .server_client import BacktestServerError, FastApiBacktestClient
62
+
63
+ __all__ = [
64
+ "DEFAULT_RELEASE_IMAGE",
65
+ "AssetClass",
66
+ "BacktestOptions",
67
+ "BacktestReport",
68
+ "BacktestServerError",
69
+ "Bar",
70
+ "BarColumnMapping",
71
+ "BarRequest",
72
+ "CcxtCapabilityError",
73
+ "CcxtDataError",
74
+ "CcxtDependencyError",
75
+ "CcxtError",
76
+ "CcxtProvider",
77
+ "CompileCache",
78
+ "ContractSpec",
79
+ "CsvBarProvider",
80
+ "DockerBacktestRuntime",
81
+ "DockerExecutionError",
82
+ "DockerPrerequisiteError",
83
+ "EngineBacktestError",
84
+ "EngineStreamError",
85
+ "EngineStreamSink",
86
+ "FastApiBacktestClient",
87
+ "HistoricalBarProvider",
88
+ "Instrument",
89
+ "LiveTradeProvider",
90
+ "MacroDataProvider",
91
+ "MacroObservation",
92
+ "MacroRequest",
93
+ "MagnifierDistribution",
94
+ "MarketCatalogProvider",
95
+ "MarketDataProvider",
96
+ "MarketListing",
97
+ "MarketNotFoundError",
98
+ "MarketQuery",
99
+ "MarketType",
100
+ "OptionType",
101
+ "PfBar",
102
+ "PfTradeTick",
103
+ "PineForgeBacktestRunner",
104
+ "ProviderFactory",
105
+ "ProviderNotFoundError",
106
+ "ProviderRegistry",
107
+ "ProviderRegistryError",
108
+ "ReleaseContractError",
109
+ "SchemaMappingError",
110
+ "SourceColumn",
111
+ "SqlAlchemyBarProvider",
112
+ "SqlAlchemyDependencyError",
113
+ "SqliteBarProvider",
114
+ "TabularBarProvider",
115
+ "TabularDataError",
116
+ "TabularSchema",
117
+ "TimestampUnit",
118
+ "TradeSubscription",
119
+ "TradeTick",
120
+ "create_provider",
121
+ "default_registry",
122
+ "pack_bars",
123
+ "pack_trade_ticks",
124
+ ]
@@ -0,0 +1,552 @@
1
+ """Run normalized PineForge data directly through a compiled strategy library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import json
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass
9
+ from enum import IntEnum
10
+ from itertools import pairwise
11
+ from math import isfinite
12
+ from pathlib import Path
13
+ from typing import TypeAlias
14
+
15
+ from .engine import PfBar, pack_bars
16
+ from .models import Bar, Instrument
17
+
18
+ JsonScalar: TypeAlias = str | int | float | bool | None
19
+ JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
20
+
21
+ EXPECTED_PF_ABI = 2
22
+
23
+
24
+ class EngineBacktestError(RuntimeError):
25
+ """A PineForge historical backtest could not be completed."""
26
+
27
+
28
+ class MagnifierDistribution(IntEnum):
29
+ """PineForge bar-magnifier path sampling distributions."""
30
+
31
+ UNIFORM = 0
32
+ COSINE = 1
33
+ TRIANGLE = 2
34
+ ENDPOINTS = 3
35
+ FRONT_LOADED = 4
36
+ BACK_LOADED = 5
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class BacktestOptions:
41
+ """Runtime options passed to ``run_backtest_full``."""
42
+
43
+ input_timeframe: str = ""
44
+ script_timeframe: str = ""
45
+ bar_magnifier: bool = False
46
+ magnifier_samples: int = 4
47
+ magnifier_distribution: MagnifierDistribution = MagnifierDistribution.ENDPOINTS
48
+ trace_enabled: bool = False
49
+ chart_timezone: str | None = None
50
+ trade_start_time_ms: int | None = None
51
+
52
+ def __post_init__(self) -> None:
53
+ if self.magnifier_samples <= 0:
54
+ raise ValueError("magnifier_samples must be positive")
55
+ if self.input_timeframe and not self.input_timeframe.strip():
56
+ raise ValueError("input_timeframe must not be whitespace")
57
+ if self.script_timeframe and not self.script_timeframe.strip():
58
+ raise ValueError("script_timeframe must not be whitespace")
59
+ if self.chart_timezone is not None and not self.chart_timezone.strip():
60
+ raise ValueError("chart_timezone must not be empty")
61
+ if self.trade_start_time_ms is not None and not 0 <= self.trade_start_time_ms <= 2**63 - 1:
62
+ raise ValueError("trade_start_time_ms must be a non-negative signed 64-bit integer")
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class BacktestReport:
67
+ """A detached, JSON-safe copy of ``pf_report_t``."""
68
+
69
+ summary: Mapping[str, JsonValue]
70
+ metrics: Mapping[str, Mapping[str, JsonValue]]
71
+ trades: tuple[Mapping[str, JsonValue], ...]
72
+ security_diagnostics: tuple[Mapping[str, JsonValue], ...]
73
+ trace: tuple[Mapping[str, JsonValue], ...]
74
+ equity_curve: tuple[Mapping[str, JsonValue], ...]
75
+
76
+ def to_dict(self) -> dict[str, JsonValue]:
77
+ """Return a mutable JSON-shaped representation."""
78
+
79
+ return {
80
+ "summary": dict(self.summary),
81
+ "metrics": {name: dict(values) for name, values in self.metrics.items()},
82
+ "trades": [dict(trade) for trade in self.trades],
83
+ "security_diagnostics": [dict(item) for item in self.security_diagnostics],
84
+ "trace": [dict(item) for item in self.trace],
85
+ "equity_curve": [dict(point) for point in self.equity_curve],
86
+ }
87
+
88
+
89
+ class _PfTrade(ctypes.Structure):
90
+ _fields_ = [
91
+ ("entry_time", ctypes.c_int64),
92
+ ("exit_time", ctypes.c_int64),
93
+ ("entry_price", ctypes.c_double),
94
+ ("exit_price", ctypes.c_double),
95
+ ("pnl", ctypes.c_double),
96
+ ("pnl_pct", ctypes.c_double),
97
+ ("is_long", ctypes.c_int),
98
+ ("max_runup", ctypes.c_double),
99
+ ("max_drawdown", ctypes.c_double),
100
+ ("qty", ctypes.c_double),
101
+ ("commission", ctypes.c_double),
102
+ ("entry_bar_index", ctypes.c_int32),
103
+ ("exit_bar_index", ctypes.c_int32),
104
+ ]
105
+
106
+
107
+ class _PfTradeStats(ctypes.Structure):
108
+ _fields_ = [
109
+ ("num_trades", ctypes.c_int32),
110
+ ("num_wins", ctypes.c_int32),
111
+ ("num_losses", ctypes.c_int32),
112
+ ("num_even", ctypes.c_int32),
113
+ ("percent_profitable", ctypes.c_double),
114
+ ("net_profit", ctypes.c_double),
115
+ ("net_profit_pct", ctypes.c_double),
116
+ ("gross_profit", ctypes.c_double),
117
+ ("gross_profit_pct", ctypes.c_double),
118
+ ("gross_loss", ctypes.c_double),
119
+ ("gross_loss_pct", ctypes.c_double),
120
+ ("profit_factor", ctypes.c_double),
121
+ ("avg_trade", ctypes.c_double),
122
+ ("avg_trade_pct", ctypes.c_double),
123
+ ("avg_win", ctypes.c_double),
124
+ ("avg_win_pct", ctypes.c_double),
125
+ ("avg_loss", ctypes.c_double),
126
+ ("avg_loss_pct", ctypes.c_double),
127
+ ("ratio_avg_win_avg_loss", ctypes.c_double),
128
+ ("largest_win", ctypes.c_double),
129
+ ("largest_win_pct", ctypes.c_double),
130
+ ("largest_loss", ctypes.c_double),
131
+ ("largest_loss_pct", ctypes.c_double),
132
+ ("commission_paid", ctypes.c_double),
133
+ ("expectancy", ctypes.c_double),
134
+ ("max_consecutive_wins", ctypes.c_int32),
135
+ ("max_consecutive_losses", ctypes.c_int32),
136
+ ("avg_bars_in_trade", ctypes.c_double),
137
+ ("avg_bars_in_wins", ctypes.c_double),
138
+ ("avg_bars_in_losses", ctypes.c_double),
139
+ ]
140
+
141
+
142
+ class _PfEquityStats(ctypes.Structure):
143
+ _fields_ = [
144
+ ("max_equity_drawdown", ctypes.c_double),
145
+ ("max_equity_drawdown_pct", ctypes.c_double),
146
+ ("max_equity_runup", ctypes.c_double),
147
+ ("max_equity_runup_pct", ctypes.c_double),
148
+ ("buy_hold_return", ctypes.c_double),
149
+ ("buy_hold_return_pct", ctypes.c_double),
150
+ ("sharpe_tv", ctypes.c_double),
151
+ ("sortino_tv", ctypes.c_double),
152
+ ("sharpe_bar", ctypes.c_double),
153
+ ("sortino_bar", ctypes.c_double),
154
+ ("cagr", ctypes.c_double),
155
+ ("calmar", ctypes.c_double),
156
+ ("recovery_factor", ctypes.c_double),
157
+ ("time_in_market_pct", ctypes.c_double),
158
+ ("open_pl", ctypes.c_double),
159
+ ]
160
+
161
+
162
+ class _PfMetrics(ctypes.Structure):
163
+ _fields_ = [
164
+ ("all", _PfTradeStats),
165
+ ("longs", _PfTradeStats),
166
+ ("shorts", _PfTradeStats),
167
+ ("equity", _PfEquityStats),
168
+ ]
169
+
170
+
171
+ class _PfEquityPoint(ctypes.Structure):
172
+ _fields_ = [
173
+ ("time_ms", ctypes.c_int64),
174
+ ("equity", ctypes.c_double),
175
+ ("open_profit", ctypes.c_double),
176
+ ]
177
+
178
+
179
+ class _PfSecurityDiagnostic(ctypes.Structure):
180
+ _fields_ = [
181
+ ("sec_id", ctypes.c_int),
182
+ ("feed_count", ctypes.c_int64),
183
+ ("complete_count", ctypes.c_int64),
184
+ ("partial_count", ctypes.c_int64),
185
+ ]
186
+
187
+
188
+ class _PfTraceEntry(ctypes.Structure):
189
+ _fields_ = [
190
+ ("timestamp", ctypes.c_int64),
191
+ ("bar_index", ctypes.c_int32),
192
+ ("name_id", ctypes.c_int32),
193
+ ("value", ctypes.c_double),
194
+ ]
195
+
196
+
197
+ class _PfReport(ctypes.Structure):
198
+ _fields_ = [
199
+ ("total_trades", ctypes.c_int),
200
+ ("trades", ctypes.POINTER(_PfTrade)),
201
+ ("trades_len", ctypes.c_int),
202
+ ("net_profit", ctypes.c_double),
203
+ ("input_bars_processed", ctypes.c_int64),
204
+ ("script_bars_processed", ctypes.c_int64),
205
+ ("security_feeds_total", ctypes.c_int64),
206
+ ("security_complete_total", ctypes.c_int64),
207
+ ("security_partial_total", ctypes.c_int64),
208
+ ("magnifier_sub_bars_total", ctypes.c_int64),
209
+ ("magnifier_sample_ticks_total", ctypes.c_int64),
210
+ ("input_tf_seconds", ctypes.c_int),
211
+ ("script_tf_seconds", ctypes.c_int),
212
+ ("script_tf_ratio", ctypes.c_int),
213
+ ("needs_aggregation", ctypes.c_int),
214
+ ("bar_magnifier_enabled", ctypes.c_int),
215
+ ("security_diag", ctypes.POINTER(_PfSecurityDiagnostic)),
216
+ ("security_diag_len", ctypes.c_int),
217
+ ("trace", ctypes.POINTER(_PfTraceEntry)),
218
+ ("trace_len", ctypes.c_int),
219
+ ("trace_names", ctypes.POINTER(ctypes.c_char_p)),
220
+ ("trace_names_len", ctypes.c_int),
221
+ ("metrics", _PfMetrics),
222
+ ("equity_curve", ctypes.POINTER(_PfEquityPoint)),
223
+ ("equity_curve_len", ctypes.c_int64),
224
+ ]
225
+
226
+
227
+ _TRADE_STATS_FIELDS = (
228
+ "num_trades",
229
+ "num_wins",
230
+ "num_losses",
231
+ "num_even",
232
+ "percent_profitable",
233
+ "net_profit",
234
+ "net_profit_pct",
235
+ "gross_profit",
236
+ "gross_profit_pct",
237
+ "gross_loss",
238
+ "gross_loss_pct",
239
+ "profit_factor",
240
+ "avg_trade",
241
+ "avg_trade_pct",
242
+ "avg_win",
243
+ "avg_win_pct",
244
+ "avg_loss",
245
+ "avg_loss_pct",
246
+ "ratio_avg_win_avg_loss",
247
+ "largest_win",
248
+ "largest_win_pct",
249
+ "largest_loss",
250
+ "largest_loss_pct",
251
+ "commission_paid",
252
+ "expectancy",
253
+ "max_consecutive_wins",
254
+ "max_consecutive_losses",
255
+ "avg_bars_in_trade",
256
+ "avg_bars_in_wins",
257
+ "avg_bars_in_losses",
258
+ )
259
+ _EQUITY_STATS_FIELDS = (
260
+ "max_equity_drawdown",
261
+ "max_equity_drawdown_pct",
262
+ "max_equity_runup",
263
+ "max_equity_runup_pct",
264
+ "buy_hold_return",
265
+ "buy_hold_return_pct",
266
+ "sharpe_tv",
267
+ "sortino_tv",
268
+ "sharpe_bar",
269
+ "sortino_bar",
270
+ "cagr",
271
+ "calmar",
272
+ "recovery_factor",
273
+ "time_in_market_pct",
274
+ "open_pl",
275
+ )
276
+
277
+
278
+ def _json_number(value: float) -> float | None:
279
+ return value if isfinite(value) else None
280
+
281
+
282
+ def _numeric_structure(
283
+ structure: ctypes.Structure, field_names: Sequence[str]
284
+ ) -> dict[str, JsonValue]:
285
+ values: dict[str, JsonValue] = {}
286
+ for name in field_names:
287
+ value = getattr(structure, name)
288
+ if isinstance(value, float):
289
+ values[name] = _json_number(value)
290
+ elif isinstance(value, int):
291
+ values[name] = value
292
+ else:
293
+ raise EngineBacktestError(f"unsupported report field type for {name}")
294
+ return values
295
+
296
+
297
+ def _decode(value: bytes | None) -> str:
298
+ return value.decode("utf-8", "replace") if value else ""
299
+
300
+
301
+ class PineForgeBacktestRunner:
302
+ """Thin owner-safe wrapper around one compiled PineForge strategy library."""
303
+
304
+ def __init__(self, library: ctypes.CDLL) -> None:
305
+ self._library = library
306
+ self._check_abi()
307
+ self._configure_signatures()
308
+
309
+ @classmethod
310
+ def load(cls, strategy_library: str | Path) -> PineForgeBacktestRunner:
311
+ """Load a compiled strategy shared library from disk."""
312
+
313
+ path = Path(strategy_library).expanduser().resolve()
314
+ if not path.is_file():
315
+ raise FileNotFoundError(f"strategy library not found: {path}")
316
+ return cls(ctypes.CDLL(str(path)))
317
+
318
+ def _check_abi(self) -> None:
319
+ try:
320
+ self._library.pf_abi_version.argtypes = []
321
+ self._library.pf_abi_version.restype = ctypes.c_int
322
+ actual = int(self._library.pf_abi_version())
323
+ except AttributeError as exc:
324
+ raise EngineBacktestError(
325
+ "strategy library predates pf_abi_version; rebuild it with the current engine"
326
+ ) from exc
327
+ if actual != EXPECTED_PF_ABI:
328
+ raise EngineBacktestError(
329
+ f"PineForge ABI mismatch: strategy reports {actual}, expected {EXPECTED_PF_ABI}"
330
+ )
331
+
332
+ def _configure_signatures(self) -> None:
333
+ library = self._library
334
+ library.strategy_create.argtypes = [ctypes.c_char_p]
335
+ library.strategy_create.restype = ctypes.c_void_p
336
+ library.strategy_free.argtypes = [ctypes.c_void_p]
337
+ library.strategy_free.restype = None
338
+ library.run_backtest_full.argtypes = [
339
+ ctypes.c_void_p,
340
+ ctypes.POINTER(PfBar),
341
+ ctypes.c_int,
342
+ ctypes.c_char_p,
343
+ ctypes.c_char_p,
344
+ ctypes.c_int,
345
+ ctypes.c_int,
346
+ ctypes.c_int,
347
+ ctypes.POINTER(_PfReport),
348
+ ]
349
+ library.run_backtest_full.restype = None
350
+ library.report_free.argtypes = [ctypes.POINTER(_PfReport)]
351
+ library.report_free.restype = None
352
+ if hasattr(library, "strategy_get_last_error"):
353
+ library.strategy_get_last_error.argtypes = [ctypes.c_void_p]
354
+ library.strategy_get_last_error.restype = ctypes.c_char_p
355
+ if hasattr(library, "strategy_set_trace_enabled"):
356
+ library.strategy_set_trace_enabled.argtypes = [ctypes.c_void_p, ctypes.c_int]
357
+ library.strategy_set_trace_enabled.restype = None
358
+ if hasattr(library, "strategy_set_input"):
359
+ library.strategy_set_input.argtypes = [
360
+ ctypes.c_void_p,
361
+ ctypes.c_char_p,
362
+ ctypes.c_char_p,
363
+ ]
364
+ library.strategy_set_input.restype = None
365
+ if hasattr(library, "strategy_set_chart_timezone"):
366
+ library.strategy_set_chart_timezone.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
367
+ library.strategy_set_chart_timezone.restype = None
368
+ if hasattr(library, "strategy_set_syminfo_timezone"):
369
+ library.strategy_set_syminfo_timezone.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
370
+ library.strategy_set_syminfo_timezone.restype = None
371
+ if hasattr(library, "strategy_set_syminfo_session"):
372
+ library.strategy_set_syminfo_session.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
373
+ library.strategy_set_syminfo_session.restype = None
374
+ if hasattr(library, "strategy_set_trade_start_time"):
375
+ library.strategy_set_trade_start_time.argtypes = [ctypes.c_void_p, ctypes.c_int64]
376
+ library.strategy_set_trade_start_time.restype = None
377
+
378
+ def _apply_context(
379
+ self, state: int | ctypes.c_void_p, instrument: Instrument, options: BacktestOptions
380
+ ) -> None:
381
+ library = self._library
382
+ if options.trace_enabled and hasattr(library, "strategy_set_trace_enabled"):
383
+ library.strategy_set_trace_enabled(state, 1)
384
+ chart_timezone = options.chart_timezone or instrument.timezone
385
+ if chart_timezone and hasattr(library, "strategy_set_chart_timezone"):
386
+ library.strategy_set_chart_timezone(state, chart_timezone.encode())
387
+ if instrument.timezone and hasattr(library, "strategy_set_syminfo_timezone"):
388
+ library.strategy_set_syminfo_timezone(state, instrument.timezone.encode())
389
+ if instrument.session and hasattr(library, "strategy_set_syminfo_session"):
390
+ library.strategy_set_syminfo_session(state, instrument.session.encode())
391
+ if options.trade_start_time_ms is not None:
392
+ if not hasattr(library, "strategy_set_trade_start_time"):
393
+ raise EngineBacktestError(
394
+ "strategy library does not expose strategy_set_trade_start_time"
395
+ )
396
+ library.strategy_set_trade_start_time(state, options.trade_start_time_ms)
397
+
398
+ def _last_error(self, state: int | ctypes.c_void_p) -> str:
399
+ if not hasattr(self._library, "strategy_get_last_error"):
400
+ return ""
401
+ return _decode(self._library.strategy_get_last_error(state))
402
+
403
+ def run(
404
+ self,
405
+ bars: Sequence[Bar],
406
+ *,
407
+ instrument: Instrument,
408
+ options: BacktestOptions | None = None,
409
+ strategy_params: Mapping[str, JsonValue] | None = None,
410
+ ) -> BacktestReport:
411
+ """Run confirmed normalized bars and return a detached report."""
412
+
413
+ if not bars:
414
+ raise ValueError("bars must not be empty")
415
+ if len(bars) > 2**31 - 1:
416
+ raise ValueError("bar count exceeds the PineForge C ABI limit")
417
+ if any(left.timestamp_ms >= right.timestamp_ms for left, right in pairwise(bars)):
418
+ raise ValueError("bar timestamps must be strictly increasing")
419
+
420
+ runtime = options or BacktestOptions()
421
+ packed = pack_bars(bars, instrument=instrument)
422
+ params_json = json.dumps(
423
+ dict(strategy_params or {}), separators=(",", ":"), allow_nan=False
424
+ ).encode()
425
+ state = self._library.strategy_create(params_json)
426
+ if not state:
427
+ raise EngineBacktestError("strategy_create returned a null handle")
428
+
429
+ native_report = _PfReport()
430
+ try:
431
+ self._apply_context(state, instrument, runtime)
432
+ if hasattr(self._library, "strategy_set_input"):
433
+ for name, value in (strategy_params or {}).items():
434
+ self._library.strategy_set_input(
435
+ state,
436
+ name.encode(),
437
+ str(value).encode(),
438
+ )
439
+ self._library.run_backtest_full(
440
+ state,
441
+ packed,
442
+ len(packed),
443
+ runtime.input_timeframe.encode(),
444
+ runtime.script_timeframe.encode(),
445
+ int(runtime.bar_magnifier),
446
+ runtime.magnifier_samples,
447
+ int(runtime.magnifier_distribution),
448
+ ctypes.byref(native_report),
449
+ )
450
+ error = self._last_error(state)
451
+ if error:
452
+ raise EngineBacktestError(error)
453
+ return self._copy_report(native_report)
454
+ finally:
455
+ self._library.report_free(ctypes.byref(native_report))
456
+ self._library.strategy_free(state)
457
+
458
+ def _copy_report(self, report: _PfReport) -> BacktestReport:
459
+ summary: dict[str, JsonValue] = {
460
+ "total_trades": report.total_trades,
461
+ "net_profit": _json_number(report.net_profit),
462
+ "input_bars_processed": report.input_bars_processed,
463
+ "script_bars_processed": report.script_bars_processed,
464
+ "security_feeds_total": report.security_feeds_total,
465
+ "security_complete_total": report.security_complete_total,
466
+ "security_partial_total": report.security_partial_total,
467
+ "magnifier_sub_bars_total": report.magnifier_sub_bars_total,
468
+ "magnifier_sample_ticks_total": report.magnifier_sample_ticks_total,
469
+ "input_tf_seconds": report.input_tf_seconds,
470
+ "script_tf_seconds": report.script_tf_seconds,
471
+ "script_tf_ratio": report.script_tf_ratio,
472
+ "needs_aggregation": bool(report.needs_aggregation),
473
+ "bar_magnifier_enabled": bool(report.bar_magnifier_enabled),
474
+ }
475
+ metrics = {
476
+ "all": _numeric_structure(report.metrics.all, _TRADE_STATS_FIELDS),
477
+ "longs": _numeric_structure(report.metrics.longs, _TRADE_STATS_FIELDS),
478
+ "shorts": _numeric_structure(report.metrics.shorts, _TRADE_STATS_FIELDS),
479
+ "equity": _numeric_structure(report.metrics.equity, _EQUITY_STATS_FIELDS),
480
+ }
481
+ trades: list[Mapping[str, JsonValue]] = []
482
+ for index in range(report.trades_len):
483
+ trade = report.trades[index]
484
+ trades.append(
485
+ {
486
+ "entry_time": trade.entry_time,
487
+ "exit_time": trade.exit_time,
488
+ "entry_price": _json_number(trade.entry_price),
489
+ "exit_price": _json_number(trade.exit_price),
490
+ "pnl": _json_number(trade.pnl),
491
+ "pnl_pct": _json_number(trade.pnl_pct),
492
+ "is_long": bool(trade.is_long),
493
+ "max_runup": _json_number(trade.max_runup),
494
+ "max_drawdown": _json_number(trade.max_drawdown),
495
+ "qty": _json_number(trade.qty),
496
+ "commission": _json_number(trade.commission),
497
+ "entry_bar_index": trade.entry_bar_index,
498
+ "exit_bar_index": trade.exit_bar_index,
499
+ }
500
+ )
501
+
502
+ diagnostics: list[Mapping[str, JsonValue]] = []
503
+ for index in range(report.security_diag_len):
504
+ diagnostic = report.security_diag[index]
505
+ diagnostics.append(
506
+ {
507
+ "sec_id": diagnostic.sec_id,
508
+ "feed_count": diagnostic.feed_count,
509
+ "complete_count": diagnostic.complete_count,
510
+ "partial_count": diagnostic.partial_count,
511
+ }
512
+ )
513
+
514
+ trace_names = [
515
+ _decode(report.trace_names[index]) for index in range(report.trace_names_len)
516
+ ]
517
+ trace: list[Mapping[str, JsonValue]] = []
518
+ for index in range(report.trace_len):
519
+ entry = report.trace[index]
520
+ name = trace_names[entry.name_id] if 0 <= entry.name_id < len(trace_names) else None
521
+ trace.append(
522
+ {
523
+ "timestamp": entry.timestamp,
524
+ "bar_index": entry.bar_index,
525
+ "name_id": entry.name_id,
526
+ "name": name,
527
+ "value": _json_number(entry.value),
528
+ }
529
+ )
530
+
531
+ equity_curve: list[Mapping[str, JsonValue]] = []
532
+ for index in range(report.equity_curve_len):
533
+ point = report.equity_curve[index]
534
+ equity_curve.append(
535
+ {
536
+ "time_ms": point.time_ms,
537
+ "equity": _json_number(point.equity),
538
+ "open_profit": _json_number(point.open_profit),
539
+ }
540
+ )
541
+ return BacktestReport(
542
+ summary,
543
+ metrics,
544
+ tuple(trades),
545
+ tuple(diagnostics),
546
+ tuple(trace),
547
+ tuple(equity_curve),
548
+ )
549
+
550
+
551
+ if ctypes.sizeof(_PfReport) != 944 or _PfReport.metrics.offset != 160:
552
+ raise RuntimeError("unsupported platform ABI layout for PineForge reports")
@@ -0,0 +1 @@
1
+ """Command-line harnesses shipped with pineforge-data."""