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
pineforge_data/server.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""Bounded-concurrency FastAPI service backed by pineforge-release."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
import shutil
|
|
13
|
+
import signal
|
|
14
|
+
import tempfile
|
|
15
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Annotated, Literal, TypeAlias
|
|
19
|
+
from uuid import uuid4
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI, Header
|
|
22
|
+
from fastapi.responses import JSONResponse
|
|
23
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
24
|
+
|
|
25
|
+
from .backtest import BacktestOptions
|
|
26
|
+
from .compile_cache import CompileCache
|
|
27
|
+
from .models import Bar, Instrument
|
|
28
|
+
from .release_contract import (
|
|
29
|
+
DEFAULT_RELEASE_IMAGE,
|
|
30
|
+
RELEASE_ENTRYPOINT,
|
|
31
|
+
ReleaseContractError,
|
|
32
|
+
parse_release_report,
|
|
33
|
+
release_environment,
|
|
34
|
+
release_response,
|
|
35
|
+
write_release_inputs,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
InputValue: TypeAlias = str | int | float | bool
|
|
39
|
+
MagnifierName = Literal[
|
|
40
|
+
"uniform",
|
|
41
|
+
"cosine",
|
|
42
|
+
"triangle",
|
|
43
|
+
"endpoints",
|
|
44
|
+
"front_loaded",
|
|
45
|
+
"back_loaded",
|
|
46
|
+
]
|
|
47
|
+
_REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ApiInstrument(BaseModel):
|
|
51
|
+
model_config = ConfigDict(extra="forbid")
|
|
52
|
+
|
|
53
|
+
symbol: str = Field(min_length=1, max_length=256)
|
|
54
|
+
venue: str = Field(default="", max_length=128)
|
|
55
|
+
timezone: str = Field(default="UTC", min_length=1, max_length=128)
|
|
56
|
+
session: str = Field(default="24x7", min_length=1, max_length=256)
|
|
57
|
+
volume_unit: str = Field(default="base", min_length=1, max_length=64)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ApiBar(BaseModel):
|
|
61
|
+
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
|
62
|
+
|
|
63
|
+
timestamp_ms: int = Field(ge=0, le=2**63 - 1)
|
|
64
|
+
open: float = Field(gt=0)
|
|
65
|
+
high: float = Field(gt=0)
|
|
66
|
+
low: float = Field(gt=0)
|
|
67
|
+
close: float = Field(gt=0)
|
|
68
|
+
volume: float = Field(ge=0)
|
|
69
|
+
|
|
70
|
+
@model_validator(mode="after")
|
|
71
|
+
def validate_ohlc(self) -> ApiBar:
|
|
72
|
+
if self.high < max(self.open, self.close, self.low):
|
|
73
|
+
raise ValueError("high must be greater than or equal to OHLC values")
|
|
74
|
+
if self.low > min(self.open, self.close, self.high):
|
|
75
|
+
raise ValueError("low must be less than or equal to OHLC values")
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ApiBacktestOptions(BaseModel):
|
|
80
|
+
model_config = ConfigDict(extra="forbid")
|
|
81
|
+
|
|
82
|
+
input_timeframe: str = Field(default="", max_length=32)
|
|
83
|
+
script_timeframe: str = Field(default="", max_length=32)
|
|
84
|
+
bar_magnifier: bool = False
|
|
85
|
+
magnifier_samples: int = Field(default=4, ge=1, le=10_000)
|
|
86
|
+
magnifier_distribution: MagnifierName = "endpoints"
|
|
87
|
+
trace_enabled: bool = False
|
|
88
|
+
chart_timezone: str | None = Field(default=None, max_length=128)
|
|
89
|
+
trade_start_time_ms: int | None = Field(default=None, ge=0, le=2**63 - 1)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BacktestApiRequest(BaseModel):
|
|
93
|
+
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
|
94
|
+
|
|
95
|
+
pine_source: str = Field(min_length=1, max_length=2_000_000)
|
|
96
|
+
bars: list[ApiBar] = Field(min_length=1, max_length=1_000_000)
|
|
97
|
+
instrument: ApiInstrument
|
|
98
|
+
source: str = Field(default="provider", min_length=1, max_length=256)
|
|
99
|
+
options: ApiBacktestOptions = Field(default_factory=ApiBacktestOptions)
|
|
100
|
+
strategy_params: dict[str, InputValue] = Field(default_factory=dict)
|
|
101
|
+
strategy_overrides: dict[str, InputValue] = Field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
@model_validator(mode="after")
|
|
104
|
+
def validate_timestamps(self) -> BacktestApiRequest:
|
|
105
|
+
if any(
|
|
106
|
+
left.timestamp_ms >= right.timestamp_ms
|
|
107
|
+
for left, right in zip(self.bars, self.bars[1:], strict=False)
|
|
108
|
+
):
|
|
109
|
+
raise ValueError("bar timestamps must be strictly increasing")
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def domain_values(
|
|
113
|
+
self,
|
|
114
|
+
) -> tuple[str, list[Bar], Instrument, BacktestOptions]:
|
|
115
|
+
instrument = Instrument(
|
|
116
|
+
self.instrument.symbol,
|
|
117
|
+
venue=self.instrument.venue,
|
|
118
|
+
timezone=self.instrument.timezone,
|
|
119
|
+
session=self.instrument.session,
|
|
120
|
+
volume_unit=self.instrument.volume_unit,
|
|
121
|
+
)
|
|
122
|
+
bars = [
|
|
123
|
+
Bar(
|
|
124
|
+
instrument,
|
|
125
|
+
value.timestamp_ms,
|
|
126
|
+
value.open,
|
|
127
|
+
value.high,
|
|
128
|
+
value.low,
|
|
129
|
+
value.close,
|
|
130
|
+
value.volume,
|
|
131
|
+
self.source,
|
|
132
|
+
)
|
|
133
|
+
for value in self.bars
|
|
134
|
+
]
|
|
135
|
+
from .backtest import MagnifierDistribution
|
|
136
|
+
|
|
137
|
+
options = BacktestOptions(
|
|
138
|
+
input_timeframe=self.options.input_timeframe,
|
|
139
|
+
script_timeframe=self.options.script_timeframe,
|
|
140
|
+
bar_magnifier=self.options.bar_magnifier,
|
|
141
|
+
magnifier_samples=self.options.magnifier_samples,
|
|
142
|
+
magnifier_distribution=MagnifierDistribution[
|
|
143
|
+
self.options.magnifier_distribution.upper()
|
|
144
|
+
],
|
|
145
|
+
trace_enabled=self.options.trace_enabled,
|
|
146
|
+
chart_timezone=self.options.chart_timezone,
|
|
147
|
+
trade_start_time_ms=self.options.trade_start_time_ms,
|
|
148
|
+
)
|
|
149
|
+
return self.pine_source, bars, instrument, options
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class BacktestServiceError(RuntimeError):
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
code: str,
|
|
156
|
+
message: str,
|
|
157
|
+
*,
|
|
158
|
+
status_code: int,
|
|
159
|
+
phase: str,
|
|
160
|
+
) -> None:
|
|
161
|
+
super().__init__(message)
|
|
162
|
+
self.code = code
|
|
163
|
+
self.status_code = status_code
|
|
164
|
+
self.phase = phase
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True, slots=True)
|
|
168
|
+
class ExecutionResult:
|
|
169
|
+
report: Mapping[str, object]
|
|
170
|
+
runtime_metadata: Mapping[str, object]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
Executor = Callable[[BacktestApiRequest], Awaitable[ExecutionResult]]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _positive_env(name: str, default: int) -> int:
|
|
177
|
+
raw = os.environ.get(name, str(default))
|
|
178
|
+
try:
|
|
179
|
+
value = int(raw)
|
|
180
|
+
except ValueError as exc:
|
|
181
|
+
raise RuntimeError(f"{name} must be an integer") from exc
|
|
182
|
+
if value <= 0:
|
|
183
|
+
raise RuntimeError(f"{name} must be positive")
|
|
184
|
+
return value
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _non_negative_env(name: str, default: int) -> int:
|
|
188
|
+
raw = os.environ.get(name, str(default))
|
|
189
|
+
try:
|
|
190
|
+
value = int(raw)
|
|
191
|
+
except ValueError as exc:
|
|
192
|
+
raise RuntimeError(f"{name} must be an integer") from exc
|
|
193
|
+
if value < 0:
|
|
194
|
+
raise RuntimeError(f"{name} must be non-negative")
|
|
195
|
+
return value
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class BacktestService:
|
|
199
|
+
"""Admit a bounded number of jobs and execute them in isolated directories."""
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
*,
|
|
204
|
+
max_concurrency: int | None = None,
|
|
205
|
+
max_queue: int | None = None,
|
|
206
|
+
queue_timeout_seconds: float | None = None,
|
|
207
|
+
execution_timeout_seconds: float | None = None,
|
|
208
|
+
release_image: str | None = None,
|
|
209
|
+
entrypoint: Path | None = None,
|
|
210
|
+
run_json_path: Path | None = None,
|
|
211
|
+
cache: CompileCache | None = None,
|
|
212
|
+
executor: Executor | None = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
cpu_default = max(1, min(4, os.cpu_count() or 1))
|
|
215
|
+
self.max_concurrency = (
|
|
216
|
+
max_concurrency
|
|
217
|
+
if max_concurrency is not None
|
|
218
|
+
else _positive_env("PINEFORGE_SERVER_CONCURRENCY", cpu_default)
|
|
219
|
+
)
|
|
220
|
+
self.max_queue = (
|
|
221
|
+
max_queue
|
|
222
|
+
if max_queue is not None
|
|
223
|
+
else _non_negative_env("PINEFORGE_SERVER_MAX_QUEUE", self.max_concurrency * 2)
|
|
224
|
+
)
|
|
225
|
+
self.queue_timeout_seconds = (
|
|
226
|
+
queue_timeout_seconds
|
|
227
|
+
if queue_timeout_seconds is not None
|
|
228
|
+
else float(os.environ.get("PINEFORGE_SERVER_QUEUE_TIMEOUT", "30"))
|
|
229
|
+
)
|
|
230
|
+
self.execution_timeout_seconds = (
|
|
231
|
+
execution_timeout_seconds
|
|
232
|
+
if execution_timeout_seconds is not None
|
|
233
|
+
else float(os.environ.get("PINEFORGE_SERVER_EXECUTION_TIMEOUT", "300"))
|
|
234
|
+
)
|
|
235
|
+
if self.max_concurrency <= 0 or self.max_queue < 0:
|
|
236
|
+
raise ValueError("concurrency must be positive and max_queue non-negative")
|
|
237
|
+
if self.queue_timeout_seconds <= 0 or self.execution_timeout_seconds <= 0:
|
|
238
|
+
raise ValueError("queue and execution timeouts must be positive")
|
|
239
|
+
self.release_image = release_image or os.environ.get(
|
|
240
|
+
"PINEFORGE_RELEASE_IMAGE", DEFAULT_RELEASE_IMAGE
|
|
241
|
+
)
|
|
242
|
+
self.entrypoint = entrypoint or Path(
|
|
243
|
+
os.environ.get("PINEFORGE_RELEASE_ENTRYPOINT", RELEASE_ENTRYPOINT)
|
|
244
|
+
)
|
|
245
|
+
self.run_json_path = run_json_path or Path(
|
|
246
|
+
os.environ.get("PINEFORGE_RELEASE_RUN_JSON", "/opt/pineforge/bin/run_json.py")
|
|
247
|
+
)
|
|
248
|
+
self.cache = cache or CompileCache(
|
|
249
|
+
Path(os.environ.get("PINEFORGE_SERVER_CACHE_DIR", "/tmp/pineforge-compile-cache")),
|
|
250
|
+
max_entries=_positive_env("PINEFORGE_SERVER_CACHE_MAX_ENTRIES", 1_024),
|
|
251
|
+
max_bytes=_positive_env("PINEFORGE_SERVER_CACHE_MAX_BYTES", 2 * 1_024 * 1_024 * 1_024),
|
|
252
|
+
)
|
|
253
|
+
self._executor = executor or self._execute_release
|
|
254
|
+
self._requires_entrypoint = executor is None
|
|
255
|
+
self._semaphore = asyncio.Semaphore(self.max_concurrency)
|
|
256
|
+
self._state_lock = asyncio.Lock()
|
|
257
|
+
self._admitted = 0
|
|
258
|
+
self._running = 0
|
|
259
|
+
|
|
260
|
+
def ready(self) -> bool:
|
|
261
|
+
return not self._requires_entrypoint or (
|
|
262
|
+
self.entrypoint.is_file()
|
|
263
|
+
and os.access(self.entrypoint, os.X_OK)
|
|
264
|
+
and self.run_json_path.is_file()
|
|
265
|
+
and shutil.which("g++") is not None
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
async def status(self) -> dict[str, object]:
|
|
269
|
+
cache_status = await self.cache.status()
|
|
270
|
+
async with self._state_lock:
|
|
271
|
+
return {
|
|
272
|
+
"ready": self.ready(),
|
|
273
|
+
"running": self._running,
|
|
274
|
+
"queued": self._admitted - self._running,
|
|
275
|
+
"max_concurrency": self.max_concurrency,
|
|
276
|
+
"max_queue": self.max_queue,
|
|
277
|
+
"release_image": self.release_image,
|
|
278
|
+
"compile_cache": cache_status,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async def _admit(self) -> None:
|
|
282
|
+
async with self._state_lock:
|
|
283
|
+
capacity = self.max_concurrency + self.max_queue
|
|
284
|
+
if self._admitted >= capacity:
|
|
285
|
+
raise BacktestServiceError(
|
|
286
|
+
"server_overloaded",
|
|
287
|
+
"backtest capacity is full; retry later",
|
|
288
|
+
status_code=429,
|
|
289
|
+
phase="queue",
|
|
290
|
+
)
|
|
291
|
+
self._admitted += 1
|
|
292
|
+
|
|
293
|
+
async def _release_admission(self) -> None:
|
|
294
|
+
async with self._state_lock:
|
|
295
|
+
self._admitted -= 1
|
|
296
|
+
|
|
297
|
+
async def run(self, request: BacktestApiRequest, request_id: str) -> dict[str, object]:
|
|
298
|
+
await self._admit()
|
|
299
|
+
acquired = False
|
|
300
|
+
try:
|
|
301
|
+
try:
|
|
302
|
+
await asyncio.wait_for(
|
|
303
|
+
self._semaphore.acquire(), timeout=self.queue_timeout_seconds
|
|
304
|
+
)
|
|
305
|
+
acquired = True
|
|
306
|
+
except TimeoutError as exc:
|
|
307
|
+
raise BacktestServiceError(
|
|
308
|
+
"queue_timeout",
|
|
309
|
+
"backtest did not reach an execution slot before the queue timeout",
|
|
310
|
+
status_code=503,
|
|
311
|
+
phase="queue",
|
|
312
|
+
) from exc
|
|
313
|
+
async with self._state_lock:
|
|
314
|
+
self._running += 1
|
|
315
|
+
try:
|
|
316
|
+
result = await self._executor(request)
|
|
317
|
+
finally:
|
|
318
|
+
async with self._state_lock:
|
|
319
|
+
self._running -= 1
|
|
320
|
+
return release_response(
|
|
321
|
+
result.report,
|
|
322
|
+
release_image=self.release_image,
|
|
323
|
+
mode="fastapi-server",
|
|
324
|
+
request_id=request_id,
|
|
325
|
+
runtime_metadata=result.runtime_metadata,
|
|
326
|
+
)
|
|
327
|
+
finally:
|
|
328
|
+
if acquired:
|
|
329
|
+
self._semaphore.release()
|
|
330
|
+
await self._release_admission()
|
|
331
|
+
|
|
332
|
+
async def _run_command(
|
|
333
|
+
self,
|
|
334
|
+
command: list[str],
|
|
335
|
+
*,
|
|
336
|
+
environment: Mapping[str, str],
|
|
337
|
+
phase: str,
|
|
338
|
+
deadline: float,
|
|
339
|
+
) -> bytes:
|
|
340
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
341
|
+
if remaining <= 0:
|
|
342
|
+
raise BacktestServiceError(
|
|
343
|
+
"execution_timeout",
|
|
344
|
+
f"backtest exceeded {self.execution_timeout_seconds:g} seconds",
|
|
345
|
+
status_code=504,
|
|
346
|
+
phase=phase,
|
|
347
|
+
)
|
|
348
|
+
try:
|
|
349
|
+
process = await asyncio.create_subprocess_exec(
|
|
350
|
+
*command,
|
|
351
|
+
stdout=asyncio.subprocess.PIPE,
|
|
352
|
+
stderr=asyncio.subprocess.PIPE,
|
|
353
|
+
env=environment,
|
|
354
|
+
start_new_session=True,
|
|
355
|
+
)
|
|
356
|
+
except OSError as exc:
|
|
357
|
+
raise BacktestServiceError(
|
|
358
|
+
"runtime_unavailable",
|
|
359
|
+
f"cannot start {phase} process: {exc}",
|
|
360
|
+
status_code=503,
|
|
361
|
+
phase=phase,
|
|
362
|
+
) from exc
|
|
363
|
+
try:
|
|
364
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=remaining)
|
|
365
|
+
except TimeoutError as exc:
|
|
366
|
+
try:
|
|
367
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
368
|
+
except (ProcessLookupError, PermissionError):
|
|
369
|
+
process.kill()
|
|
370
|
+
await process.communicate()
|
|
371
|
+
raise BacktestServiceError(
|
|
372
|
+
"execution_timeout",
|
|
373
|
+
f"backtest exceeded {self.execution_timeout_seconds:g} seconds",
|
|
374
|
+
status_code=504,
|
|
375
|
+
phase=phase,
|
|
376
|
+
) from exc
|
|
377
|
+
if process.returncode != 0:
|
|
378
|
+
detail = stderr.decode("utf-8", "replace").strip() or "pineforge-release failed"
|
|
379
|
+
raise BacktestServiceError(
|
|
380
|
+
f"{phase}_failed",
|
|
381
|
+
detail,
|
|
382
|
+
status_code=422 if phase in ("input", "transpile", "compile", "backtest") else 500,
|
|
383
|
+
phase=phase,
|
|
384
|
+
)
|
|
385
|
+
return stdout
|
|
386
|
+
|
|
387
|
+
def _cache_key(self, generated_cpp: bytes) -> tuple[str, str]:
|
|
388
|
+
cpp_sha256 = hashlib.sha256(generated_cpp).hexdigest()
|
|
389
|
+
identity = "\0".join(
|
|
390
|
+
(
|
|
391
|
+
"pineforge-compiled-strategy-v1",
|
|
392
|
+
cpp_sha256,
|
|
393
|
+
self.release_image,
|
|
394
|
+
os.environ.get("PINEFORGE_ENGINE_VERSION", "unknown"),
|
|
395
|
+
os.environ.get("PINEFORGE_CODEGEN_VERSION", "unknown"),
|
|
396
|
+
os.environ.get("PINEFORGE_RELEASE_VERSION", "unknown"),
|
|
397
|
+
platform.machine(),
|
|
398
|
+
"g++-std=c++17-O2-ffp-contract=off-fPIC-shared-whole-archive",
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
return hashlib.sha256(identity.encode()).hexdigest(), cpp_sha256
|
|
402
|
+
|
|
403
|
+
async def _execute_release(self, request: BacktestApiRequest) -> ExecutionResult:
|
|
404
|
+
pine_source, bars, instrument, options = request.domain_values()
|
|
405
|
+
deadline = asyncio.get_running_loop().time() + self.execution_timeout_seconds
|
|
406
|
+
with tempfile.TemporaryDirectory(prefix="pineforge-server-") as temporary:
|
|
407
|
+
workspace = Path(temporary)
|
|
408
|
+
try:
|
|
409
|
+
await asyncio.to_thread(
|
|
410
|
+
write_release_inputs,
|
|
411
|
+
workspace,
|
|
412
|
+
pine_source,
|
|
413
|
+
bars,
|
|
414
|
+
instrument,
|
|
415
|
+
)
|
|
416
|
+
release_environment(
|
|
417
|
+
str(workspace),
|
|
418
|
+
instrument,
|
|
419
|
+
options,
|
|
420
|
+
request.strategy_params,
|
|
421
|
+
request.strategy_overrides,
|
|
422
|
+
)
|
|
423
|
+
except (ReleaseContractError, ValueError) as exc:
|
|
424
|
+
raise BacktestServiceError(
|
|
425
|
+
"invalid_request",
|
|
426
|
+
str(exc),
|
|
427
|
+
status_code=400,
|
|
428
|
+
phase="input",
|
|
429
|
+
) from exc
|
|
430
|
+
environment = os.environ.copy()
|
|
431
|
+
environment.update(
|
|
432
|
+
{
|
|
433
|
+
"PINEFORGE_IN_DIR": str(workspace),
|
|
434
|
+
"PINEFORGE_TRANSPILE_ONLY": "1",
|
|
435
|
+
}
|
|
436
|
+
)
|
|
437
|
+
generated_cpp = await self._run_command(
|
|
438
|
+
[str(self.entrypoint)],
|
|
439
|
+
environment=environment,
|
|
440
|
+
phase="transpile",
|
|
441
|
+
deadline=deadline,
|
|
442
|
+
)
|
|
443
|
+
generated_path = workspace / "strategy.cpp"
|
|
444
|
+
await asyncio.to_thread(generated_path.write_bytes, generated_cpp)
|
|
445
|
+
cache_key, cpp_sha256 = self._cache_key(generated_cpp)
|
|
446
|
+
inputs = json.dumps(request.strategy_params, separators=(",", ":"), allow_nan=False)
|
|
447
|
+
overrides = json.dumps(
|
|
448
|
+
request.strategy_overrides, separators=(",", ":"), allow_nan=False
|
|
449
|
+
)
|
|
450
|
+
cache_hit = False
|
|
451
|
+
async with self.cache.compile_lock(cache_key):
|
|
452
|
+
strategy_library = await self.cache.acquire(cache_key)
|
|
453
|
+
if strategy_library is None:
|
|
454
|
+
temporary_library = self.cache.temporary_path(cache_key)
|
|
455
|
+
prefix = os.environ.get("PINEFORGE_PREFIX", "/opt/pineforge")
|
|
456
|
+
compile_command = [
|
|
457
|
+
"g++",
|
|
458
|
+
"-std=c++17",
|
|
459
|
+
"-O2",
|
|
460
|
+
"-ffp-contract=off",
|
|
461
|
+
"-fPIC",
|
|
462
|
+
"-shared",
|
|
463
|
+
f"-I{prefix}/include",
|
|
464
|
+
"-I/usr/include/eigen3",
|
|
465
|
+
str(generated_path),
|
|
466
|
+
"-Wl,--whole-archive",
|
|
467
|
+
f"{prefix}/lib/libpineforge.a",
|
|
468
|
+
"-Wl,--no-whole-archive",
|
|
469
|
+
"-o",
|
|
470
|
+
str(temporary_library),
|
|
471
|
+
]
|
|
472
|
+
try:
|
|
473
|
+
await self._run_command(
|
|
474
|
+
compile_command,
|
|
475
|
+
environment=os.environ.copy(),
|
|
476
|
+
phase="compile",
|
|
477
|
+
deadline=deadline,
|
|
478
|
+
)
|
|
479
|
+
strategy_library = await self.cache.commit_and_acquire(
|
|
480
|
+
cache_key, temporary_library
|
|
481
|
+
)
|
|
482
|
+
finally:
|
|
483
|
+
if temporary_library.exists():
|
|
484
|
+
temporary_library.unlink()
|
|
485
|
+
else:
|
|
486
|
+
cache_hit = True
|
|
487
|
+
if strategy_library is None:
|
|
488
|
+
raise BacktestServiceError(
|
|
489
|
+
"compile_failed",
|
|
490
|
+
"compiled strategy cache artifact was not created",
|
|
491
|
+
status_code=500,
|
|
492
|
+
phase="compile",
|
|
493
|
+
)
|
|
494
|
+
command = [
|
|
495
|
+
"python3",
|
|
496
|
+
str(self.run_json_path),
|
|
497
|
+
"--so",
|
|
498
|
+
str(strategy_library),
|
|
499
|
+
"--ohlcv",
|
|
500
|
+
str(workspace / "ohlcv.csv"),
|
|
501
|
+
"--inputs",
|
|
502
|
+
inputs,
|
|
503
|
+
"--overrides",
|
|
504
|
+
overrides,
|
|
505
|
+
"--input-tf",
|
|
506
|
+
options.input_timeframe,
|
|
507
|
+
"--script-tf",
|
|
508
|
+
options.script_timeframe,
|
|
509
|
+
"--bar-magnifier",
|
|
510
|
+
"true" if options.bar_magnifier else "false",
|
|
511
|
+
"--magnifier-samples",
|
|
512
|
+
str(options.magnifier_samples),
|
|
513
|
+
"--magnifier-dist",
|
|
514
|
+
options.magnifier_distribution.name.lower(),
|
|
515
|
+
"--generated-cpp",
|
|
516
|
+
str(generated_path),
|
|
517
|
+
"--transpiled",
|
|
518
|
+
"true",
|
|
519
|
+
"--syminfo",
|
|
520
|
+
str(workspace / "syminfo.json"),
|
|
521
|
+
"--chart-tz",
|
|
522
|
+
options.chart_timezone or "",
|
|
523
|
+
]
|
|
524
|
+
if options.trade_start_time_ms is not None:
|
|
525
|
+
command.extend(("--trade-start-ms", str(options.trade_start_time_ms)))
|
|
526
|
+
try:
|
|
527
|
+
stdout = await self._run_command(
|
|
528
|
+
command,
|
|
529
|
+
environment=os.environ.copy(),
|
|
530
|
+
phase="backtest",
|
|
531
|
+
deadline=deadline,
|
|
532
|
+
)
|
|
533
|
+
finally:
|
|
534
|
+
await self.cache.release(cache_key)
|
|
535
|
+
await self.cache.trim()
|
|
536
|
+
try:
|
|
537
|
+
report = parse_release_report(stdout.decode("utf-8", "replace"))
|
|
538
|
+
except ReleaseContractError as exc:
|
|
539
|
+
raise BacktestServiceError(
|
|
540
|
+
"invalid_release_response",
|
|
541
|
+
str(exc),
|
|
542
|
+
status_code=502,
|
|
543
|
+
phase="response",
|
|
544
|
+
) from exc
|
|
545
|
+
return ExecutionResult(
|
|
546
|
+
report,
|
|
547
|
+
{
|
|
548
|
+
"release_version": os.environ.get("PINEFORGE_RELEASE_VERSION", "unknown"),
|
|
549
|
+
"engine_version": os.environ.get("PINEFORGE_ENGINE_VERSION", "unknown"),
|
|
550
|
+
"codegen_version": os.environ.get("PINEFORGE_CODEGEN_VERSION", "unknown"),
|
|
551
|
+
"compile_cache": {
|
|
552
|
+
"key": cache_key,
|
|
553
|
+
"hit": cache_hit,
|
|
554
|
+
"generated_cpp_sha256": cpp_sha256,
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _request_id(value: str | None) -> str:
|
|
561
|
+
if value is None:
|
|
562
|
+
return uuid4().hex
|
|
563
|
+
if not _REQUEST_ID.fullmatch(value):
|
|
564
|
+
raise BacktestServiceError(
|
|
565
|
+
"invalid_request_id",
|
|
566
|
+
"X-Request-ID must contain 1-128 safe identifier characters",
|
|
567
|
+
status_code=400,
|
|
568
|
+
phase="request",
|
|
569
|
+
)
|
|
570
|
+
return value
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def create_app(
|
|
574
|
+
service: BacktestService | None = None,
|
|
575
|
+
*,
|
|
576
|
+
api_key: str | None = None,
|
|
577
|
+
) -> FastAPI:
|
|
578
|
+
runtime = service or BacktestService()
|
|
579
|
+
expected_api_key = (
|
|
580
|
+
api_key if api_key is not None else os.environ.get("PINEFORGE_SERVER_API_KEY", "")
|
|
581
|
+
)
|
|
582
|
+
application = FastAPI(
|
|
583
|
+
title="PineForge Backtest Server",
|
|
584
|
+
version="1",
|
|
585
|
+
docs_url="/docs",
|
|
586
|
+
redoc_url=None,
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
@application.get("/healthz")
|
|
590
|
+
async def healthz() -> dict[str, str]:
|
|
591
|
+
return {"status": "ok"}
|
|
592
|
+
|
|
593
|
+
@application.get("/readyz")
|
|
594
|
+
async def readyz() -> JSONResponse:
|
|
595
|
+
status = await runtime.status()
|
|
596
|
+
return JSONResponse(status_code=200 if status["ready"] else 503, content=status)
|
|
597
|
+
|
|
598
|
+
@application.post("/v1/backtests")
|
|
599
|
+
async def backtest(
|
|
600
|
+
request: BacktestApiRequest,
|
|
601
|
+
authorization: Annotated[str | None, Header()] = None,
|
|
602
|
+
x_request_id: Annotated[str | None, Header()] = None,
|
|
603
|
+
) -> JSONResponse:
|
|
604
|
+
request_id = ""
|
|
605
|
+
try:
|
|
606
|
+
request_id = _request_id(x_request_id)
|
|
607
|
+
if expected_api_key:
|
|
608
|
+
scheme, _, credential = (authorization or "").partition(" ")
|
|
609
|
+
supplied = credential if scheme.casefold() == "bearer" else ""
|
|
610
|
+
if not secrets.compare_digest(supplied, expected_api_key):
|
|
611
|
+
raise BacktestServiceError(
|
|
612
|
+
"unauthorized",
|
|
613
|
+
"a valid bearer token is required",
|
|
614
|
+
status_code=401,
|
|
615
|
+
phase="authorization",
|
|
616
|
+
)
|
|
617
|
+
result = await runtime.run(request, request_id)
|
|
618
|
+
return JSONResponse(status_code=200, content=result)
|
|
619
|
+
except BacktestServiceError as exc:
|
|
620
|
+
return JSONResponse(
|
|
621
|
+
status_code=exc.status_code,
|
|
622
|
+
content={
|
|
623
|
+
"error": {
|
|
624
|
+
"code": exc.code,
|
|
625
|
+
"phase": exc.phase,
|
|
626
|
+
"message": str(exc),
|
|
627
|
+
"request_id": request_id or None,
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
return application
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
app = create_app()
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def main() -> None:
|
|
639
|
+
import uvicorn
|
|
640
|
+
|
|
641
|
+
uvicorn.run(
|
|
642
|
+
"pineforge_data.server:app",
|
|
643
|
+
host=os.environ.get("PINEFORGE_SERVER_HOST", "0.0.0.0"),
|
|
644
|
+
port=int(os.environ.get("PINEFORGE_SERVER_PORT", "8000")),
|
|
645
|
+
workers=1,
|
|
646
|
+
)
|