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,354 @@
1
+ """Fetch provider OHLCV and run a PineForge strategy without intermediate files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ from dataclasses import replace
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import cast
15
+
16
+ from ..backtest import BacktestOptions, JsonValue
17
+ from ..docker_runtime import DockerBacktestRuntime
18
+ from ..models import MarketListing
19
+ from ..providers import create_provider
20
+ from ..release_contract import DEFAULT_RELEASE_IMAGE
21
+ from ..requests import BarRequest
22
+ from ..server_client import FastApiBacktestClient
23
+
24
+
25
+ def parse_timestamp(value: str) -> int:
26
+ """Parse Unix milliseconds or a timezone-aware ISO-8601 timestamp."""
27
+
28
+ try:
29
+ timestamp = int(value)
30
+ except ValueError:
31
+ try:
32
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
33
+ except ValueError as exc:
34
+ raise argparse.ArgumentTypeError(f"invalid timestamp: {value}") from exc
35
+ if parsed.tzinfo is None:
36
+ raise argparse.ArgumentTypeError("ISO timestamps must include a timezone") from None
37
+ timestamp = int(parsed.timestamp() * 1_000)
38
+ if timestamp < 0:
39
+ raise argparse.ArgumentTypeError("timestamps must be non-negative")
40
+ return timestamp
41
+
42
+
43
+ def source_timeframe_to_pine(timeframe: str) -> str:
44
+ """Translate common compact timeframe spelling into Pine timeframe spelling."""
45
+
46
+ match = re.fullmatch(r"([1-9][0-9]*)([smhdwM])", timeframe)
47
+ if match is None:
48
+ raise ValueError(f"cannot translate source timeframe to PineForge: {timeframe}")
49
+ count = int(match.group(1))
50
+ unit = match.group(2)
51
+ if unit == "s":
52
+ return f"{count}S"
53
+ if unit == "m":
54
+ return str(count)
55
+ if unit == "h":
56
+ return str(count * 60)
57
+ return f"{count}{unit.upper() if unit != 'M' else unit}"
58
+
59
+
60
+ def warmup_request_start_ms(start_ms: int, timeframe: str, warmup_bars: int) -> int:
61
+ """Return an inclusive provider start that can contain ``warmup_bars`` bars."""
62
+
63
+ if warmup_bars < 0:
64
+ raise ValueError("warmup_bars must be non-negative")
65
+ if warmup_bars == 0:
66
+ return start_ms
67
+ match = re.fullmatch(r"([1-9][0-9]*)([smhdwM])", timeframe)
68
+ if match is None:
69
+ raise ValueError(f"cannot calculate warmup for source timeframe: {timeframe}")
70
+ count = int(match.group(1))
71
+ unit = match.group(2)
72
+ # A calendar month has no fixed duration. Thirty-one days deliberately
73
+ # over-fetches; run_harness trims the result to the requested bar count.
74
+ unit_ms = {
75
+ "s": 1_000,
76
+ "m": 60_000,
77
+ "h": 3_600_000,
78
+ "d": 86_400_000,
79
+ "w": 604_800_000,
80
+ "M": 2_678_400_000,
81
+ }[unit]
82
+ duration_ms = count * unit_ms * warmup_bars
83
+ return max(0, start_ms - duration_ms)
84
+
85
+
86
+ # Backward-compatible import for callers of the CCXT-only bootstrap API.
87
+ ccxt_timeframe_to_pine = source_timeframe_to_pine
88
+
89
+
90
+ def _load_json_object(path: Path | None) -> dict[str, JsonValue]:
91
+ if path is None:
92
+ return {}
93
+ value = json.loads(path.read_text(encoding="utf-8"))
94
+ if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
95
+ raise ValueError(f"JSON file must contain an object: {path}")
96
+ return cast(dict[str, JsonValue], value)
97
+
98
+
99
+ def _market_payload(listing: MarketListing) -> dict[str, JsonValue]:
100
+ instrument = listing.instrument
101
+ contract = instrument.contract
102
+ contract_payload: JsonValue = None
103
+ if contract is not None:
104
+ contract_payload = {
105
+ "contract_size": contract.contract_size,
106
+ "linear": contract.linear,
107
+ "inverse": contract.inverse,
108
+ "expiry_ms": contract.expiry_ms,
109
+ "strike": contract.strike,
110
+ "option_type": contract.option_type.value if contract.option_type else None,
111
+ }
112
+ return {
113
+ "symbol": instrument.symbol,
114
+ "provider_id": instrument.provider_id,
115
+ "asset_class": instrument.asset_class.value,
116
+ "market_type": instrument.market_type.value,
117
+ "base": instrument.base,
118
+ "quote": instrument.quote,
119
+ "settle": instrument.settle,
120
+ "volume_unit": instrument.volume_unit,
121
+ "active": listing.active,
122
+ "margin_supported": listing.margin_supported,
123
+ "contract": contract_payload,
124
+ }
125
+
126
+
127
+ def build_parser() -> argparse.ArgumentParser:
128
+ parser = argparse.ArgumentParser(
129
+ prog="pineforge-backtest",
130
+ description="Transpile raw PineScript and backtest provider OHLCV in Docker",
131
+ )
132
+ parser.add_argument("--pine", type=Path, required=True, help="raw PineScript v6 strategy")
133
+ parser.add_argument(
134
+ "--provider", default="ccxt", help="provider adapter name; defaults to ccxt"
135
+ )
136
+ parser.add_argument(
137
+ "--venue",
138
+ "--exchange",
139
+ dest="venue",
140
+ required=True,
141
+ help="exchange, broker, or provider environment; for example kraken",
142
+ )
143
+ parser.add_argument(
144
+ "--symbol",
145
+ required=True,
146
+ help="exact provider-normalized symbol, such as BTC/USD or BTC/USDT:USDT",
147
+ )
148
+ parser.add_argument("--timeframe", required=True, help="source timeframe, such as 15m or 1h")
149
+ parser.add_argument("--start", type=parse_timestamp, required=True)
150
+ parser.add_argument("--end", type=parse_timestamp, required=True)
151
+ parser.add_argument("--limit", type=int)
152
+ parser.add_argument(
153
+ "--warmup-bars",
154
+ type=int,
155
+ default=0,
156
+ help="source bars before --start used for indicator warmup; trading stays disabled",
157
+ )
158
+ parser.add_argument("--timezone", default="UTC", help="IANA chart and exchange timezone")
159
+ parser.add_argument("--session", default="24x7", help="PineForge syminfo session")
160
+ parser.add_argument(
161
+ "--engine-timeframe",
162
+ help="PineForge input timeframe; defaults to a translation of --timeframe",
163
+ )
164
+ parser.add_argument("--script-timeframe", default="")
165
+ parser.add_argument("--provider-config", type=Path, help="provider options JSON file")
166
+ parser.add_argument("--strategy-params", type=Path, help="strategy parameters JSON file")
167
+ parser.add_argument(
168
+ "--strategy-overrides", type=Path, help="strategy() header overrides JSON file"
169
+ )
170
+ parser.add_argument("--bar-magnifier", action="store_true")
171
+ parser.add_argument("--magnifier-samples", type=int, default=4)
172
+ parser.add_argument("--trace", action="store_true")
173
+ parser.add_argument(
174
+ "--runtime-image",
175
+ "--image",
176
+ dest="runtime_image",
177
+ default=DEFAULT_RELEASE_IMAGE,
178
+ help="pineforge-release image; defaults to the package's pinned digest",
179
+ )
180
+ parser.add_argument(
181
+ "--pull-policy",
182
+ choices=("always", "missing", "never"),
183
+ default="missing",
184
+ help="local release-image pull policy",
185
+ )
186
+ parser.add_argument(
187
+ "--execution-timeout",
188
+ type=float,
189
+ default=300.0,
190
+ help="local or server request timeout in seconds",
191
+ )
192
+ parser.add_argument(
193
+ "--server-url",
194
+ help="FastAPI base URL; also read from PINEFORGE_SERVER_URL",
195
+ )
196
+ parser.add_argument(
197
+ "--server-api-key-env",
198
+ default="PINEFORGE_SERVER_API_KEY",
199
+ help="environment variable containing the server bearer token",
200
+ )
201
+ parser.add_argument("--output", type=Path, help="report path; defaults to stdout")
202
+ parser.add_argument("--pretty", action="store_true", help="pretty-print report JSON")
203
+ return parser
204
+
205
+
206
+ async def run_harness(args: argparse.Namespace) -> dict[str, JsonValue]:
207
+ """Execute the provider-to-engine pipeline for parsed CLI arguments."""
208
+
209
+ if args.warmup_bars < 0:
210
+ raise ValueError("--warmup-bars must be non-negative")
211
+ if args.end <= args.start:
212
+ raise ValueError("--end must be later than --start")
213
+ if args.limit is not None and args.limit <= 0:
214
+ raise ValueError("--limit must be positive")
215
+
216
+ provider_config = _load_json_object(args.provider_config)
217
+ strategy_params = _load_json_object(args.strategy_params)
218
+ strategy_overrides = _load_json_object(args.strategy_overrides)
219
+ provider = create_provider(args.provider, args.venue, config=provider_config)
220
+ try:
221
+ listing = await provider.resolve_market(args.symbol)
222
+ instrument = replace(
223
+ listing.instrument,
224
+ timezone=args.timezone,
225
+ session=args.session,
226
+ )
227
+ provider_start_ms = warmup_request_start_ms(args.start, args.timeframe, args.warmup_bars)
228
+ request_limit = None if args.limit is None else args.limit + args.warmup_bars
229
+ request = BarRequest(
230
+ instrument,
231
+ args.timeframe,
232
+ provider_start_ms,
233
+ args.end,
234
+ limit=request_limit,
235
+ )
236
+ fetched_bars = list(await provider.fetch_bars(request))
237
+ provider_name = provider.name
238
+ finally:
239
+ await provider.close()
240
+ if not fetched_bars:
241
+ raise RuntimeError("provider returned no confirmed bars for the requested interval")
242
+ requested_bars = [bar for bar in fetched_bars if bar.timestamp_ms >= args.start]
243
+ if not requested_bars:
244
+ raise RuntimeError("provider returned no confirmed bars at or after --start")
245
+ warmup_candidates = [bar for bar in fetched_bars if bar.timestamp_ms < args.start]
246
+ warmup = warmup_candidates[-args.warmup_bars :] if args.warmup_bars else []
247
+ bars = [*warmup, *requested_bars]
248
+
249
+ engine_timeframe = args.engine_timeframe or source_timeframe_to_pine(args.timeframe)
250
+ options = BacktestOptions(
251
+ input_timeframe=engine_timeframe,
252
+ script_timeframe=args.script_timeframe or engine_timeframe,
253
+ bar_magnifier=args.bar_magnifier,
254
+ magnifier_samples=args.magnifier_samples,
255
+ trace_enabled=args.trace,
256
+ chart_timezone=args.timezone,
257
+ trade_start_time_ms=args.start if args.warmup_bars else None,
258
+ )
259
+ pine_path = args.pine.expanduser().resolve()
260
+ if not pine_path.is_file():
261
+ raise FileNotFoundError(f"PineScript file not found: {pine_path}")
262
+ pine_source = pine_path.read_text(encoding="utf-8")
263
+ server_url = args.server_url or os.environ.get("PINEFORGE_SERVER_URL")
264
+ if server_url:
265
+ api_key = os.environ.get(args.server_api_key_env) if args.server_api_key_env else None
266
+ client = FastApiBacktestClient(
267
+ server_url,
268
+ timeout_seconds=args.execution_timeout + 30,
269
+ api_key=api_key,
270
+ )
271
+ container = await asyncio.to_thread(
272
+ client.run,
273
+ pine_source,
274
+ bars,
275
+ instrument=instrument,
276
+ source=provider_name,
277
+ options=options,
278
+ strategy_params=strategy_params,
279
+ strategy_overrides=strategy_overrides,
280
+ )
281
+ else:
282
+ runtime = DockerBacktestRuntime(
283
+ image=args.runtime_image,
284
+ pull_policy=args.pull_policy,
285
+ timeout_seconds=args.execution_timeout,
286
+ )
287
+ container = await asyncio.to_thread(
288
+ runtime.run,
289
+ pine_source,
290
+ bars,
291
+ instrument=instrument,
292
+ source=provider_name,
293
+ options=options,
294
+ strategy_params=strategy_params,
295
+ strategy_overrides=strategy_overrides,
296
+ )
297
+ required_sections = ("runtime", "backtest")
298
+ if any(section not in container for section in required_sections):
299
+ raise RuntimeError("Docker report is missing a required section")
300
+ return {
301
+ "schema_version": 1,
302
+ "request_id": cast(JsonValue, container.get("request_id")),
303
+ "provider": {
304
+ "name": provider_name,
305
+ "adapter": args.provider,
306
+ "venue": args.venue,
307
+ "source_timeframe": args.timeframe,
308
+ "market": _market_payload(listing),
309
+ },
310
+ "data": {
311
+ "requested_start_ms": args.start,
312
+ "requested_end_ms": args.end,
313
+ "provider_start_ms": provider_start_ms,
314
+ "first_bar_ms": bars[0].timestamp_ms,
315
+ "last_bar_ms": bars[-1].timestamp_ms,
316
+ "bars": len(bars),
317
+ "requested_bars": len(requested_bars),
318
+ "warmup_bars_requested": args.warmup_bars,
319
+ "warmup_bars_loaded": len(warmup),
320
+ "trade_start_time_ms": options.trade_start_time_ms,
321
+ },
322
+ "strategy": {
323
+ "pine": str(pine_path),
324
+ "input_timeframe": engine_timeframe,
325
+ "script_timeframe": options.script_timeframe,
326
+ },
327
+ "runtime": cast(JsonValue, container["runtime"]),
328
+ "backtest": cast(JsonValue, container["backtest"]),
329
+ }
330
+
331
+
332
+ def main(argv: list[str] | None = None) -> int:
333
+ parser = build_parser()
334
+ args = parser.parse_args(argv)
335
+ try:
336
+ payload = asyncio.run(run_harness(args))
337
+ rendered = json.dumps(
338
+ payload,
339
+ indent=2 if args.pretty else None,
340
+ sort_keys=args.pretty,
341
+ allow_nan=False,
342
+ )
343
+ if args.output is None:
344
+ print(rendered)
345
+ else:
346
+ args.output.write_text(f"{rendered}\n", encoding="utf-8")
347
+ return 0
348
+ except Exception as exc:
349
+ print(f"error: {exc}", file=sys.stderr)
350
+ return 1
351
+
352
+
353
+ if __name__ == "__main__":
354
+ raise SystemExit(main())
@@ -0,0 +1,171 @@
1
+ """Concurrency-safe compiled-strategy cache keyed by generated C++."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from collections import defaultdict
8
+ from collections.abc import AsyncIterator
9
+ from contextlib import asynccontextmanager
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from uuid import uuid4
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class _LockState:
17
+ lock: asyncio.Lock
18
+ references: int = 0
19
+
20
+
21
+ class CompileCache:
22
+ """Store `.so` artifacts atomically and deduplicate concurrent compiles."""
23
+
24
+ def __init__(self, root: Path, *, max_entries: int, max_bytes: int) -> None:
25
+ if max_entries <= 0 or max_bytes <= 0:
26
+ raise ValueError("cache limits must be positive")
27
+ self.root = root
28
+ self.max_entries = max_entries
29
+ self.max_bytes = max_bytes
30
+ self.root.mkdir(parents=True, exist_ok=True)
31
+ self._lock_guard = asyncio.Lock()
32
+ self._files_guard = asyncio.Lock()
33
+ self._locks: dict[str, _LockState] = {}
34
+ self._active: defaultdict[str, int] = defaultdict(int)
35
+ self.hits = 0
36
+ self.misses = 0
37
+ self.compiles = 0
38
+
39
+ def path(self, key: str) -> Path:
40
+ return self.root / f"{key}.so"
41
+
42
+ def temporary_path(self, key: str) -> Path:
43
+ return self.root / f".{key}.{uuid4().hex}.tmp"
44
+
45
+ @asynccontextmanager
46
+ async def compile_lock(self, key: str) -> AsyncIterator[None]:
47
+ async with self._lock_guard:
48
+ state = self._locks.get(key)
49
+ if state is None:
50
+ state = _LockState(asyncio.Lock())
51
+ self._locks[key] = state
52
+ state.references += 1
53
+ await state.lock.acquire()
54
+ try:
55
+ yield
56
+ finally:
57
+ state.lock.release()
58
+ async with self._lock_guard:
59
+ state.references -= 1
60
+ if state.references == 0:
61
+ self._locks.pop(key, None)
62
+
63
+ @asynccontextmanager
64
+ async def use(self, key: str) -> AsyncIterator[Path]:
65
+ path = await self.acquire(key)
66
+ if path is None:
67
+ raise FileNotFoundError(self.path(key))
68
+ try:
69
+ yield path
70
+ finally:
71
+ await self.release(key)
72
+
73
+ async def lookup(self, key: str) -> Path | None:
74
+ path = self.path(key)
75
+ exists = await asyncio.to_thread(path.is_file)
76
+ async with self._files_guard:
77
+ if exists:
78
+ self.hits += 1
79
+ else:
80
+ self.misses += 1
81
+ return path if exists else None
82
+
83
+ async def commit(self, key: str, temporary: Path) -> Path:
84
+ target = self.path(key)
85
+ await asyncio.to_thread(os.replace, temporary, target)
86
+ await asyncio.to_thread(target.chmod, 0o555)
87
+ async with self._files_guard:
88
+ self.compiles += 1
89
+ return target
90
+
91
+ async def acquire(self, key: str) -> Path | None:
92
+ """Atomically look up and reserve an artifact against eviction."""
93
+
94
+ async with self._files_guard:
95
+ path = self.path(key)
96
+ if not path.is_file():
97
+ self.misses += 1
98
+ return None
99
+ self.hits += 1
100
+ self._active[key] += 1
101
+ await asyncio.to_thread(os.utime, path, None)
102
+ return path
103
+
104
+ async def commit_and_acquire(self, key: str, temporary: Path) -> Path:
105
+ """Atomically publish and reserve a newly compiled artifact."""
106
+
107
+ async with self._files_guard:
108
+ target = self.path(key)
109
+ await asyncio.to_thread(os.replace, temporary, target)
110
+ await asyncio.to_thread(target.chmod, 0o555)
111
+ self.compiles += 1
112
+ self._active[key] += 1
113
+ return target
114
+
115
+ async def release(self, key: str) -> None:
116
+ async with self._files_guard:
117
+ self._active[key] -= 1
118
+ if self._active[key] == 0:
119
+ self._active.pop(key, None)
120
+
121
+ async def status(self) -> dict[str, int | str]:
122
+ async with self._files_guard:
123
+ files, sizes = await asyncio.to_thread(self._snapshot_sync)
124
+ return {
125
+ "directory": str(self.root),
126
+ "entries": len(files),
127
+ "bytes": sizes,
128
+ "hits": self.hits,
129
+ "misses": self.misses,
130
+ "compiles": self.compiles,
131
+ }
132
+
133
+ async def trim(self) -> None:
134
+ async with self._files_guard:
135
+ active = set(self._active)
136
+ await asyncio.to_thread(self._trim_sync, active)
137
+
138
+ def _snapshot_sync(self) -> tuple[list[Path], int]:
139
+ files: list[Path] = []
140
+ size = 0
141
+ for path in self.root.glob("*.so"):
142
+ try:
143
+ stat = path.stat()
144
+ except FileNotFoundError:
145
+ continue
146
+ files.append(path)
147
+ size += stat.st_size
148
+ return files, size
149
+
150
+ def _trim_sync(self, active: set[str]) -> None:
151
+ entries = []
152
+ for path in self.root.glob("*.so"):
153
+ try:
154
+ stat = path.stat()
155
+ except FileNotFoundError:
156
+ continue
157
+ entries.append((stat.st_mtime_ns, stat.st_size, path))
158
+ entries.sort()
159
+ total = sum(size for _, size, _ in entries)
160
+ count = len(entries)
161
+ for _, size, path in entries:
162
+ if count <= self.max_entries and total <= self.max_bytes:
163
+ break
164
+ if path.stem in active:
165
+ continue
166
+ try:
167
+ path.unlink()
168
+ except FileNotFoundError:
169
+ continue
170
+ count -= 1
171
+ total -= size