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,530 @@
1
+ """Schema discovery and normalization shared by user-owned tabular sources."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass, replace
9
+ from datetime import date, datetime, time
10
+ from decimal import Decimal, InvalidOperation
11
+ from enum import StrEnum
12
+ from math import isfinite
13
+ from types import MappingProxyType
14
+ from typing import TypeAlias
15
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
16
+
17
+ from ..models import Bar, Instrument, MarketListing
18
+ from ..requests import BarRequest, MarketQuery
19
+ from .base import MarketNotFoundError
20
+
21
+ TabularRow: TypeAlias = Mapping[str, object]
22
+
23
+
24
+ class TabularDataError(RuntimeError):
25
+ """A user-owned tabular source cannot be normalized safely."""
26
+
27
+
28
+ class SchemaMappingError(TabularDataError):
29
+ """Source columns cannot be mapped unambiguously to OHLCV fields."""
30
+
31
+
32
+ class TimestampUnit(StrEnum):
33
+ """Storage unit used by numeric timestamps."""
34
+
35
+ SECONDS = "seconds"
36
+ MILLISECONDS = "milliseconds"
37
+ MICROSECONDS = "microseconds"
38
+ NANOSECONDS = "nanoseconds"
39
+ ISO8601 = "iso8601"
40
+
41
+ @classmethod
42
+ def parse(cls, value: TimestampUnit | str) -> TimestampUnit:
43
+ if isinstance(value, cls):
44
+ return value
45
+ aliases = {
46
+ "s": cls.SECONDS,
47
+ "sec": cls.SECONDS,
48
+ "second": cls.SECONDS,
49
+ "seconds": cls.SECONDS,
50
+ "ms": cls.MILLISECONDS,
51
+ "millisecond": cls.MILLISECONDS,
52
+ "milliseconds": cls.MILLISECONDS,
53
+ "us": cls.MICROSECONDS,
54
+ "microsecond": cls.MICROSECONDS,
55
+ "microseconds": cls.MICROSECONDS,
56
+ "ns": cls.NANOSECONDS,
57
+ "nanosecond": cls.NANOSECONDS,
58
+ "nanoseconds": cls.NANOSECONDS,
59
+ "iso": cls.ISO8601,
60
+ "iso8601": cls.ISO8601,
61
+ "datetime": cls.ISO8601,
62
+ }
63
+ normalized = value.strip().casefold()
64
+ try:
65
+ return aliases[normalized]
66
+ except KeyError as exc:
67
+ choices = ", ".join(unit.value for unit in cls)
68
+ raise ValueError(
69
+ f"unknown timestamp unit {value!r}; expected one of: {choices}"
70
+ ) from exc
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class SourceColumn:
75
+ """One column discovered from a file header or database reflection."""
76
+
77
+ name: str
78
+ data_type: str = ""
79
+ nullable: bool | None = None
80
+
81
+ def __post_init__(self) -> None:
82
+ if not self.name:
83
+ raise ValueError("source column name must not be empty")
84
+
85
+
86
+ _REQUIRED_FIELDS = ("timestamp", "open", "high", "low", "close", "volume")
87
+ _OPTIONAL_FIELDS = ("symbol", "timeframe")
88
+ _MAPPING_FIELDS = _REQUIRED_FIELDS + _OPTIONAL_FIELDS
89
+ _ALIASES: Mapping[str, tuple[str, ...]] = MappingProxyType(
90
+ {
91
+ "timestamp": (
92
+ "timestamp",
93
+ "timestamp_ms",
94
+ "time",
95
+ "datetime",
96
+ "date",
97
+ "ts",
98
+ "open_time",
99
+ "bar_time",
100
+ ),
101
+ "open": ("open", "open_price", "price_open", "px_open", "o"),
102
+ "high": ("high", "high_price", "price_high", "px_high", "h"),
103
+ "low": ("low", "low_price", "price_low", "px_low", "l"),
104
+ "close": ("close", "close_price", "price_close", "px_close", "c"),
105
+ "volume": ("volume", "vol", "base_volume", "quantity", "qty", "v"),
106
+ "symbol": ("symbol", "ticker", "instrument", "market", "security"),
107
+ "timeframe": ("timeframe", "interval", "resolution", "bar_size"),
108
+ }
109
+ )
110
+
111
+
112
+ def _normalized_column_name(value: str) -> str:
113
+ return "".join(character for character in value.casefold() if character.isalnum())
114
+
115
+
116
+ def _validate_overrides(overrides: Mapping[str, str] | None) -> dict[str, str]:
117
+ normalized = dict(overrides or {})
118
+ unknown = sorted(normalized.keys() - set(_MAPPING_FIELDS))
119
+ if unknown:
120
+ raise SchemaMappingError(
121
+ f"unknown canonical column field(s): {', '.join(unknown)}; "
122
+ f"expected: {', '.join(_MAPPING_FIELDS)}"
123
+ )
124
+ for field, column in normalized.items():
125
+ if not isinstance(column, str) or not column:
126
+ raise SchemaMappingError(f"column override for {field!r} must be a non-empty string")
127
+ return normalized
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class BarColumnMapping:
132
+ """Map canonical PineForge bar fields to arbitrary source column names."""
133
+
134
+ timestamp: str
135
+ open: str
136
+ high: str
137
+ low: str
138
+ close: str
139
+ volume: str
140
+ symbol: str | None = None
141
+ timeframe: str | None = None
142
+
143
+ def __post_init__(self) -> None:
144
+ for field in _REQUIRED_FIELDS:
145
+ column = getattr(self, field)
146
+ if not isinstance(column, str) or not column:
147
+ raise ValueError(f"{field} column must be a non-empty string")
148
+ for field in _OPTIONAL_FIELDS:
149
+ column = getattr(self, field)
150
+ if column is not None and not column:
151
+ raise ValueError(f"{field} column must be None or a non-empty string")
152
+
153
+ selected = [
154
+ column for field in _MAPPING_FIELDS if (column := getattr(self, field)) is not None
155
+ ]
156
+ duplicates = sorted({column for column in selected if selected.count(column) > 1})
157
+ if duplicates:
158
+ raise ValueError(
159
+ "one source column cannot map to multiple fields: " + ", ".join(duplicates)
160
+ )
161
+
162
+ @property
163
+ def columns(self) -> tuple[str, ...]:
164
+ """Return mapped source columns in canonical order without duplicates."""
165
+
166
+ return tuple(
167
+ column for field in _MAPPING_FIELDS if (column := getattr(self, field)) is not None
168
+ )
169
+
170
+ @classmethod
171
+ def infer(
172
+ cls,
173
+ columns: Sequence[str],
174
+ overrides: Mapping[str, str] | None = None,
175
+ ) -> BarColumnMapping:
176
+ """Infer common OHLCV names while requiring explicit ambiguous choices."""
177
+
178
+ available = tuple(columns)
179
+ if not available:
180
+ raise SchemaMappingError("source exposes no columns")
181
+ if len(set(available)) != len(available):
182
+ duplicates = sorted({name for name in available if available.count(name) > 1})
183
+ raise SchemaMappingError(f"source has duplicate column names: {', '.join(duplicates)}")
184
+
185
+ selected = _validate_overrides(overrides)
186
+ missing_overrides = sorted(set(selected.values()) - set(available))
187
+ if missing_overrides:
188
+ raise SchemaMappingError(
189
+ "mapped column(s) not found: "
190
+ f"{', '.join(missing_overrides)}; available columns: {', '.join(available)}"
191
+ )
192
+
193
+ used = set(selected.values())
194
+ ambiguous: dict[str, list[str]] = {}
195
+ for field in _MAPPING_FIELDS:
196
+ if field in selected:
197
+ continue
198
+ aliases = {_normalized_column_name(alias) for alias in _ALIASES[field]}
199
+ matches = [
200
+ column
201
+ for column in available
202
+ if column not in used and _normalized_column_name(column) in aliases
203
+ ]
204
+ if len(matches) == 1:
205
+ selected[field] = matches[0]
206
+ used.add(matches[0])
207
+ elif len(matches) > 1:
208
+ ambiguous[field] = matches
209
+
210
+ missing = [field for field in _REQUIRED_FIELDS if field not in selected]
211
+ if missing or ambiguous:
212
+ details: list[str] = []
213
+ if missing:
214
+ details.append("missing mappings for " + ", ".join(missing))
215
+ if ambiguous:
216
+ choices = "; ".join(
217
+ f"{field}: {', '.join(matches)}" for field, matches in sorted(ambiguous.items())
218
+ )
219
+ details.append("ambiguous mappings for " + choices)
220
+ details.append("available columns: " + ", ".join(available))
221
+ details.append(
222
+ "pass mapping={'timestamp': 'your_time', ...} to override only unusual names"
223
+ )
224
+ raise SchemaMappingError("; ".join(details))
225
+
226
+ return cls(
227
+ timestamp=selected["timestamp"],
228
+ open=selected["open"],
229
+ high=selected["high"],
230
+ low=selected["low"],
231
+ close=selected["close"],
232
+ volume=selected["volume"],
233
+ symbol=selected.get("symbol"),
234
+ timeframe=selected.get("timeframe"),
235
+ )
236
+
237
+ def validate(self, columns: Sequence[str]) -> None:
238
+ """Require every selected name to exist in the reflected source schema."""
239
+
240
+ missing = sorted(set(self.columns) - set(columns))
241
+ if missing:
242
+ raise SchemaMappingError(
243
+ f"mapped column(s) not found: {', '.join(missing)}; "
244
+ f"available columns: {', '.join(columns)}"
245
+ )
246
+
247
+
248
+ ColumnMappingInput: TypeAlias = BarColumnMapping | Mapping[str, str] | None
249
+
250
+
251
+ @dataclass(frozen=True, slots=True)
252
+ class TabularSchema:
253
+ """Runtime description of a user-owned file, table, or view."""
254
+
255
+ source: str
256
+ columns: tuple[SourceColumn, ...]
257
+
258
+ def __post_init__(self) -> None:
259
+ names = self.column_names
260
+ if not self.source:
261
+ raise ValueError("schema source must not be empty")
262
+ if len(set(names)) != len(names):
263
+ duplicates = sorted({name for name in names if names.count(name) > 1})
264
+ raise SchemaMappingError(f"source has duplicate column names: {', '.join(duplicates)}")
265
+
266
+ @property
267
+ def column_names(self) -> tuple[str, ...]:
268
+ return tuple(column.name for column in self.columns)
269
+
270
+ def infer_bar_mapping(self, overrides: Mapping[str, str] | None = None) -> BarColumnMapping:
271
+ """Build a validated mapping from reflected columns and optional overrides."""
272
+
273
+ return BarColumnMapping.infer(self.column_names, overrides)
274
+
275
+
276
+ def _numeric_timestamp_ms(value: object, unit: TimestampUnit) -> int:
277
+ try:
278
+ numeric = Decimal(str(value).strip())
279
+ except (InvalidOperation, AttributeError) as exc:
280
+ raise ValueError(f"invalid numeric timestamp: {value!r}") from exc
281
+ if not numeric.is_finite():
282
+ raise ValueError("timestamp must be finite")
283
+ scale = {
284
+ TimestampUnit.SECONDS: Decimal(1_000),
285
+ TimestampUnit.MILLISECONDS: Decimal(1),
286
+ TimestampUnit.MICROSECONDS: Decimal("0.001"),
287
+ TimestampUnit.NANOSECONDS: Decimal("0.000001"),
288
+ }[unit]
289
+ milliseconds = numeric * scale
290
+ if milliseconds != milliseconds.to_integral_value():
291
+ raise ValueError("timestamp cannot be represented exactly at millisecond precision")
292
+ normalized = int(milliseconds)
293
+ if normalized < 0 or normalized > 2**63 - 1:
294
+ raise ValueError("timestamp must fit a non-negative signed 64-bit millisecond value")
295
+ return normalized
296
+
297
+
298
+ def _timestamp_ms(value: object, unit: TimestampUnit, timezone: ZoneInfo) -> int:
299
+ if isinstance(value, bool) or value is None:
300
+ raise ValueError("timestamp must be numeric, ISO-8601 text, or a datetime")
301
+
302
+ if isinstance(value, datetime):
303
+ parsed = value
304
+ elif isinstance(value, date):
305
+ parsed = datetime.combine(value, time.min)
306
+ elif isinstance(value, str):
307
+ text = value.strip()
308
+ if not text:
309
+ raise ValueError("timestamp must not be empty")
310
+ if unit is not TimestampUnit.ISO8601:
311
+ try:
312
+ Decimal(text)
313
+ except InvalidOperation:
314
+ pass
315
+ else:
316
+ return _numeric_timestamp_ms(text, unit)
317
+ try:
318
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
319
+ except ValueError as exc:
320
+ raise ValueError(f"invalid ISO-8601 timestamp: {value!r}") from exc
321
+ else:
322
+ if unit is TimestampUnit.ISO8601:
323
+ raise ValueError("timestamp_unit=iso8601 requires text or datetime values")
324
+ return _numeric_timestamp_ms(value, unit)
325
+
326
+ if parsed.tzinfo is None:
327
+ parsed = parsed.replace(tzinfo=timezone)
328
+ normalized = int(parsed.timestamp() * 1_000)
329
+ if normalized < 0 or normalized > 2**63 - 1:
330
+ raise ValueError("timestamp must fit a non-negative signed 64-bit millisecond value")
331
+ return normalized
332
+
333
+
334
+ def _number(value: object, field: str) -> float:
335
+ if isinstance(value, bool) or value is None:
336
+ raise ValueError(f"{field} must be numeric")
337
+ try:
338
+ normalized = float(value if isinstance(value, (int, float, str)) else str(value))
339
+ except (TypeError, ValueError) as exc:
340
+ raise ValueError(f"{field} must be numeric") from exc
341
+ if not isfinite(normalized):
342
+ raise ValueError(f"{field} must be finite")
343
+ return normalized
344
+
345
+
346
+ def numeric_source_bounds(
347
+ start_ms: int, end_ms: int, unit: TimestampUnit
348
+ ) -> tuple[int | float, int | float] | None:
349
+ """Translate millisecond request bounds for safe numeric SQL pushdown."""
350
+
351
+ if unit is TimestampUnit.ISO8601:
352
+ return None
353
+ if unit is TimestampUnit.SECONDS:
354
+ return start_ms / 1_000, end_ms / 1_000
355
+ if unit is TimestampUnit.MILLISECONDS:
356
+ return start_ms, end_ms
357
+ if unit is TimestampUnit.MICROSECONDS:
358
+ return start_ms * 1_000, end_ms * 1_000
359
+ return start_ms * 1_000_000, end_ms * 1_000_000
360
+
361
+
362
+ class TabularBarProvider(ABC):
363
+ """Common catalog and OHLCV behavior for schema-mapped tabular sources."""
364
+
365
+ name: str
366
+ venue: str
367
+
368
+ def __init__(
369
+ self,
370
+ *,
371
+ venue: str,
372
+ mapping: ColumnMappingInput = None,
373
+ timestamp_unit: TimestampUnit | str = TimestampUnit.MILLISECONDS,
374
+ timestamp_timezone: str = "UTC",
375
+ instrument: Instrument | None = None,
376
+ timeframe: str | None = None,
377
+ ) -> None:
378
+ if not venue.strip():
379
+ raise ValueError("venue must not be empty")
380
+ if timeframe is not None and not timeframe.strip():
381
+ raise ValueError("timeframe must not be empty")
382
+ if instrument is not None and instrument.venue and instrument.venue != venue:
383
+ raise ValueError(
384
+ f"instrument venue {instrument.venue!r} does not match provider venue {venue!r}"
385
+ )
386
+ try:
387
+ self._timestamp_timezone = ZoneInfo(timestamp_timezone)
388
+ except ZoneInfoNotFoundError as exc:
389
+ raise ValueError(f"unknown IANA timestamp timezone: {timestamp_timezone}") from exc
390
+
391
+ self.venue = venue
392
+ self.timestamp_unit = TimestampUnit.parse(timestamp_unit)
393
+ self.timestamp_timezone = timestamp_timezone
394
+ self._instrument_template = instrument
395
+ self._timeframe = timeframe
396
+ self._explicit_mapping: BarColumnMapping | None = None
397
+ self._mapping_overrides: dict[str, str] = {}
398
+ if isinstance(mapping, BarColumnMapping):
399
+ self._explicit_mapping = mapping
400
+ elif mapping is not None:
401
+ self._mapping_overrides = _validate_overrides(mapping)
402
+
403
+ async def inspect_schema(self) -> TabularSchema:
404
+ """Reflect columns without reading or normalizing the full dataset."""
405
+
406
+ return await asyncio.to_thread(self._inspect_schema_sync)
407
+
408
+ def _resolved_mapping_sync(self) -> BarColumnMapping:
409
+ schema = self._inspect_schema_sync()
410
+ if self._explicit_mapping is not None:
411
+ self._explicit_mapping.validate(schema.column_names)
412
+ return self._explicit_mapping
413
+ return schema.infer_bar_mapping(self._mapping_overrides)
414
+
415
+ def _instrument(self, symbol: str) -> Instrument:
416
+ template = self._instrument_template
417
+ if template is not None:
418
+ if template.symbol != symbol:
419
+ raise MarketNotFoundError(
420
+ f"{self.name} is bound to {template.symbol!r}, not {symbol!r}"
421
+ )
422
+ return replace(
423
+ template,
424
+ venue=template.venue or self.venue,
425
+ provider_id=template.provider_id or symbol,
426
+ )
427
+ return Instrument(symbol=symbol, venue=self.venue, provider_id=symbol)
428
+
429
+ def _symbols_sync(self, mapping: BarColumnMapping) -> tuple[str, ...]:
430
+ if mapping.symbol is None:
431
+ if self._instrument_template is None:
432
+ return ()
433
+ return (self._instrument_template.symbol,)
434
+ values = self._distinct_values_sync(mapping.symbol)
435
+ symbols = {
436
+ str(value).strip() for value in values if value is not None and str(value).strip()
437
+ }
438
+ return tuple(sorted(symbols))
439
+
440
+ async def list_markets(self, query: MarketQuery | None = None) -> Sequence[MarketListing]:
441
+ mapping = await asyncio.to_thread(self._resolved_mapping_sync)
442
+ symbols = await asyncio.to_thread(self._symbols_sync, mapping)
443
+ listings = [MarketListing(self._instrument(symbol)) for symbol in symbols]
444
+ return listings if query is None else [item for item in listings if query.matches(item)]
445
+
446
+ async def resolve_market(self, symbol: str) -> MarketListing:
447
+ if not symbol.strip():
448
+ raise ValueError("symbol must not be empty")
449
+ mapping = await asyncio.to_thread(self._resolved_mapping_sync)
450
+ if mapping.symbol is not None:
451
+ symbols = await asyncio.to_thread(self._symbols_sync, mapping)
452
+ if symbol not in symbols:
453
+ raise MarketNotFoundError(f"{self.name} has no exact symbol {symbol!r}")
454
+ return MarketListing(self._instrument(symbol))
455
+
456
+ def _fetch_bars_sync(self, request: BarRequest) -> Sequence[Bar]:
457
+ if request.instrument.venue and request.instrument.venue != self.venue:
458
+ raise ValueError(
459
+ f"instrument venue {request.instrument.venue!r} does not match provider "
460
+ f"venue {self.venue!r}"
461
+ )
462
+ if self._instrument_template is not None:
463
+ self._instrument(request.instrument.symbol)
464
+ if self._timeframe is not None and request.timeframe != self._timeframe:
465
+ raise ValueError(
466
+ f"source timeframe {self._timeframe!r} does not match request {request.timeframe!r}"
467
+ )
468
+
469
+ mapping = self._resolved_mapping_sync()
470
+ rows = self._read_rows_sync(mapping, request)
471
+ by_timestamp: dict[int, Bar] = {}
472
+ for index, row in enumerate(rows, start=1):
473
+ try:
474
+ if mapping.symbol is not None:
475
+ row_symbol = str(row[mapping.symbol]).strip()
476
+ if row_symbol != request.instrument.symbol:
477
+ continue
478
+ if mapping.timeframe is not None:
479
+ row_timeframe = str(row[mapping.timeframe]).strip()
480
+ if row_timeframe != request.timeframe:
481
+ continue
482
+ timestamp_ms = _timestamp_ms(
483
+ row[mapping.timestamp], self.timestamp_unit, self._timestamp_timezone
484
+ )
485
+ if not request.start_ms <= timestamp_ms < request.end_ms:
486
+ continue
487
+ if timestamp_ms in by_timestamp:
488
+ raise TabularDataError(
489
+ f"duplicate bar timestamp {timestamp_ms} for "
490
+ f"{request.instrument.symbol!r} and {request.timeframe!r}"
491
+ )
492
+ by_timestamp[timestamp_ms] = Bar(
493
+ instrument=request.instrument,
494
+ timestamp_ms=timestamp_ms,
495
+ open=_number(row[mapping.open], "open"),
496
+ high=_number(row[mapping.high], "high"),
497
+ low=_number(row[mapping.low], "low"),
498
+ close=_number(row[mapping.close], "close"),
499
+ volume=_number(row[mapping.volume], "volume"),
500
+ source=self.name,
501
+ )
502
+ except TabularDataError:
503
+ raise
504
+ except (KeyError, TypeError, ValueError) as exc:
505
+ raise TabularDataError(f"cannot normalize source row {index}: {exc}") from exc
506
+
507
+ bars = [by_timestamp[timestamp] for timestamp in sorted(by_timestamp)]
508
+ return bars if request.limit is None else bars[: request.limit]
509
+
510
+ async def fetch_bars(self, request: BarRequest) -> Sequence[Bar]:
511
+ """Read, validate, sort, and normalize bars for one exact request."""
512
+
513
+ return await asyncio.to_thread(self._fetch_bars_sync, request)
514
+
515
+ async def close(self) -> None:
516
+ await asyncio.to_thread(self._close_sync)
517
+
518
+ @abstractmethod
519
+ def _inspect_schema_sync(self) -> TabularSchema: ...
520
+
521
+ @abstractmethod
522
+ def _read_rows_sync(
523
+ self, mapping: BarColumnMapping, request: BarRequest
524
+ ) -> Sequence[TabularRow]: ...
525
+
526
+ @abstractmethod
527
+ def _distinct_values_sync(self, column: str) -> Sequence[object]: ...
528
+
529
+ def _close_sync(self) -> None:
530
+ return None
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,153 @@
1
+ """Shared input and output contract for the published PineForge release image."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from collections.abc import Mapping, Sequence
8
+ from itertools import pairwise
9
+ from pathlib import Path
10
+
11
+ from .backtest import BacktestOptions, JsonValue
12
+ from .models import Bar, Instrument
13
+
14
+ DEFAULT_RELEASE_IMAGE = (
15
+ "ghcr.io/pineforge-4pass/pineforge-release:0.1.12@"
16
+ "sha256:312b9d908390b828484617472c749d5815feb75507da87eae2f6902cfe3d47b1"
17
+ )
18
+ RELEASE_ENTRYPOINT = "/opt/pineforge/bin/entrypoint.sh"
19
+ RESPONSE_SCHEMA_VERSION = 1
20
+
21
+
22
+ class ReleaseContractError(ValueError):
23
+ """A request or release-image response violates the integration contract."""
24
+
25
+
26
+ def _validate_request(pine_source: str, bars: Sequence[Bar]) -> None:
27
+ if not pine_source.strip():
28
+ raise ReleaseContractError("PineScript source must not be empty")
29
+ if not bars:
30
+ raise ReleaseContractError("bars must not be empty")
31
+ if any(left.timestamp_ms >= right.timestamp_ms for left, right in pairwise(bars)):
32
+ raise ReleaseContractError("bar timestamps must be strictly increasing")
33
+
34
+
35
+ def write_release_inputs(
36
+ workspace: Path,
37
+ pine_source: str,
38
+ bars: Sequence[Bar],
39
+ instrument: Instrument,
40
+ ) -> None:
41
+ """Write the immutable `/in` files consumed by pineforge-release."""
42
+
43
+ _validate_request(pine_source, bars)
44
+ workspace.mkdir(parents=True, exist_ok=True)
45
+ pine_path = workspace / "strategy.pine"
46
+ ohlcv_path = workspace / "ohlcv.csv"
47
+ syminfo_path = workspace / "syminfo.json"
48
+ pine_path.write_text(pine_source, encoding="utf-8")
49
+ with ohlcv_path.open("w", newline="", encoding="utf-8") as handle:
50
+ writer = csv.writer(handle)
51
+ writer.writerow(("timestamp", "open", "high", "low", "close", "volume"))
52
+ for bar in bars:
53
+ writer.writerow(
54
+ (
55
+ bar.timestamp_ms,
56
+ bar.open,
57
+ bar.high,
58
+ bar.low,
59
+ bar.close,
60
+ bar.volume,
61
+ )
62
+ )
63
+ syminfo_path.write_text(
64
+ json.dumps(
65
+ {
66
+ "syminfo": {
67
+ "ticker": instrument.symbol,
68
+ "timezone": instrument.timezone,
69
+ "session": instrument.session,
70
+ }
71
+ },
72
+ separators=(",", ":"),
73
+ ),
74
+ encoding="utf-8",
75
+ )
76
+ for path in (pine_path, ohlcv_path, syminfo_path):
77
+ path.chmod(0o644)
78
+ workspace.chmod(0o755)
79
+
80
+
81
+ def release_environment(
82
+ input_directory: str,
83
+ instrument: Instrument,
84
+ options: BacktestOptions,
85
+ strategy_params: Mapping[str, JsonValue] | None = None,
86
+ strategy_overrides: Mapping[str, JsonValue] | None = None,
87
+ ) -> dict[str, str]:
88
+ """Translate PineForge options into the release entrypoint environment."""
89
+
90
+ if options.trace_enabled:
91
+ raise ReleaseContractError("pineforge-release 0.1.12 does not expose trace collection")
92
+ if options.bar_magnifier and options.magnifier_samples < 2:
93
+ raise ReleaseContractError("bar magnifier requires at least two samples")
94
+ environment = {
95
+ "PINEFORGE_IN_DIR": input_directory,
96
+ "PINEFORGE_INPUTS": json.dumps(
97
+ dict(strategy_params or {}), separators=(",", ":"), allow_nan=False
98
+ ),
99
+ "PINEFORGE_OVERRIDES": json.dumps(
100
+ dict(strategy_overrides or {}), separators=(",", ":"), allow_nan=False
101
+ ),
102
+ "PINEFORGE_INPUT_TF": options.input_timeframe,
103
+ "PINEFORGE_SCRIPT_TF": options.script_timeframe,
104
+ "PINEFORGE_BAR_MAGNIFIER": "true" if options.bar_magnifier else "false",
105
+ "PINEFORGE_MAGNIFIER_SAMPLES": str(options.magnifier_samples),
106
+ "PINEFORGE_MAGNIFIER_DIST": options.magnifier_distribution.name.lower(),
107
+ "PINEFORGE_CHART_TZ": options.chart_timezone or "",
108
+ "PINEFORGE_SYMINFO": f"{input_directory.rstrip('/')}/syminfo.json",
109
+ "PINEFORGE_DATA_SYMBOL": instrument.symbol,
110
+ "PINEFORGE_DATA_VENUE": instrument.venue,
111
+ }
112
+ if options.trade_start_time_ms is not None:
113
+ environment["PINEFORGE_TRADE_START_MS"] = str(options.trade_start_time_ms)
114
+ return environment
115
+
116
+
117
+ def parse_release_report(stdout: str) -> dict[str, object]:
118
+ """Parse and minimally validate the canonical release-image report."""
119
+
120
+ try:
121
+ value = json.loads(stdout)
122
+ except json.JSONDecodeError as exc:
123
+ raise ReleaseContractError("pineforge-release returned invalid JSON") from exc
124
+ if not isinstance(value, dict):
125
+ raise ReleaseContractError("pineforge-release report must be a JSON object")
126
+ required = ("summary", "trades", "metrics", "diagnostics")
127
+ missing = [field for field in required if field not in value]
128
+ if missing:
129
+ raise ReleaseContractError(f"pineforge-release report is missing: {', '.join(missing)}")
130
+ return value
131
+
132
+
133
+ def release_response(
134
+ report: Mapping[str, object],
135
+ *,
136
+ release_image: str,
137
+ mode: str,
138
+ request_id: str | None,
139
+ runtime_metadata: Mapping[str, object] | None = None,
140
+ ) -> dict[str, object]:
141
+ """Wrap the release report in the stable pineforge-data transport envelope."""
142
+
143
+ runtime: dict[str, object] = {
144
+ "mode": mode,
145
+ "release_image": release_image,
146
+ }
147
+ runtime.update(runtime_metadata or {})
148
+ return {
149
+ "schema_version": RESPONSE_SCHEMA_VERSION,
150
+ "request_id": request_id,
151
+ "runtime": runtime,
152
+ "backtest": dict(report),
153
+ }