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.
- pineforge_data/__init__.py +124 -0
- pineforge_data/backtest.py +552 -0
- pineforge_data/cli/__init__.py +1 -0
- pineforge_data/cli/backtest.py +354 -0
- pineforge_data/compile_cache.py +171 -0
- pineforge_data/docker_runtime.py +226 -0
- pineforge_data/engine.py +185 -0
- pineforge_data/errors.py +5 -0
- pineforge_data/models.py +199 -0
- pineforge_data/providers/__init__.py +69 -0
- pineforge_data/providers/base.py +51 -0
- pineforge_data/providers/ccxt.py +411 -0
- pineforge_data/providers/local.py +207 -0
- pineforge_data/providers/registry.py +252 -0
- pineforge_data/providers/sqlalchemy.py +138 -0
- pineforge_data/providers/tabular.py +530 -0
- pineforge_data/py.typed +1 -0
- pineforge_data/release_contract.py +153 -0
- pineforge_data/requests.py +87 -0
- pineforge_data/server.py +646 -0
- pineforge_data/server_client.py +142 -0
- pineforge_data-0.2.0.dist-info/METADATA +364 -0
- pineforge_data-0.2.0.dist-info/RECORD +26 -0
- pineforge_data-0.2.0.dist-info/WHEEL +4 -0
- pineforge_data-0.2.0.dist-info/entry_points.txt +3 -0
- pineforge_data-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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."""
|