quantbt-engine 1.0.7__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.
- quantbt/__init__.py +862 -0
- quantbt/adapters/__init__.py +5 -0
- quantbt/adapters/nautilus/__init__.py +42 -0
- quantbt/adapters/nautilus/_dependency.py +53 -0
- quantbt/adapters/nautilus/backend.py +639 -0
- quantbt/adapters/nautilus/instruments.py +275 -0
- quantbt/adapters/nautilus/options.py +465 -0
- quantbt/adapters/nautilus/reports.py +231 -0
- quantbt/backends/__init__.py +29 -0
- quantbt/backends/_native_event_rust.py +2168 -0
- quantbt/backends/native_event.py +5048 -0
- quantbt/backends/native_option.py +796 -0
- quantbt/backends/native_portfolio.py +928 -0
- quantbt/backends/native_vectorized.py +1299 -0
- quantbt/backtester.py +485 -0
- quantbt/benchmarks/__init__.py +1 -0
- quantbt/benchmarks/compare_phase9_parity.py +302 -0
- quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
- quantbt/benchmarks/profile_phase7.py +415 -0
- quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
- quantbt/benchmarks/run_optimization_overhead.py +195 -0
- quantbt/benchmarks/run_options_engine.py +272 -0
- quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
- quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
- quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
- quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
- quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
- quantbt/benchmarks/run_phase14_service_loop.py +694 -0
- quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
- quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
- quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
- quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
- quantbt/benchmarks/run_phase31_intrabar.py +441 -0
- quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
- quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
- quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
- quantbt/benchmarks/run_phase7.py +654 -0
- quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
- quantbt/core/__init__.py +323 -0
- quantbt/core/arbitrage.py +767 -0
- quantbt/core/basket.py +234 -0
- quantbt/core/certification.py +274 -0
- quantbt/core/constraints.py +155 -0
- quantbt/core/engine.py +1144 -0
- quantbt/core/event.py +869 -0
- quantbt/core/execution_contract.py +197 -0
- quantbt/core/execution_depth.py +613 -0
- quantbt/core/intrabar_kernel.py +1906 -0
- quantbt/core/intrabar_reference.py +907 -0
- quantbt/core/intrabar_session.py +156 -0
- quantbt/core/market_tape.py +480 -0
- quantbt/core/native_event_capabilities.py +121 -0
- quantbt/core/native_event_parity.py +336 -0
- quantbt/core/order_compiler.py +422 -0
- quantbt/core/orders.py +319 -0
- quantbt/core/portfolio.py +392 -0
- quantbt/core/preprocessor.py +256 -0
- quantbt/core/reactive.py +173 -0
- quantbt/core/results.py +341 -0
- quantbt/core/schema.py +211 -0
- quantbt/core/scopes.py +96 -0
- quantbt/core/structured_orders.py +378 -0
- quantbt/core/types.py +87 -0
- quantbt/core/vectorized.py +203 -0
- quantbt/endpoint.py +4465 -0
- quantbt/engines.py +1014 -0
- quantbt/metrics/__init__.py +45 -0
- quantbt/metrics/options_analytics.py +46 -0
- quantbt/metrics/performance.py +541 -0
- quantbt/optimization/__init__.py +94 -0
- quantbt/optimization/callbacks.py +116 -0
- quantbt/optimization/candidate_selection.py +397 -0
- quantbt/optimization/config.py +84 -0
- quantbt/optimization/constraints.py +22 -0
- quantbt/optimization/evaluator.py +21 -0
- quantbt/optimization/evaluators/__init__.py +32 -0
- quantbt/optimization/evaluators/arbitrage.py +22 -0
- quantbt/optimization/evaluators/generic.py +34 -0
- quantbt/optimization/evaluators/grid_dca.py +22 -0
- quantbt/optimization/evaluators/intrabar.py +54 -0
- quantbt/optimization/evaluators/native_event.py +51 -0
- quantbt/optimization/evaluators/options.py +22 -0
- quantbt/optimization/evaluators/portfolio.py +42 -0
- quantbt/optimization/evaluators/signal.py +43 -0
- quantbt/optimization/multiseed.py +176 -0
- quantbt/optimization/objectives.py +221 -0
- quantbt/optimization/optimizer.py +500 -0
- quantbt/optimization/result.py +80 -0
- quantbt/optimization/samplers.py +84 -0
- quantbt/optimization/space.py +223 -0
- quantbt/options/__init__.py +215 -0
- quantbt/options/cache.py +116 -0
- quantbt/options/conventions.py +158 -0
- quantbt/options/data.py +177 -0
- quantbt/options/execution.py +600 -0
- quantbt/options/fees.py +135 -0
- quantbt/options/greeks.py +173 -0
- quantbt/options/hedging.py +232 -0
- quantbt/options/iv.py +188 -0
- quantbt/options/ledger.py +263 -0
- quantbt/options/lifecycle.py +94 -0
- quantbt/options/margin.py +311 -0
- quantbt/options/packages.py +147 -0
- quantbt/options/pricing.py +191 -0
- quantbt/options/schema.py +227 -0
- quantbt/options/selectors.py +281 -0
- quantbt/options/strategy.py +249 -0
- quantbt/options/surface.py +142 -0
- quantbt/options/tape.py +226 -0
- quantbt/options/templates/__init__.py +33 -0
- quantbt/options/templates/packages.py +354 -0
- quantbt/portfolio.py +606 -0
- quantbt/py.typed +1 -0
- quantbt/reporting/__init__.py +38 -0
- quantbt/reporting/arbitrage_audit.py +211 -0
- quantbt/reporting/nautilus_bundle.py +758 -0
- quantbt/reporting/nautilus_certification.py +226 -0
- quantbt/reporting/nautilus_diagnostics.py +225 -0
- quantbt/reporting/parity.py +496 -0
- quantbt/reporting/portfolio_audit.py +206 -0
- quantbt/reporting/portfolio_nautilus.py +140 -0
- quantbt/sizing/__init__.py +3 -0
- quantbt/sizing/fast.py +67 -0
- quantbt/sizing/modes.py +162 -0
- quantbt/viz/__init__.py +4 -0
- quantbt/viz/plots.py +310 -0
- quantbt/viz/themes.py +102 -0
- quantbt/walkforward.py +3136 -0
- quantbt_engine-1.0.7.dist-info/METADATA +891 -0
- quantbt_engine-1.0.7.dist-info/RECORD +133 -0
- quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
- quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
- quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Session-aware intrabar execution primitives.
|
|
2
|
+
|
|
3
|
+
These objects are intentionally data-only. Calendar, timezone, and entry-window
|
|
4
|
+
logic are normalized before the execution kernel so the hot path never needs to
|
|
5
|
+
parse datetimes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Optional, Sequence
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EntryPositionPolicy(str, Enum):
|
|
21
|
+
CURRENT_BEHAVIOR = "current_behavior"
|
|
22
|
+
FLAT_ONLY = "flat_only"
|
|
23
|
+
REVERSE = "reverse"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SessionCounterBasis(str, Enum):
|
|
27
|
+
FILLED_ENTRY = "filled_entry"
|
|
28
|
+
ACCEPTED_ENTRY = "accepted_entry"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProtectiveExitReentryPolicy(str, Enum):
|
|
32
|
+
ALLOW = "allow"
|
|
33
|
+
SUPPRESS_SIGNAL_BAR = "suppress_signal_bar"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class SessionExecutionPolicy:
|
|
38
|
+
entry_position_policy: EntryPositionPolicy = EntryPositionPolicy.CURRENT_BEHAVIOR
|
|
39
|
+
max_long_entries_per_session: Optional[int] = None
|
|
40
|
+
max_short_entries_per_session: Optional[int] = None
|
|
41
|
+
counter_basis: SessionCounterBasis = SessionCounterBasis.FILLED_ENTRY
|
|
42
|
+
cancel_pending_on_session_change: bool = True
|
|
43
|
+
suppress_entry_on_force_flat_bar: bool = True
|
|
44
|
+
protective_exit_reentry_policy: ProtectiveExitReentryPolicy = ProtectiveExitReentryPolicy.ALLOW
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
object.__setattr__(self, "entry_position_policy", EntryPositionPolicy(self.entry_position_policy))
|
|
48
|
+
object.__setattr__(self, "counter_basis", SessionCounterBasis(self.counter_basis))
|
|
49
|
+
object.__setattr__(self, "protective_exit_reentry_policy", ProtectiveExitReentryPolicy(self.protective_exit_reentry_policy))
|
|
50
|
+
for name in ("max_long_entries_per_session", "max_short_entries_per_session"):
|
|
51
|
+
value = getattr(self, name)
|
|
52
|
+
if value is not None and int(value) < 0:
|
|
53
|
+
raise ValueError(f"{name} must be >= 0 when provided")
|
|
54
|
+
if value is not None:
|
|
55
|
+
object.__setattr__(self, name, int(value))
|
|
56
|
+
|
|
57
|
+
def to_metadata(self) -> dict:
|
|
58
|
+
return {
|
|
59
|
+
"entry_position_policy": self.entry_position_policy.value,
|
|
60
|
+
"max_long_entries_per_session": self.max_long_entries_per_session,
|
|
61
|
+
"max_short_entries_per_session": self.max_short_entries_per_session,
|
|
62
|
+
"counter_basis": self.counter_basis.value,
|
|
63
|
+
"cancel_pending_on_session_change": bool(self.cancel_pending_on_session_change),
|
|
64
|
+
"suppress_entry_on_force_flat_bar": bool(self.suppress_entry_on_force_flat_bar),
|
|
65
|
+
"protective_exit_reentry_policy": self.protective_exit_reentry_policy.value,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_metadata(cls, metadata: Optional[dict]) -> Optional["SessionExecutionPolicy"]:
|
|
70
|
+
if metadata is None:
|
|
71
|
+
return None
|
|
72
|
+
if isinstance(metadata, SessionExecutionPolicy):
|
|
73
|
+
return metadata
|
|
74
|
+
return cls(**dict(metadata))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class IntrabarSessionTape:
|
|
79
|
+
session_id: np.ndarray
|
|
80
|
+
entry_allowed_at_open: np.ndarray
|
|
81
|
+
force_flat_at_open: np.ndarray
|
|
82
|
+
signature: str = ""
|
|
83
|
+
|
|
84
|
+
def __post_init__(self) -> None:
|
|
85
|
+
session_id = np.ascontiguousarray(self.session_id, dtype=np.int64)
|
|
86
|
+
entry_allowed = np.ascontiguousarray(self.entry_allowed_at_open, dtype=np.bool_)
|
|
87
|
+
force_flat = np.ascontiguousarray(self.force_flat_at_open, dtype=np.bool_)
|
|
88
|
+
n = len(session_id)
|
|
89
|
+
if len(entry_allowed) != n or len(force_flat) != n:
|
|
90
|
+
raise ValueError("session tape arrays must have the same length")
|
|
91
|
+
session_id.setflags(write=False)
|
|
92
|
+
entry_allowed.setflags(write=False)
|
|
93
|
+
force_flat.setflags(write=False)
|
|
94
|
+
object.__setattr__(self, "session_id", session_id)
|
|
95
|
+
object.__setattr__(self, "entry_allowed_at_open", entry_allowed)
|
|
96
|
+
object.__setattr__(self, "force_flat_at_open", force_flat)
|
|
97
|
+
signature = self.signature or self._build_signature(session_id, entry_allowed, force_flat)
|
|
98
|
+
object.__setattr__(self, "signature", signature)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_index(
|
|
102
|
+
cls,
|
|
103
|
+
index: Sequence,
|
|
104
|
+
*,
|
|
105
|
+
timezone: str = "UTC",
|
|
106
|
+
session_key: str = "local_date",
|
|
107
|
+
entry_windows: Sequence[tuple[str, str]] = (),
|
|
108
|
+
force_flat_time: Optional[str] = None,
|
|
109
|
+
) -> "IntrabarSessionTape":
|
|
110
|
+
idx = pd.DatetimeIndex(pd.to_datetime(index))
|
|
111
|
+
if idx.tz is None:
|
|
112
|
+
if not timezone:
|
|
113
|
+
raise ValueError("timezone is required for naive session indexes")
|
|
114
|
+
idx = idx.tz_localize(timezone)
|
|
115
|
+
local = idx.tz_convert(timezone)
|
|
116
|
+
if session_key != "local_date":
|
|
117
|
+
raise NotImplementedError("IntrabarSessionTape.from_index currently supports session_key='local_date'")
|
|
118
|
+
dates = pd.Index(local.date)
|
|
119
|
+
_, session_id = np.unique(dates.astype(str), return_inverse=True)
|
|
120
|
+
minutes = local.hour.to_numpy(dtype=np.int64) * 60 + local.minute.to_numpy(dtype=np.int64)
|
|
121
|
+
if entry_windows:
|
|
122
|
+
entry_allowed = np.zeros(len(local), dtype=np.bool_)
|
|
123
|
+
for start, end in entry_windows:
|
|
124
|
+
start_min = _parse_hhmm(start)
|
|
125
|
+
end_min = _parse_hhmm(end)
|
|
126
|
+
entry_allowed |= (minutes >= start_min) & (minutes <= end_min)
|
|
127
|
+
else:
|
|
128
|
+
entry_allowed = np.ones(len(local), dtype=np.bool_)
|
|
129
|
+
force_flat = np.zeros(len(local), dtype=np.bool_)
|
|
130
|
+
if force_flat_time is not None:
|
|
131
|
+
force_flat[:] = minutes == _parse_hhmm(force_flat_time)
|
|
132
|
+
return cls(
|
|
133
|
+
session_id=np.ascontiguousarray(session_id, dtype=np.int64),
|
|
134
|
+
entry_allowed_at_open=entry_allowed,
|
|
135
|
+
force_flat_at_open=force_flat,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _build_signature(session_id: np.ndarray, entry_allowed: np.ndarray, force_flat: np.ndarray) -> str:
|
|
140
|
+
h = hashlib.blake2b(digest_size=16)
|
|
141
|
+
for arr in (session_id, entry_allowed, force_flat):
|
|
142
|
+
h.update(np.ascontiguousarray(arr).view(np.uint8))
|
|
143
|
+
payload = {
|
|
144
|
+
"session_id": str(session_id.dtype),
|
|
145
|
+
"entry_allowed": str(entry_allowed.dtype),
|
|
146
|
+
"force_flat": str(force_flat.dtype),
|
|
147
|
+
"rows": int(len(session_id)),
|
|
148
|
+
"hash": h.hexdigest(),
|
|
149
|
+
}
|
|
150
|
+
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parse_hhmm(value: str) -> int:
|
|
154
|
+
hour, minute = str(value).split(":", 1)
|
|
155
|
+
return int(hour) * 60 + int(minute)
|
|
156
|
+
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Strict market tape preparation for execution-certified engines.
|
|
3
|
+
|
|
4
|
+
This module intentionally does not reuse the compatibility preprocessor. The
|
|
5
|
+
existing preprocessor is permissive for legacy notebooks; Phase 31 engines need
|
|
6
|
+
explicit validation and a certificate before kernels run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
import hashlib
|
|
13
|
+
from typing import Dict, Optional, Sequence, Union
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
SeriesMap = Dict[str, pd.Series]
|
|
20
|
+
FrameMap = Dict[str, pd.DataFrame]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class MarketValidationCertificate:
|
|
25
|
+
signature: str
|
|
26
|
+
row_count: int
|
|
27
|
+
symbol_count: int
|
|
28
|
+
timezone: str
|
|
29
|
+
first_timestamp_ns: int
|
|
30
|
+
last_timestamp_ns: int
|
|
31
|
+
finite_ok: bool
|
|
32
|
+
ohlc_ok: bool
|
|
33
|
+
monotonic_ok: bool
|
|
34
|
+
unique_ok: bool
|
|
35
|
+
alignment_ok: bool
|
|
36
|
+
bar_timestamp_semantics: str = "close"
|
|
37
|
+
validator_version: str = "market_tape_v1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class PreparedMarketTape:
|
|
42
|
+
timestamps_ns: np.ndarray
|
|
43
|
+
symbols: tuple[str, ...]
|
|
44
|
+
opens: np.ndarray
|
|
45
|
+
highs: np.ndarray
|
|
46
|
+
lows: np.ndarray
|
|
47
|
+
closes: np.ndarray
|
|
48
|
+
volumes: np.ndarray
|
|
49
|
+
funding_rates: np.ndarray
|
|
50
|
+
funding_event_mask: np.ndarray
|
|
51
|
+
bar_timestamp_semantics: str
|
|
52
|
+
signature: str
|
|
53
|
+
validation_certificate: MarketValidationCertificate
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def n_bars(self) -> int:
|
|
57
|
+
return int(self.opens.shape[0])
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def n_symbols(self) -> int:
|
|
61
|
+
return int(self.opens.shape[1])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def prepare_market_tape(
|
|
65
|
+
*,
|
|
66
|
+
data: Optional[Union[pd.DataFrame, FrameMap]] = None,
|
|
67
|
+
opens: Optional[Union[pd.Series, SeriesMap]] = None,
|
|
68
|
+
highs: Optional[Union[pd.Series, SeriesMap]] = None,
|
|
69
|
+
lows: Optional[Union[pd.Series, SeriesMap]] = None,
|
|
70
|
+
closes: Optional[Union[pd.Series, SeriesMap]] = None,
|
|
71
|
+
volumes: Optional[Union[pd.Series, SeriesMap]] = None,
|
|
72
|
+
datetime_index: Optional[pd.DatetimeIndex] = None,
|
|
73
|
+
symbols: Optional[Sequence[str]] = None,
|
|
74
|
+
funding_rate: Union[float, pd.Series, Dict[str, Union[float, pd.Series]]] = 0.0,
|
|
75
|
+
funding_event_timestamps: Optional[Union[pd.DatetimeIndex, Sequence]] = None,
|
|
76
|
+
funding_event_rates: Optional[Union[Sequence, pd.Series, Dict[str, Union[Sequence, pd.Series]]]] = None,
|
|
77
|
+
use_funding: bool = True,
|
|
78
|
+
validation_mode: str = "strict",
|
|
79
|
+
missing_funding_policy: str = "raise",
|
|
80
|
+
source_timezone: Optional[str] = None,
|
|
81
|
+
bar_timestamp_semantics: str = "close",
|
|
82
|
+
) -> PreparedMarketTape:
|
|
83
|
+
"""
|
|
84
|
+
Build a strict, immutable OHLCV/funding tape.
|
|
85
|
+
|
|
86
|
+
`validation_mode="strict"` rejects unsorted, duplicate, missing, NaN, and
|
|
87
|
+
invalid OHLC data. It does not forward-fill or fallback high/low to close.
|
|
88
|
+
"""
|
|
89
|
+
mode = str(validation_mode).lower().strip()
|
|
90
|
+
if mode not in {"strict", "trusted_prepared", "debug"}:
|
|
91
|
+
raise ValueError("validation_mode must be strict, trusted_prepared, or debug")
|
|
92
|
+
timestamp_semantics = _normalize_bar_timestamp_semantics(bar_timestamp_semantics)
|
|
93
|
+
if isinstance(data, PreparedMarketTape):
|
|
94
|
+
if data.bar_timestamp_semantics != timestamp_semantics:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"prepared market tape bar_timestamp_semantics does not match requested semantics"
|
|
97
|
+
)
|
|
98
|
+
return data
|
|
99
|
+
|
|
100
|
+
frames, symbol_list = _frames_from_inputs(
|
|
101
|
+
data=data,
|
|
102
|
+
opens=opens,
|
|
103
|
+
highs=highs,
|
|
104
|
+
lows=lows,
|
|
105
|
+
closes=closes,
|
|
106
|
+
volumes=volumes,
|
|
107
|
+
datetime_index=datetime_index,
|
|
108
|
+
symbols=symbols,
|
|
109
|
+
source_timezone=source_timezone,
|
|
110
|
+
)
|
|
111
|
+
if not symbol_list:
|
|
112
|
+
raise ValueError("at least one symbol is required")
|
|
113
|
+
idx = frames[symbol_list[0]].index
|
|
114
|
+
_validate_index(idx, name=symbol_list[0])
|
|
115
|
+
for symbol in symbol_list[1:]:
|
|
116
|
+
if not frames[symbol].index.equals(idx):
|
|
117
|
+
raise ValueError(f"symbol {symbol!r} index is not aligned to {symbol_list[0]!r}")
|
|
118
|
+
|
|
119
|
+
n = len(idx)
|
|
120
|
+
m = len(symbol_list)
|
|
121
|
+
opens_m = np.empty((n, m), dtype=np.float64)
|
|
122
|
+
highs_m = np.empty((n, m), dtype=np.float64)
|
|
123
|
+
lows_m = np.empty((n, m), dtype=np.float64)
|
|
124
|
+
closes_m = np.empty((n, m), dtype=np.float64)
|
|
125
|
+
volumes_m = np.empty((n, m), dtype=np.float64)
|
|
126
|
+
for j, symbol in enumerate(symbol_list):
|
|
127
|
+
frame = frames[symbol]
|
|
128
|
+
_validate_ohlcv_frame(frame, symbol)
|
|
129
|
+
opens_m[:, j] = frame["open"].to_numpy(dtype=np.float64)
|
|
130
|
+
highs_m[:, j] = frame["high"].to_numpy(dtype=np.float64)
|
|
131
|
+
lows_m[:, j] = frame["low"].to_numpy(dtype=np.float64)
|
|
132
|
+
closes_m[:, j] = frame["close"].to_numpy(dtype=np.float64)
|
|
133
|
+
volumes_m[:, j] = frame["volume"].to_numpy(dtype=np.float64)
|
|
134
|
+
|
|
135
|
+
ohlcv = np.stack((opens_m, highs_m, lows_m, closes_m, volumes_m), axis=2)
|
|
136
|
+
finite_ok = bool(np.isfinite(ohlcv).all())
|
|
137
|
+
if not finite_ok:
|
|
138
|
+
raise ValueError("OHLCV contains NaN or infinite values")
|
|
139
|
+
ohlc_ok = bool(
|
|
140
|
+
(
|
|
141
|
+
(lows_m <= opens_m)
|
|
142
|
+
& (lows_m <= closes_m)
|
|
143
|
+
& (highs_m >= opens_m)
|
|
144
|
+
& (highs_m >= closes_m)
|
|
145
|
+
& (highs_m >= lows_m)
|
|
146
|
+
& (opens_m > 0.0)
|
|
147
|
+
& (highs_m > 0.0)
|
|
148
|
+
& (lows_m > 0.0)
|
|
149
|
+
& (closes_m > 0.0)
|
|
150
|
+
& (volumes_m >= 0.0)
|
|
151
|
+
).all()
|
|
152
|
+
)
|
|
153
|
+
if not ohlc_ok:
|
|
154
|
+
raise ValueError("invalid OHLCV invariant")
|
|
155
|
+
|
|
156
|
+
timestamps_ns = idx.view("int64").astype(np.int64, copy=True)
|
|
157
|
+
funding_m, funding_mask = _prepare_funding_matrix(
|
|
158
|
+
funding_rate=funding_rate,
|
|
159
|
+
funding_event_timestamps=funding_event_timestamps,
|
|
160
|
+
funding_event_rates=funding_event_rates,
|
|
161
|
+
use_funding=use_funding,
|
|
162
|
+
symbols=symbol_list,
|
|
163
|
+
idx=idx,
|
|
164
|
+
missing_funding_policy=missing_funding_policy,
|
|
165
|
+
source_timezone=source_timezone,
|
|
166
|
+
)
|
|
167
|
+
signature = _signature(
|
|
168
|
+
timestamps_ns,
|
|
169
|
+
symbol_list,
|
|
170
|
+
opens_m,
|
|
171
|
+
highs_m,
|
|
172
|
+
lows_m,
|
|
173
|
+
closes_m,
|
|
174
|
+
volumes_m,
|
|
175
|
+
funding_m,
|
|
176
|
+
funding_mask.astype(np.float64),
|
|
177
|
+
metadata=f"bar_timestamp_semantics={timestamp_semantics}",
|
|
178
|
+
)
|
|
179
|
+
cert = MarketValidationCertificate(
|
|
180
|
+
signature=signature,
|
|
181
|
+
row_count=int(n),
|
|
182
|
+
symbol_count=int(m),
|
|
183
|
+
timezone=str(idx.tz),
|
|
184
|
+
first_timestamp_ns=int(timestamps_ns[0]),
|
|
185
|
+
last_timestamp_ns=int(timestamps_ns[-1]),
|
|
186
|
+
finite_ok=finite_ok,
|
|
187
|
+
ohlc_ok=ohlc_ok,
|
|
188
|
+
monotonic_ok=True,
|
|
189
|
+
unique_ok=True,
|
|
190
|
+
alignment_ok=True,
|
|
191
|
+
bar_timestamp_semantics=timestamp_semantics,
|
|
192
|
+
)
|
|
193
|
+
arrays = (timestamps_ns, opens_m, highs_m, lows_m, closes_m, volumes_m, funding_m, funding_mask)
|
|
194
|
+
for arr in arrays:
|
|
195
|
+
arr.setflags(write=False)
|
|
196
|
+
return PreparedMarketTape(
|
|
197
|
+
timestamps_ns=np.ascontiguousarray(timestamps_ns),
|
|
198
|
+
symbols=tuple(symbol_list),
|
|
199
|
+
opens=np.ascontiguousarray(opens_m),
|
|
200
|
+
highs=np.ascontiguousarray(highs_m),
|
|
201
|
+
lows=np.ascontiguousarray(lows_m),
|
|
202
|
+
closes=np.ascontiguousarray(closes_m),
|
|
203
|
+
volumes=np.ascontiguousarray(volumes_m),
|
|
204
|
+
funding_rates=np.ascontiguousarray(funding_m),
|
|
205
|
+
funding_event_mask=np.ascontiguousarray(funding_mask),
|
|
206
|
+
bar_timestamp_semantics=timestamp_semantics,
|
|
207
|
+
signature=signature,
|
|
208
|
+
validation_certificate=cert,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _frames_from_inputs(
|
|
213
|
+
*,
|
|
214
|
+
data,
|
|
215
|
+
opens,
|
|
216
|
+
highs,
|
|
217
|
+
lows,
|
|
218
|
+
closes,
|
|
219
|
+
volumes,
|
|
220
|
+
datetime_index,
|
|
221
|
+
symbols,
|
|
222
|
+
source_timezone,
|
|
223
|
+
) -> tuple[FrameMap, list[str]]:
|
|
224
|
+
if data is not None:
|
|
225
|
+
if isinstance(data, pd.DataFrame):
|
|
226
|
+
symbol_list = list(symbols or ["DEFAULT"])
|
|
227
|
+
if len(symbol_list) != 1:
|
|
228
|
+
raise ValueError("single DataFrame market tape requires one symbol")
|
|
229
|
+
return {symbol_list[0]: _standard_frame(data, datetime_index, source_timezone=source_timezone)}, symbol_list
|
|
230
|
+
symbol_list = list(symbols or data.keys())
|
|
231
|
+
return {symbol: _standard_frame(data[symbol], datetime_index=None, source_timezone=source_timezone) for symbol in symbol_list}, symbol_list
|
|
232
|
+
|
|
233
|
+
if closes is None:
|
|
234
|
+
raise ValueError("closes or data is required")
|
|
235
|
+
if isinstance(closes, pd.Series):
|
|
236
|
+
symbol_list = list(symbols or ["DEFAULT"])
|
|
237
|
+
if len(symbol_list) != 1:
|
|
238
|
+
raise ValueError("single Series market tape requires one symbol")
|
|
239
|
+
symbol = symbol_list[0]
|
|
240
|
+
idx = _strict_index(datetime_index if datetime_index is not None else closes.index, name=symbol, source_timezone=source_timezone)
|
|
241
|
+
frame = pd.DataFrame(
|
|
242
|
+
{
|
|
243
|
+
"open": _series_for_symbol(opens, symbol, idx, required=True),
|
|
244
|
+
"high": _series_for_symbol(highs, symbol, idx, required=True),
|
|
245
|
+
"low": _series_for_symbol(lows, symbol, idx, required=True),
|
|
246
|
+
"close": _align_exact(closes, idx, "close"),
|
|
247
|
+
"volume": _series_for_symbol(volumes, symbol, idx, required=False),
|
|
248
|
+
},
|
|
249
|
+
index=idx,
|
|
250
|
+
)
|
|
251
|
+
return {symbol: frame}, symbol_list
|
|
252
|
+
|
|
253
|
+
symbol_list = list(symbols or closes.keys())
|
|
254
|
+
idx = _strict_index(datetime_index if datetime_index is not None else closes[symbol_list[0]].index, name=symbol_list[0], source_timezone=source_timezone)
|
|
255
|
+
frames = {}
|
|
256
|
+
for symbol in symbol_list:
|
|
257
|
+
frames[symbol] = pd.DataFrame(
|
|
258
|
+
{
|
|
259
|
+
"open": _series_for_symbol(opens, symbol, idx, required=True),
|
|
260
|
+
"high": _series_for_symbol(highs, symbol, idx, required=True),
|
|
261
|
+
"low": _series_for_symbol(lows, symbol, idx, required=True),
|
|
262
|
+
"close": _align_exact(closes[symbol], idx, "close"),
|
|
263
|
+
"volume": _series_for_symbol(volumes, symbol, idx, required=False),
|
|
264
|
+
},
|
|
265
|
+
index=idx,
|
|
266
|
+
)
|
|
267
|
+
return frames, symbol_list
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _standard_frame(data: pd.DataFrame, datetime_index=None, *, source_timezone: Optional[str] = None) -> pd.DataFrame:
|
|
271
|
+
frame = data.copy().rename(
|
|
272
|
+
columns={
|
|
273
|
+
"Datetime": "timestamp",
|
|
274
|
+
"Date": "timestamp",
|
|
275
|
+
"Timestamp": "timestamp",
|
|
276
|
+
"Open": "open",
|
|
277
|
+
"High": "high",
|
|
278
|
+
"Low": "low",
|
|
279
|
+
"Close": "close",
|
|
280
|
+
"Volume": "volume",
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
if datetime_index is not None:
|
|
284
|
+
frame.index = _strict_index(datetime_index, name="datetime_index", source_timezone=source_timezone)
|
|
285
|
+
elif "timestamp" in frame.columns:
|
|
286
|
+
frame = frame.set_index(_strict_index(frame["timestamp"], name="timestamp", source_timezone=source_timezone))
|
|
287
|
+
else:
|
|
288
|
+
frame.index = _strict_index(frame.index, name="data", source_timezone=source_timezone)
|
|
289
|
+
required = {"open", "high", "low", "close"}
|
|
290
|
+
missing = sorted(required - set(frame.columns))
|
|
291
|
+
if missing:
|
|
292
|
+
raise ValueError(f"market data is missing required columns {missing}")
|
|
293
|
+
if "volume" not in frame.columns:
|
|
294
|
+
frame["volume"] = 0.0
|
|
295
|
+
frame = frame[["open", "high", "low", "close", "volume"]].copy()
|
|
296
|
+
frame.index = _strict_index(frame.index, name="data", source_timezone=source_timezone)
|
|
297
|
+
return frame
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _strict_index(value, *, name: str, source_timezone: Optional[str] = None) -> pd.DatetimeIndex:
|
|
301
|
+
raw = pd.DatetimeIndex(pd.to_datetime(value, errors="raise"))
|
|
302
|
+
if raw.tz is None:
|
|
303
|
+
if source_timezone is None:
|
|
304
|
+
raise ValueError(f"{name} index is timezone-naive; pass source_timezone for strict market tape")
|
|
305
|
+
raw = raw.tz_localize(source_timezone)
|
|
306
|
+
idx = raw.tz_convert("UTC")
|
|
307
|
+
_validate_index(idx, name=name)
|
|
308
|
+
return idx
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _validate_index(idx: pd.DatetimeIndex, *, name: str) -> None:
|
|
312
|
+
if len(idx) == 0:
|
|
313
|
+
raise ValueError(f"{name} index is empty")
|
|
314
|
+
values = idx.view("int64")
|
|
315
|
+
if not bool(np.all(values[1:] > values[:-1])):
|
|
316
|
+
if bool(pd.Index(values).duplicated().any()):
|
|
317
|
+
raise ValueError(f"{name} index contains duplicate timestamps")
|
|
318
|
+
raise ValueError(f"{name} index must be strictly increasing")
|
|
319
|
+
if idx.tz is None:
|
|
320
|
+
raise ValueError(f"{name} index must be timezone-aware")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _validate_ohlcv_frame(frame: pd.DataFrame, symbol: str) -> None:
|
|
324
|
+
if len(frame) == 0:
|
|
325
|
+
raise ValueError(f"{symbol} market data is empty")
|
|
326
|
+
missing = [col for col in ("open", "high", "low", "close", "volume") if col not in frame]
|
|
327
|
+
if missing:
|
|
328
|
+
raise ValueError(f"{symbol} market data is missing columns {missing}")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _series_for_symbol(data, symbol: str, idx: pd.DatetimeIndex, *, required: bool) -> pd.Series:
|
|
332
|
+
if data is None:
|
|
333
|
+
if required:
|
|
334
|
+
raise ValueError(f"{symbol} requires explicit open/high/low/close for strict market tape")
|
|
335
|
+
return pd.Series(0.0, index=idx, name="volume")
|
|
336
|
+
if isinstance(data, pd.Series):
|
|
337
|
+
series = data
|
|
338
|
+
else:
|
|
339
|
+
if symbol not in data:
|
|
340
|
+
if required:
|
|
341
|
+
raise KeyError(f"{symbol!r} missing from strict market tape input")
|
|
342
|
+
return pd.Series(0.0, index=idx, name="volume")
|
|
343
|
+
series = data[symbol]
|
|
344
|
+
return _align_exact(series, idx, symbol)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _align_exact(series: pd.Series, idx: pd.DatetimeIndex, name: str) -> pd.Series:
|
|
348
|
+
s = series.copy()
|
|
349
|
+
s.index = _strict_index(s.index, name=name)
|
|
350
|
+
if not s.index.equals(idx):
|
|
351
|
+
raise ValueError(f"{name} series index is not exactly aligned")
|
|
352
|
+
return pd.to_numeric(s, errors="raise").astype(float)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _prepare_funding_matrix(
|
|
356
|
+
*,
|
|
357
|
+
funding_rate,
|
|
358
|
+
funding_event_timestamps,
|
|
359
|
+
funding_event_rates,
|
|
360
|
+
use_funding: bool,
|
|
361
|
+
symbols: list[str],
|
|
362
|
+
idx: pd.DatetimeIndex,
|
|
363
|
+
missing_funding_policy: str,
|
|
364
|
+
source_timezone: Optional[str],
|
|
365
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
366
|
+
n = len(idx)
|
|
367
|
+
m = len(symbols)
|
|
368
|
+
funding = np.zeros((n, m), dtype=np.float64)
|
|
369
|
+
mask = np.zeros(n, dtype=np.bool_)
|
|
370
|
+
if not use_funding:
|
|
371
|
+
return funding, mask
|
|
372
|
+
policy = str(missing_funding_policy or "raise").lower().strip()
|
|
373
|
+
if policy not in {"raise", "zero"}:
|
|
374
|
+
raise ValueError("missing_funding_policy must be raise or zero")
|
|
375
|
+
if funding_event_timestamps is not None or funding_event_rates is not None:
|
|
376
|
+
if funding_event_timestamps is None or funding_event_rates is None:
|
|
377
|
+
raise ValueError("funding_event_timestamps and funding_event_rates must be provided together")
|
|
378
|
+
return _funding_from_events(
|
|
379
|
+
event_timestamps=funding_event_timestamps,
|
|
380
|
+
event_rates=funding_event_rates,
|
|
381
|
+
symbols=symbols,
|
|
382
|
+
idx=idx,
|
|
383
|
+
source_timezone=source_timezone,
|
|
384
|
+
)
|
|
385
|
+
if isinstance(funding_rate, dict):
|
|
386
|
+
for j, symbol in enumerate(symbols):
|
|
387
|
+
if symbol not in funding_rate:
|
|
388
|
+
if policy == "zero":
|
|
389
|
+
continue
|
|
390
|
+
raise KeyError(f"funding_rate dict is missing symbol {symbol!r}")
|
|
391
|
+
value = funding_rate[symbol]
|
|
392
|
+
if isinstance(value, pd.Series):
|
|
393
|
+
funding[:, j] = _align_exact(value, idx, f"funding:{symbol}").to_numpy(dtype=np.float64)
|
|
394
|
+
else:
|
|
395
|
+
scalar = float(value)
|
|
396
|
+
if policy != "zero":
|
|
397
|
+
raise ValueError("strict funding requires event timestamps/rates or an aligned Series; scalar funding is not event-causal")
|
|
398
|
+
funding[:, j] = scalar
|
|
399
|
+
elif isinstance(funding_rate, pd.Series):
|
|
400
|
+
series = _align_exact(funding_rate, idx, "funding")
|
|
401
|
+
funding[:, :] = series.to_numpy(dtype=np.float64)[:, None]
|
|
402
|
+
else:
|
|
403
|
+
scalar = float(funding_rate)
|
|
404
|
+
if scalar != 0.0 or policy != "zero":
|
|
405
|
+
raise ValueError("strict funding requires funding events or an aligned Series; use_funding=False or missing_funding_policy='zero' for no funding")
|
|
406
|
+
funding[:, :] = 0.0
|
|
407
|
+
mask[1:] = funding[1:].any(axis=1)
|
|
408
|
+
return funding, mask
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _funding_from_events(
|
|
412
|
+
*,
|
|
413
|
+
event_timestamps,
|
|
414
|
+
event_rates,
|
|
415
|
+
symbols: list[str],
|
|
416
|
+
idx: pd.DatetimeIndex,
|
|
417
|
+
source_timezone: Optional[str],
|
|
418
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
419
|
+
event_idx = _strict_index(event_timestamps, name="funding_events", source_timezone=source_timezone)
|
|
420
|
+
if len(event_idx) == 0:
|
|
421
|
+
return np.zeros((len(idx), len(symbols)), dtype=np.float64), np.zeros(len(idx), dtype=np.bool_)
|
|
422
|
+
event_ns = event_idx.view("int64")
|
|
423
|
+
if isinstance(event_rates, dict):
|
|
424
|
+
rates_by_symbol = {}
|
|
425
|
+
for symbol in symbols:
|
|
426
|
+
if symbol not in event_rates:
|
|
427
|
+
raise KeyError(f"funding_event_rates dict is missing symbol {symbol!r}")
|
|
428
|
+
rates_by_symbol[symbol] = _event_rate_values(event_rates[symbol], event_idx, symbol)
|
|
429
|
+
else:
|
|
430
|
+
values = _event_rate_values(event_rates, event_idx, "funding_events")
|
|
431
|
+
rates_by_symbol = {symbol: values for symbol in symbols}
|
|
432
|
+
|
|
433
|
+
funding = np.zeros((len(idx), len(symbols)), dtype=np.float64)
|
|
434
|
+
mask = np.zeros(len(idx), dtype=np.bool_)
|
|
435
|
+
idx_ns = idx.view("int64")
|
|
436
|
+
for k, ts_ns in enumerate(event_ns):
|
|
437
|
+
bar = int(np.searchsorted(idx_ns, ts_ns, side="left"))
|
|
438
|
+
if bar >= len(idx_ns) or idx_ns[bar] != ts_ns:
|
|
439
|
+
raise ValueError("funding events must align exactly to a market bar timestamp for POSITION_AT_EVENT certification")
|
|
440
|
+
if bar == 0:
|
|
441
|
+
raise ValueError("funding event at the first bar cannot be certified because no prior position interval exists")
|
|
442
|
+
for j, symbol in enumerate(symbols):
|
|
443
|
+
rate = float(rates_by_symbol[symbol][k])
|
|
444
|
+
if rate != 0.0:
|
|
445
|
+
funding[bar, j] += rate
|
|
446
|
+
mask[bar] = True
|
|
447
|
+
return funding, mask
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _event_rate_values(value, event_idx: pd.DatetimeIndex, name: str) -> np.ndarray:
|
|
451
|
+
if isinstance(value, pd.Series):
|
|
452
|
+
series = value.copy()
|
|
453
|
+
series.index = _strict_index(series.index, name=f"funding_event_rates:{name}")
|
|
454
|
+
if not series.index.equals(event_idx):
|
|
455
|
+
raise ValueError(f"funding event rates for {name} must align exactly to funding_event_timestamps")
|
|
456
|
+
return pd.to_numeric(series, errors="raise").to_numpy(dtype=np.float64)
|
|
457
|
+
arr = np.asarray(value, dtype=np.float64)
|
|
458
|
+
if arr.ndim == 0:
|
|
459
|
+
raise ValueError("funding_event_rates scalar is not valid; pass one rate per funding event")
|
|
460
|
+
if len(arr) != len(event_idx):
|
|
461
|
+
raise ValueError("funding_event_rates length must match funding_event_timestamps")
|
|
462
|
+
return np.ascontiguousarray(arr, dtype=np.float64)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _normalize_bar_timestamp_semantics(value: str) -> str:
|
|
466
|
+
semantics = str(value or "close").lower().strip()
|
|
467
|
+
if semantics not in {"open", "close"}:
|
|
468
|
+
raise ValueError("bar_timestamp_semantics must be 'open' or 'close'")
|
|
469
|
+
return semantics
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _signature(timestamps_ns: np.ndarray, symbols: list[str], *arrays: np.ndarray, metadata: str = "") -> str:
|
|
473
|
+
h = hashlib.sha256()
|
|
474
|
+
h.update(np.ascontiguousarray(timestamps_ns).view(np.uint8))
|
|
475
|
+
h.update("|".join(symbols).encode("utf-8"))
|
|
476
|
+
if metadata:
|
|
477
|
+
h.update(str(metadata).encode("utf-8"))
|
|
478
|
+
for arr in arrays:
|
|
479
|
+
h.update(np.ascontiguousarray(arr).view(np.uint8))
|
|
480
|
+
return h.hexdigest()
|