release: v0.4.6

- Cross-exchange backtesting (Pro)
- Dict universe format (provider-based symbol resolution)
- Exogenous data support (register_exo + exo() expressions)
- Provider-based data layout (binance/1h/TICKER.arrow)
- Preload fix for provider layout
- Exo column resampling for multi-resolution
- Pro gate for cross-exchange (clean exit)
- ATR/ADX rolling SMA fix
- Precise mode hybrid fills
This commit is contained in:
Jimmy7892
2026-04-01 01:18:20 +02:00
parent 4039012c58
commit 6ba4691a02
23 changed files with 792 additions and 118 deletions
+227 -16
View File
@@ -43,7 +43,7 @@ from manifoldbt.exceptions import (
LicenseError,
StrategyError,
)
from manifoldbt.expr import AssetRef, Expr, TimeframeRef, asset, col, hold, lit, param, s, scan, symbol_ref, tf, when
from manifoldbt.expr import AssetRef, Expr, TimeframeRef, asset, col, exo, hold, lit, param, s, scan, symbol_ref, tf, when
from manifoldbt.helpers import (
ExecutionPrice,
FillModel,
@@ -120,11 +120,12 @@ def _is_pro() -> bool:
def _require_pro(feature: str) -> None:
"""Warn and raise if not Pro. Use _gate_pro for graceful skip."""
"""Print Pro warning and exit cleanly if not Pro."""
if _is_pro():
return
_warn_pro(feature)
raise LicenseError(f"{feature} -- Pro license required")
print(f"\n\033[38;5;214m[!] {feature} -- Pro feature\033[0m")
print("\033[38;5;214m -> upgrade at www.manifoldbt.com\033[0m")
raise SystemExit(0)
def _gate_pro(feature: str) -> bool:
@@ -151,24 +152,126 @@ def _classify_error(exc: Exception) -> Exception:
# Config preparation (symbol resolution + strategy orders merge)
# ---------------------------------------------------------------------------
def _prepare_config(config: BacktestConfig, strategy: Strategy, store: DataStore) -> BacktestConfig:
"""Prepare config for execution: resolve symbols and merge strategy orders."""
cfg = config
_AC_SUFFIX_MAP = {
"spot": "CryptoSpot", "perp": "CryptoPerpetual",
"future": "Future", "equity": "Equity",
"option": "EquityOption", "fx": "Forex",
"index": "Index",
}
# Resolve string symbols in universe
has_strings = any(isinstance(s, str) for s in cfg.universe)
has_strategy_orders = hasattr(strategy, '_orders') and strategy._orders
def _resolve_normalized(sym: str, provider: str, store) -> int:
"""Resolve a normalized symbol name like 'BTC-USDT:perp' on a provider to SymbolId.
if not has_strings and not has_strategy_orders:
return cfg
Tries: 1) normalized parse → metadata lookup by (base, quote, asset_class, provider)
2) fallback to raw ticker match
"""
import sqlite3, os
cfg = copy.deepcopy(cfg)
# Parse normalized name: "BTC-USDT:perp" → base=BTC, quote=USDT, ac=CryptoPerpetual
if ":" in sym:
pair, suffix = sym.rsplit(":", 1)
ac_db = _AC_SUFFIX_MAP.get(suffix)
else:
pair, ac_db = sym, None
if has_strings:
cfg.universe = resolve_universe(cfg.universe, store)
if "-" in pair:
base, quote = pair.split("-", 1)
else:
base, quote = pair, ""
if ac_db:
# Try metadata lookup by (base, quote, asset_class, provider)
meta_db = store.metadata_db()
conn = sqlite3.connect(meta_db)
row = conn.execute(
"SELECT id FROM symbols WHERE base_currency=? COLLATE NOCASE "
"AND quote_currency=? COLLATE NOCASE AND asset_class=? "
"AND exchange=? COLLATE NOCASE ORDER BY id DESC LIMIT 1",
(base, quote, ac_db, provider.upper()),
).fetchone()
conn.close()
if row:
return row[0]
# Fallback: try raw ticker match
try:
return store.resolve_symbol(sym)
except Exception:
raise ValueError(
f"Symbol '{sym}' not found on provider '{provider}'. "
f"Searched: base={base}, quote={quote}, class={ac_db}"
)
def _resolve_source_dict(source, store):
"""Resolve a signal/execution source dict → list of (provider, norm_sym, symbol_id, raw_ticker).
Returns the raw ticker from metadata (what the files are named on disk).
"""
if isinstance(source, dict):
import sqlite3
conn = sqlite3.connect(store.metadata_db())
resolved = []
for provider, symbols in source.items():
for sym in symbols:
sid = _resolve_normalized(sym, provider, store)
# Get raw ticker from metadata
row = conn.execute("SELECT ticker FROM symbols WHERE id=?", (sid,)).fetchone()
raw_ticker = row[0] if row else sym
resolved.append((provider, sym, sid, raw_ticker))
conn.close()
return resolved
return None
def _prepare_config(config: BacktestConfig, strategy, store: DataStore) -> BacktestConfig:
"""Prepare config for execution: resolve symbols, convert deprecated fields."""
cfg = copy.deepcopy(config)
# --- Dict universe: {"binance": ["BTC-USDT:perp"], "onchain": ["hashrate"]} ---
if isinstance(cfg.universe, dict):
# Cross-exchange (multiple providers) is a Pro feature.
if len(cfg.universe) > 1:
_require_pro("Cross-exchange backtesting")
resolved_universe = []
qualified_names = {} # "binance:BTC-USDT:perp" → SymbolId
for provider, symbols in cfg.universe.items():
for sym in symbols:
sid = _resolve_normalized(sym, provider, store)
resolved_universe.append(sid)
qualified = f"{provider}:{sym}"
qualified_names[qualified] = sid
cfg.universe = resolved_universe
cfg.symbol_names = qualified_names
# Clear deprecated fields
cfg.signal_source = None
cfg.execution_source = None
cfg.pair_map = {}
cfg.exo_sources = {}
cfg.provider = None
# --- Legacy list universe: [1, 2, 3] or ["BTC-USD", "ETH-USD"] ---
elif cfg.universe:
if any(isinstance(s, str) for s in cfg.universe):
cfg.universe = resolve_universe(cfg.universe, store, cfg.symbol_names)
# Legacy exo_sources resolution
if cfg.exo_sources and any(isinstance(k, str) for k in cfg.exo_sources):
resolved = {}
for key, val in cfg.exo_sources.items():
sid = store.resolve_symbol(key) if isinstance(key, str) else key
resolved[sid] = val
cfg.exo_sources = resolved
if cfg.provider and not cfg.signal_source:
cfg.signal_source = cfg.provider
# Merge orders from strategy into execution config
if has_strategy_orders:
if strategy and hasattr(strategy, '_orders') and strategy._orders:
if cfg.execution.orders is None:
cfg.execution.orders = OrderConfig()
for key, val in strategy._orders.items():
@@ -519,6 +622,7 @@ def run_batch(
One :class:`Result` per strategy, in input order.
"""
try:
config = _prepare_config(config, None, store)
config = _cap_output_resolution(config)
store = _resolve_store(config, store)
strategy_jsons = [strat.to_json() for strat in strategies]
@@ -556,6 +660,7 @@ def run_batch_lite(
One :class:`BatchResultLite` per strategy (name, metrics, equity, trade_count).
"""
try:
config = _prepare_config(config, None, store)
config = _cap_output_resolution(config)
store = _resolve_store(config, store)
strategy_jsons = [strat.to_json() for strat in strategies]
@@ -640,6 +745,7 @@ def run_walk_forward(
"""
if not _gate_pro("Walk-forward optimization"):
return {"folds": [], "best_params_per_fold": []}
config = _prepare_config(config, strategy, store)
wf_json = json.dumps(_convert_param_grid_in_config(wf_config))
return _run_walk_forward_native(strategy.to_json(), wf_json, config.to_json(), store)
@@ -667,6 +773,7 @@ def run_sweep_2d(
Returns:
Dict with ``metric_grid`` (2D list), ``x_values``, ``y_values``, etc.
"""
config = _prepare_config(config, strategy, store)
sweep_json = json.dumps(_convert_scalar_values_in_sweep(sweep_config))
return _run_sweep_2d_native(strategy.to_json(), sweep_json, config.to_json(), store)
@@ -692,6 +799,7 @@ def run_stability(
Returns:
Dict with ``stability_score``, ``metric_values``, ``mean_metric``, ``std_metric``.
"""
config = _prepare_config(config, strategy, store)
stab_json = json.dumps(_convert_scalar_values_in_stability(stability_config))
return _run_stability_native(strategy.to_json(), stab_json, config.to_json(), store)
@@ -835,6 +943,7 @@ def run_portfolio(
breakdown via ``result.per_strategy``.
"""
try:
config = _prepare_config(config, None, store)
raw_combined, per_strategy_info = _run_portfolio_native(
portfolio.to_json(),
config.to_json(),
@@ -892,6 +1001,105 @@ def _convert_scalar_values_in_stability(stability_config: Dict[str, Any]) -> Dic
return result
# ---------------------------------------------------------------------------
# Exogenous data registration
# ---------------------------------------------------------------------------
def register_exo(
name: str,
data,
store: Optional["DataStore"] = None,
data_root: str = "data",
provider: Optional[str] = None,
timeframe: str = "1d",
):
"""Register an exogenous data series for use in strategies.
Without ``provider``: writes to ``{root}/exo/{name}.arrow`` (legacy layout).
With ``provider``: writes to ``{root}/{provider}/{timeframe}/{name}.arrow``
(unified layout, used for cross-exchange data).
Args:
name: Series identifier (e.g. ``"hashrate"``, ``"BTCUSDT"``).
data: A pandas/polars DataFrame or dict with a ``"timestamp"`` column
and one or more float value columns.
store: Optional DataStore to infer ``data_root`` from.
data_root: Root data directory (default ``"data"``).
provider: Provider name for unified layout (e.g. ``"binance"``).
timeframe: Timeframe label (e.g. ``"1d"``, ``"1h"``). Default ``"1d"``.
Example::
# Legacy (non-symbol exo like hashrate)
bt.register_exo("hashrate", df)
# Unified layout (cross-exchange)
bt.register_exo("BTCUSDT", df, provider="binance", timeframe="1h")
"""
import pyarrow as pa
from pathlib import Path
# Resolve data root
if store is not None:
root = Path(store.data_root()) / "mega"
else:
root = Path(data_root) / "mega"
if provider:
# Unified layout: {root}/{provider}/{timeframe}/{name}.arrow
target_dir = root / provider / timeframe
else:
# Legacy layout: {root}/exo/{name}.arrow
target_dir = root / "exo"
target_dir.mkdir(parents=True, exist_ok=True)
# Convert to Arrow Table
if hasattr(data, "to_arrow"):
# Polars DataFrame
table = data.to_arrow()
elif hasattr(data, "columns"):
# Pandas DataFrame
import pandas as pd
table = pa.Table.from_pandas(data)
elif isinstance(data, dict):
table = pa.table(data)
else:
raise TypeError(f"Unsupported data type: {type(data)}. Use a pandas/polars DataFrame or dict.")
# Ensure timestamp is TimestampNanosecond(UTC)
ts_idx = table.schema.get_field_index("timestamp")
if ts_idx < 0:
raise ValueError("Data must have a 'timestamp' column")
ts_type = table.schema.field(ts_idx).type
if not pa.types.is_timestamp(ts_type):
raise ValueError(f"'timestamp' column must be a timestamp type, got {ts_type}")
# Cast to nanos UTC if needed
target_type = pa.timestamp("ns", tz="UTC")
if ts_type != target_type:
ts_col = table.column(ts_idx).cast(target_type)
table = table.set_column(ts_idx, pa.field("timestamp", target_type), ts_col)
# Cast value columns to float64
for i, field in enumerate(table.schema):
if field.name == "timestamp":
continue
if field.type != pa.float64():
table = table.set_column(
i, pa.field(field.name, pa.float64()), table.column(i).cast(pa.float64())
)
# Write Arrow IPC
path = target_dir / f"{name}.arrow"
writer = pa.ipc.new_file(str(path), table.schema)
writer.write_table(table)
writer.close()
print(f"Registered exo '{name}': {table.num_rows} rows, "
f"columns={[f.name for f in table.schema if f.name != 'timestamp']} -> {path}")
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@@ -919,6 +1127,7 @@ __all__ = [
"TimeframeRef",
"asset",
"col",
"exo",
"lit",
"param",
"s",
@@ -956,6 +1165,8 @@ __all__ = [
# Portfolio
"Portfolio",
"run_portfolio",
# Exogenous data
"register_exo",
# Version
"__version__",
# Indicators (submodule)
+44 -5
View File
@@ -187,8 +187,8 @@ class BacktestConfig:
Allows indicators (EMA, SMA, etc.) to stabilise. During warmup,
equity tracking runs but no trades are generated.
Set to at least the longest indicator window (e.g. 25 for EMA(25))."""
precise: bool = False
"""When True, always load finest resolution (1m) regardless of bar_interval.
accuracy: bool = False
"""When True, simulation runs on 1-minute bars regardless of bar_interval.
Signals are still evaluated at bar_interval resolution (hybrid mode).
Use for precise SL/TP fills and intraday drawdown tracking. Slower."""
extra_timeframes: Dict[str, Any] = field(default_factory=dict)
@@ -196,6 +196,27 @@ class BacktestConfig:
Maps labels to Interval dicts. The engine resamples native bars
and injects prefixed columns (e.g. "1h.close", "4h.high").
Example: ``{"1h": Interval.hours(1), "4h": Interval.hours(4)}``"""
exo_data: List[str] = field(default_factory=list)
"""Exogenous data series names to inject into signal evaluation.
Each name corresponds to an ``exo/{name}/`` directory in the data store
(written via ``bt.register_exo()``). Columns are ASOF-joined onto
bar timestamps and accessible as ``col("exo.{name}.{column}")``.
Example: ``["hashrate", "fear_greed"]``"""
signal_source: Any = None
"""Signal data source. Dict mapping provider → list of normalized symbols.
Example: ``{"binance": ["BTC-USDT:perp", "ETH-USDT:perp"]}``
Also accepts a string (single provider for all symbols) for backward compat."""
execution_source: Any = None
"""Execution data source. Same format as ``signal_source``.
Fill prices come from this source. When absent, same as ``signal_source``.
Example: ``{"dydx": ["BTC-USD:perp", "ETH-USD:perp"]}``"""
pair_map: Dict[str, str] = field(default_factory=dict)
"""Explicit mapping from signal symbol to execution symbol.
Required when signal and execution have different tickers.
Example: ``{"BTC-USDT:perp": "BTC-USD:perp"}``"""
# Deprecated — kept for backward compat
provider: Optional[str] = None
exo_sources: Dict = field(default_factory=dict)
def to_json_dict(self) -> dict:
d: dict = {
@@ -224,10 +245,23 @@ class BacktestConfig:
d["symbol_names"] = self.symbol_names
if self.warmup_bars > 0:
d["warmup_bars"] = self.warmup_bars
if self.precise:
if self.accuracy:
d["precise"] = True
if self.extra_timeframes:
d["extra_timeframes"] = self.extra_timeframes
if self.exo_data:
d["exo_data"] = self.exo_data
if self.signal_source:
d["signal_source"] = self.signal_source
if self.execution_source:
d["execution_source"] = self.execution_source
# Deprecated fields (backward compat)
if self.provider:
d["provider"] = self.provider
if self.exo_sources:
d["exo_sources"] = {
str(sid): list(src) for sid, src in self.exo_sources.items()
}
return d
def to_json(self) -> str:
@@ -237,12 +271,14 @@ class BacktestConfig:
def resolve_universe(
universe: List[Union[int, str]],
store: Any,
symbol_names: Optional[Dict[str, int]] = None,
) -> List[int]:
"""Resolve a mixed list of symbol IDs and ticker names to integer IDs.
Args:
universe: List of integer IDs or string ticker names.
store: A ``DataStore`` instance (must have ``resolve_symbol()``).
symbol_names: Optional name-to-ID mapping (checked before store).
Returns:
List of integer symbol IDs.
@@ -256,12 +292,15 @@ def resolve_universe(
if isinstance(item, int):
result.append(item)
elif isinstance(item, str):
if store is None:
if symbol_names and item in symbol_names:
result.append(symbol_names[item])
elif store is None:
raise TypeError(
f"DataStore required to resolve symbol name {item!r}. "
f"Pass integer IDs or provide a store."
)
result.append(store.resolve_symbol(item))
else:
result.append(store.resolve_symbol(item))
else:
result.append(int(item))
return result
+28 -1
View File
@@ -5,7 +5,7 @@ Builds an expression tree that serializes to JSON matching the Rust
"""
from __future__ import annotations
from typing import Any, Union
from typing import Any, Optional, Union
from manifoldbt._serde import scalar_value_to_json
@@ -518,6 +518,33 @@ def when(condition: Expr, true_value: Any = 1.0, false_value: Any = float("nan")
return Expr("IfElse", condition, _coerce(true_value), _coerce(false_value))
def exo(name: str, column: Optional[str] = None) -> Expr:
"""Reference an exogenous data column.
Exogenous data is registered via ``bt.register_exo()`` and declared
in ``BacktestConfig(exo_data=[...])``.
Args:
name: Exo series name (e.g. ``"hashrate"``).
column: Column name within the exo series. If ``None``, defaults
to ``name`` (convenient when the series has a single value column
with the same name as the series).
Returns:
An ``Expr`` referencing ``col("exo.{name}.{column}")``.
Example::
# Single-column shorthand
signal = rsi(exo("hashrate"), 14) > 70
# Multi-column explicit
signal = exo("onchain", "active_addresses") > 1_000_000
"""
col_name = column if column is not None else name
return col(f"exo.{name}.{col_name}")
def symbol_ref(symbol: str, column: str) -> Expr:
"""Reference a column from a specific symbol's data.
+94 -44
View File
@@ -128,9 +128,14 @@ def rsi(source: Expr, period=14) -> Expr:
return source.rsi(period)
def stoch_k(period: int = 14) -> Expr:
"""Stochastic %K oscillator (native Rust, uses high/low/close)."""
return Expr("StochK", high, low, close, period)
def stoch_k(period: int = 14, *, h: Expr = None, l: Expr = None, c: Expr = None) -> Expr:
"""Stochastic %K oscillator (native Rust).
Args:
h, l, c: Custom high/low/close columns (e.g. exo columns).
Defaults to native bar columns.
"""
return Expr("StochK", h or high, l or low, c or close, period)
def stochastic_k(period: int = 14, source: Expr = None) -> Expr:
@@ -144,19 +149,32 @@ def stochastic_k(period: int = 14, source: Expr = None) -> Expr:
return (c - lowest) / (highest - lowest + lit(1e-12)) * lit(100.0)
def williams_r(period: int = 14) -> Expr:
"""Williams %R oscillator (native Rust, uses high/low/close)."""
return Expr("WilliamsR", high, low, close, period)
def williams_r(period: int = 14, *, h: Expr = None, l: Expr = None, c: Expr = None) -> Expr:
"""Williams %R oscillator (native Rust).
Args:
h, l, c: Custom high/low/close columns. Defaults to native bar columns.
"""
return Expr("WilliamsR", h or high, l or low, c or close, period)
def cci(period: int = 20) -> Expr:
"""Commodity Channel Index (native Rust, uses high/low/close)."""
return Expr("Cci", high, low, close, period)
def cci(period: int = 20, *, h: Expr = None, l: Expr = None, c: Expr = None) -> Expr:
"""Commodity Channel Index (native Rust).
Args:
h, l, c: Custom high/low/close columns. Defaults to native bar columns.
"""
return Expr("Cci", h or high, l or low, c or close, period)
def adx(period: int = 14) -> Expr:
"""Average Directional Index (native Rust, uses high/low/close)."""
return Expr("Adx", high, low, close, period)
def adx(period: int = 14, *, h: Expr = None, l: Expr = None, c: Expr = None) -> Expr:
"""Average Directional Index (native Rust).
Args:
h, l, c: Custom high/low/close columns (e.g. exo columns).
Defaults to native bar columns.
"""
return Expr("Adx", h or high, l or low, c or close, period)
# ---------------------------------------------------------------------------
@@ -183,39 +201,59 @@ def bollinger_width(source: Expr, period: int = 20, num_std: float = 2.0) -> Exp
return source.bollinger_width(period, num_std)
def atr(period: int = 14) -> Expr:
def atr(period: int = 14, *, h: Expr = None, l: Expr = None, c: Expr = None) -> Expr:
"""Average True Range (native Rust, Wilder's smoothing, single-pass O(n)).
Uses ``high``, ``low``, ``close`` columns from the bar data.
Args:
h, l, c: Custom high/low/close columns. Defaults to native bar columns.
"""
return Expr("Atr", high, low, close, period)
return Expr("Atr", h or high, l or low, c or close, period)
def true_range() -> Expr:
"""True Range (native Rust, uses high/low/close)."""
return Expr("TrueRange", high, low, close)
def true_range(*, h: Expr = None, l: Expr = None, c: Expr = None) -> Expr:
"""True Range (native Rust).
def natr(period: int = 14) -> Expr:
"""Normalized ATR (native Rust, uses high/low/close)."""
return Expr("Natr", high, low, close, period)
def keltner_channels(period: int = 20, multiplier: float = 1.5) -> Tuple[Expr, Expr, Expr]:
"""Keltner Channels (native Rust, uses high/low/close).
Returns:
``(upper, middle, lower)`` three ``Expr`` objects.
Args:
h, l, c: Custom high/low/close columns. Defaults to native bar columns.
"""
upper = Expr("KeltnerUpper", high, low, close, period, multiplier)
middle = close.ewm_mean(float(period))
lower = Expr("KeltnerLower", high, low, close, period, multiplier)
return Expr("TrueRange", h or high, l or low, c or close)
def natr(period: int = 14, *, h: Expr = None, l: Expr = None, c: Expr = None) -> Expr:
"""Normalized ATR (native Rust).
Args:
h, l, c: Custom high/low/close columns. Defaults to native bar columns.
"""
return Expr("Natr", h or high, l or low, c or close, period)
def keltner_channels(
period: int = 20, multiplier: float = 1.5,
*, h: Expr = None, l: Expr = None, c: Expr = None,
) -> Tuple[Expr, Expr, Expr]:
"""Keltner Channels (native Rust).
Args:
h, l, c: Custom high/low/close columns. Defaults to native bar columns.
"""
_h, _l, _c = h or high, l or low, c or close
upper = Expr("KeltnerUpper", _h, _l, _c, period, multiplier)
middle = _c.ewm_mean(float(period))
lower = Expr("KeltnerLower", _h, _l, _c, period, multiplier)
return upper, middle, lower
def supertrend(period: int = 10, multiplier: float = 3.0) -> Expr:
"""SuperTrend indicator (native Rust, uses high/low/close)."""
return Expr("SuperTrend", high, low, close, period, multiplier)
def supertrend(
period: int = 10, multiplier: float = 3.0,
*, h: Expr = None, l: Expr = None, c: Expr = None,
) -> Expr:
"""SuperTrend indicator (native Rust).
Args:
h, l, c: Custom high/low/close columns. Defaults to native bar columns.
"""
return Expr("SuperTrend", h or high, l or low, c or close, period, multiplier)
# ---------------------------------------------------------------------------
@@ -271,19 +309,31 @@ def obv(source: Expr = None, vol: Expr = None) -> Expr:
vol if vol is not None else volume)
def vwap() -> Expr:
"""Volume Weighted Average Price (native Rust, uses high/low/close/volume)."""
return Expr("Vwap", high, low, close, volume)
def vwap(*, h: Expr = None, l: Expr = None, c: Expr = None, v: Expr = None) -> Expr:
"""Volume Weighted Average Price (native Rust).
Args:
h, l, c, v: Custom high/low/close/volume columns. Defaults to native bar columns.
"""
return Expr("Vwap", h or high, l or low, c or close, v or volume)
def ad_line() -> Expr:
"""Accumulation/Distribution Line (native Rust, uses high/low/close/volume)."""
return Expr("AdLine", high, low, close, volume)
def ad_line(*, h: Expr = None, l: Expr = None, c: Expr = None, v: Expr = None) -> Expr:
"""Accumulation/Distribution Line (native Rust).
Args:
h, l, c, v: Custom high/low/close/volume columns. Defaults to native bar columns.
"""
return Expr("AdLine", h or high, l or low, c or close, v or volume)
def mfi(period: int = 14) -> Expr:
"""Money Flow Index (native Rust, uses high/low/close/volume)."""
return Expr("Mfi", high, low, close, volume, period)
def mfi(period: int = 14, *, h: Expr = None, l: Expr = None, c: Expr = None, v: Expr = None) -> Expr:
"""Money Flow Index (native Rust).
Args:
h, l, c, v: Custom high/low/close/volume columns. Defaults to native bar columns.
"""
return Expr("Mfi", h or high, l or low, c or close, v or volume, period)
# ---------------------------------------------------------------------------
+24 -18
View File
@@ -66,26 +66,32 @@ def _load_bars(
start_dt = datetime.fromtimestamp(start_ns / 1e9, tz=timezone.utc)
end_dt = datetime.fromtimestamp(end_ns / 1e9, tz=timezone.utc)
tables = []
day = start_dt.date()
end_day = end_dt.date()
while day <= end_day:
path = (
data_root
/ "bars_1m"
/ str(symbol_id)
/ str(day.year)
/ f"{day.month:02d}"
/ f"{day.day:02d}.parquet"
)
if path.exists():
tables.append(pq.read_table(str(path)))
day += timedelta(days=1)
# Try Arrow IPC file first (new layout), then Parquet partitions (legacy)
arrow_dir = Path(store.data_root()) / "mega" if not str(data_root).endswith("mega") else data_root
ipc_path = arrow_dir / "bars_1m" / f"{symbol_id}.arrow"
if ipc_path.exists():
table = pa.ipc.open_file(str(ipc_path)).read_all()
else:
tables = []
day = start_dt.date()
end_day = end_dt.date()
while day <= end_day:
path = (
data_root
/ "bars_1m"
/ str(symbol_id)
/ str(day.year)
/ f"{day.month:02d}"
/ f"{day.day:02d}.parquet"
)
if path.exists():
tables.append(pq.read_table(str(path)))
day += timedelta(days=1)
if not tables:
return {}
if not tables:
return {}
table = pa.concat_tables(tables)
table = pa.concat_tables(tables)
# Filter to time range
ts_col = table.column("timestamp").cast(pa.int64()).to_numpy(zero_copy_only=False)