diff --git a/README.md b/README.md index f0fa2fc..1933673 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,10 @@ account reports. - `use_pyramiding=False` snaps signals to `-1/0/1`; `True` preserves fractional scales such as `1.4`. - For crypto, `contract_size` is a notional/PnL multiplier. Exchange fractional - lots are governed by `qty_step`/`lot_size`/`min_qty`/`min_notional`. + lots are governed by shared venue constraints: + `qty_step`/`lot_size`/`slot_size`/`min_qty`/`min_notional`, applied across + native legacy, native vectorized, native event/order, native portfolio, and + Nautilus validation routes. ### DCA And Grid diff --git a/__init__.py b/__init__.py index f3847eb..a306fdd 100644 --- a/__init__.py +++ b/__init__.py @@ -137,6 +137,7 @@ build_arbitrage_order_plan, round_down_to_step, ) +from .core.constraints import QuantityConstraints, build_quantity_constraints, quantize_signed_quantity from .core.schema import ( AccountConfig, AssetType, @@ -310,6 +311,7 @@ "OrderIntent", "OrderSide", "OrderType", + "QuantityConstraints", "OptionsVolArbSpec", "PackageExecutionKind", "PackageRejection", @@ -329,12 +331,14 @@ "TriangularArbSpec", "build_arbitrage_order_plan", "build_bracket_order_plan", + "build_quantity_constraints", "build_dca_grid_order_plan", "build_frozen_basket_orders", "normalize_portfolio_mode", "normalize_portfolio_sizing_mode", "normalize_rebalance_policy", "portfolio_capability_matrix", + "quantize_signed_quantity", "round_down_to_step", "simulate_nautilus_order_package_depth", "validate_portfolio_result_contract", diff --git a/backends/native_event.py b/backends/native_event.py index 3ef61f2..5dfa1b6 100644 --- a/backends/native_event.py +++ b/backends/native_event.py @@ -21,6 +21,7 @@ TIF_IOC, _engine_event_v1, ) +from ..core.constraints import build_quantity_constraints, quantize_signed_quantity from ..core.arbitrage import ( ArbitrageSpec, ArbitragePlan, @@ -59,6 +60,7 @@ OrderSide, OrderType, TimeInForce, + InstrumentSpec, ) @@ -154,6 +156,12 @@ def run_orders( symbols: Optional[List[str]] = None, market_arrays: Optional[PreparedMarketArrays] = None, compiled_orders: Optional[CompiledOrderArrays] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, ) -> BacktestResultV2: idx = validate_datetime(datetime_index) symbol_list = symbols or list(closes.keys()) @@ -170,16 +178,38 @@ def run_orders( elif market_arrays.signature != self._market_signature(idx, symbol_list): raise ValueError("prepared market arrays do not match datetime_index/symbols") + contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) + constraints = build_quantity_constraints( + symbol_list, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + effective_orders, quantity_preflight = self._apply_order_quantity_constraints( + idx=idx, + orders=orders, + closes=market_arrays.closes, + symbol_list=symbol_list, + contract_sizes=contract_sizes, + constraints=constraints, + ) + if quantity_preflight["changed_count"] or quantity_preflight["dropped_count"]: + compiled_orders = None + orders = tuple(effective_orders) + else: + effective_orders = tuple(orders) + if compiled_orders is None: - compiled_orders = self.compile_orders(datetime_index=idx, orders=orders, symbols=symbol_list) + compiled_orders = self.compile_orders(datetime_index=idx, orders=effective_orders, symbols=symbol_list) elif ( compiled_orders.index_signature != market_arrays.signature or compiled_orders.symbols != tuple(symbol_list) ): raise ValueError("compiled orders do not match prepared market arrays") n_orders = compiled_orders.n_orders - - contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) leverages = self._per_symbol_array( self.config.account.leverage if leverage is None else leverage, symbol_list, @@ -294,11 +324,77 @@ def run_orders( "fee_rate_oneway": self._fee_rate_metadata(fee_rates, symbol_list), "slippage_bps": self.config.execution.slippage_bps, "order_report": order_report, + "quantity_constraints": constraints.as_dict(), + "quantity_preflight": quantity_preflight, "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)), "liquidation_reason": int(liq_reason), }, ) + @staticmethod + def _apply_order_quantity_constraints( + *, + idx: pd.DatetimeIndex, + orders: Sequence[OrderIntent], + closes: np.ndarray, + symbol_list: List[str], + contract_sizes: np.ndarray, + constraints, + ) -> tuple[tuple[OrderIntent, ...], Dict]: + if not constraints.enabled: + return tuple(orders), {"changed_count": 0, "dropped_count": 0, "dropped_orders": []} + sym_to_col = {symbol: j for j, symbol in enumerate(symbol_list)} + changed = 0 + dropped = [] + out: list[OrderIntent] = [] + idx_ns = idx.view("int64") + for order_idx, order in enumerate(orders): + col = sym_to_col[order.symbol] + ts = pd.Timestamp(order.timestamp) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + bar = int(np.searchsorted(idx_ns, ts.value, side="left")) + if bar >= len(idx): + bar = len(idx) - 1 + price = float(order.price) if order.price is not None else float(closes[bar, col]) + signed = order.signed_qty + q = abs( + quantize_signed_quantity( + signed, + price, + float(contract_sizes[col]), + float(constraints.qty_step[col]), + float(constraints.min_qty[col]), + float(constraints.min_notional[col]), + ) + ) + if q <= 0.0: + dropped.append({"original_index": order_idx, "symbol": order.symbol, "requested_qty": float(order.qty)}) + continue + if abs(q - float(order.qty)) > 1e-12: + changed += 1 + out.append( + OrderIntent( + timestamp=order.timestamp, + symbol=order.symbol, + side=order.side, + order_type=order.order_type, + qty=q, + price=order.price, + trigger_price=order.trigger_price, + tif=order.tif, + reduce_only=order.reduce_only, + order_id=order.order_id, + tag=order.tag, + metadata={**order.metadata, "requested_qty": float(order.qty), "quantity_quantized": True}, + ) + ) + else: + out.append(order) + return tuple(out), {"changed_count": changed, "dropped_count": len(dropped), "dropped_orders": dropped} + def run_basket( self, datetime_index: Union[pd.DatetimeIndex, pd.Series], @@ -314,6 +410,12 @@ def run_basket( fee_rate: Optional[Union[float, Dict[str, float]]] = None, rebalance_threshold: Optional[float] = None, symbols: Optional[List[str]] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, ) -> BacktestResultV2: """ Build frozen basket orders from a scalar signal and execute them. @@ -343,6 +445,12 @@ def run_basket( leverage=leverage, fee_rate=fee_rate, symbols=symbols, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, ) result.metadata["basket_plan"] = plan result.metadata["basket_target_units"] = plan.target_units diff --git a/backends/native_portfolio.py b/backends/native_portfolio.py index 6e8d3af..a4c8ce4 100644 --- a/backends/native_portfolio.py +++ b/backends/native_portfolio.py @@ -19,6 +19,7 @@ import pandas as pd from ..core.engine import _engine_portfolio, _engine_portfolio_equity_sizing +from ..core.constraints import build_quantity_constraints, quantize_target_units_matrix from ..core.portfolio import ( NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES, PortfolioDomainSpec, @@ -37,7 +38,7 @@ ) from ..core.results import BacktestResultV2 from ..core.schema import AccountConfig -from ..core.schema import ExecutionConfig +from ..core.schema import ExecutionConfig, InstrumentSpec from ..sizing.fast import scale_signal_notional_matrix @@ -87,6 +88,12 @@ def run_signals( risk_lookback: int = 60, market_arrays: Optional[PreparedMarketArrays] = None, raw_signal_matrix: Optional[np.ndarray] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, ) -> BacktestResultV2: idx = validate_datetime(datetime_index) if positions is None and raw_signal_matrix is None: @@ -128,6 +135,15 @@ def run_signals( raise ValueError("raw_signal_matrix shape does not match prepared market arrays") cs_arr = self._per_symbol_array(contract_size, symbol_list, default=1.0) + constraints = build_quantity_constraints( + symbol_list, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) lev_arr = self._per_symbol_array( self.config.account.leverage if leverage is None else leverage, symbol_list, @@ -173,6 +189,9 @@ def run_signals( exposure_scalar=float(np.mean(alloc_arr)) if len(alloc_arr) else 1.0, beta=beta_arr, inv_vol=inv_vol, + qty_steps=constraints.qty_step, + min_qtys=constraints.min_qty, + min_notionals=constraints.min_notional, ) else: target_units = self._scale_target_units( @@ -191,6 +210,7 @@ def run_signals( betas=beta_arr, risk_vol=risk_vol, ) + target_units = quantize_target_units_matrix(target_units, market.closes, cs_arr, constraints) ( equity_arr, @@ -239,6 +259,7 @@ def run_signals( maintenance_ratio=maint_ratio, liquidated=bool(liq_flag), liquidation_bar=int(liq_idx), + quantity_constraints=constraints.as_dict(), ) spec = PortfolioDomainSpec(mode=portfolio_mode, sizing_mode=sizing_mode) result.metadata["portfolio_contract_report"] = validate_portfolio_result_contract(result, spec, tolerance=1e-8) @@ -423,6 +444,7 @@ def _build_result( maintenance_ratio: float, liquidated: bool, liquidation_bar: int, + quantity_constraints: Dict[str, Dict[str, float]], ) -> BacktestResultV2: equity = pd.Series(equity_arr, index=idx, name="equity") close_report = pd.DataFrame(closes_m, index=idx, columns=symbol_list, copy=False) @@ -519,6 +541,7 @@ def _build_result( "turnover_total": float(np.sum(turnover_arr)), "fee_rate_oneway": float(self.config.fee_rate), "contract_size": {s: float(contract_sizes[j]) for j, s in enumerate(symbol_list)}, + "quantity_constraints": quantity_constraints, }, ) diff --git a/backends/native_vectorized.py b/backends/native_vectorized.py index 6a36fa9..2451ccb 100644 --- a/backends/native_vectorized.py +++ b/backends/native_vectorized.py @@ -13,8 +13,9 @@ import pandas as pd from ..core.preprocessor import align_series, build_arrays, prepare_funding, validate_datetime +from ..core.constraints import build_quantity_constraints, quantize_target_units_matrix from ..core.results import BacktestResultV2 -from ..core.schema import AccountConfig, BasketLegSpec, BasketSpec, ExecutionConfig +from ..core.schema import AccountConfig, BasketLegSpec, BasketSpec, ExecutionConfig, InstrumentSpec from ..core.vectorized import _engine_units_v2 from ..core.arbitrage import ( ArbitrageSpec, @@ -80,6 +81,12 @@ def run_target_units( leverage: Optional[Union[float, Dict[str, float]]] = None, fee_rate: Optional[Union[float, Dict[str, float]]] = None, symbols: Optional[List[str]] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, ) -> BacktestResultV2: idx = validate_datetime(datetime_index) symbol_list = symbols or list(target_units.keys()) @@ -114,6 +121,12 @@ def run_target_units( contract_size=contract_size, leverage=leverage, fee_rate=fee_rate, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, ) def _run_target_arrays( @@ -129,8 +142,24 @@ def _run_target_arrays( contract_size: Union[float, Dict[str, float]] = 1.0, leverage: Optional[Union[float, Dict[str, float]]] = None, fee_rate: Optional[Union[float, Dict[str, float]]] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, ) -> BacktestResultV2: contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) + constraints = build_quantity_constraints( + symbol_list, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + target_m = quantize_target_units_matrix(target_m, closes_m, contract_sizes, constraints) leverages = self._per_symbol_array( self.config.account.leverage if leverage is None else leverage, symbol_list, @@ -222,6 +251,7 @@ def _run_target_arrays( "slippage_bps": self.config.execution.slippage_bps, "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)), "liquidation_reason": int(liq_reason), + "quantity_constraints": constraints.as_dict(), }, ) @@ -239,6 +269,12 @@ def run_signals( hedge_type: str = "signal_notional", use_pyramiding: bool = True, symbols: Optional[List[str]] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, ) -> BacktestResultV2: """ Scale raw position signals into target units, then run the V2 kernel. @@ -288,6 +324,12 @@ def run_signals( is_funding=is_funding, contract_size=contract_size, leverage=leverage, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, ) target_units = { @@ -311,6 +353,12 @@ def run_signals( contract_size=contract_size, leverage=leverage, symbols=symbol_list, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, ) def run_basis_arbitrage( diff --git a/backtester.py b/backtester.py index 4fbdd67..0953dd4 100644 --- a/backtester.py +++ b/backtester.py @@ -62,6 +62,8 @@ prepare_funding, build_arrays, ) +from .core.constraints import build_quantity_constraints, quantize_target_units_matrix +from .core.schema import InstrumentSpec from .sizing.modes import compute_target_units from .metrics.performance import full_report from .viz.plots import quick_plot, tearsheet as _tearsheet @@ -107,6 +109,12 @@ def __init__( dca_max_safety_orders: int = 5, dca_take_profit_pct: Union[float, Dict[str, float]] = 0.0, dca_allow_same_bar_exit: bool = False, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, # kept for backward compat, not used internally run_portfolio: bool = True, use_binance_netting: bool = True, @@ -169,6 +177,16 @@ def __init__( if np.any(self._contract_sizes <= 0.0): raise ValueError("contract_size must be > 0") + self._quantity_constraints = build_quantity_constraints( + self.symbols, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + # ── funding rates ───────────────────────────────────────────────── fr_input = funding_rate if use_funding_rate else 0.0 self._funding = prepare_funding(fr_input, self.symbols, self._idx) @@ -248,6 +266,7 @@ def run(self) -> BacktestResult: ) cs = self._contract_sizes + qc = self._quantity_constraints if self._is_dca_ladder: equity_arr, pos_arr, level_arr, liq_flag, liq_idx = _engine_dca_ladder( @@ -273,6 +292,9 @@ def run(self) -> BacktestResult: max_safety_orders = self.dca_max_safety_orders, take_profit_pct = self._dca_take_profit_pct, allow_same_bar_exit = self.dca_allow_same_bar_exit, + qty_steps = qc.qty_step, + min_qtys = qc.min_qty, + min_notionals = qc.min_notional, ) result_positions = pos_arr elif self._hedge_type_norm in ("%_equity", "pct_equity"): @@ -298,9 +320,13 @@ def run(self) -> BacktestResult: contract_sizes = cs, slippage = self.slippage, alloc_pct = alloc_pct, + qty_steps = qc.qty_step, + min_qtys = qc.min_qty, + min_notionals = qc.min_notional, ) result_positions = signals else: + signals = quantize_target_units_matrix(signals, closes, cs, qc) equity_arr, liq_flag, liq_idx = _engine_units( n_bars = self.n_bars, n_syms = self.n_syms, @@ -348,6 +374,7 @@ def run(self) -> BacktestResult: "fee_oneway": self.fee_oneway, "slippage": self.slippage, "maintenance_ratio": self.maintenance_ratio, + "quantity_constraints": self._quantity_constraints.as_dict(), "dca_actual_level": ( pd.DataFrame( {f"Level_{s}": level_arr[:, i] for i, s in enumerate(self.symbols)}, diff --git a/core/constraints.py b/core/constraints.py new file mode 100644 index 0000000..fc101fe --- /dev/null +++ b/core/constraints.py @@ -0,0 +1,155 @@ +"""Exchange/instrument quantity constraints shared by all backends.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Optional, Sequence, Union + +import numpy as np + +from .schema import InstrumentSpec + + +NumberOrMap = Union[float, Dict[str, float], None] + + +@dataclass(frozen=True) +class QuantityConstraints: + """Per-symbol exchange quantity rules. + + `contract_size` remains the PnL/notional multiplier. Fractional crypto + acceptance is controlled by `qty_step`/`lot_size`, `min_qty`, and + `min_notional`. QuantBT rounds target quantities down by default, matching + the conservative side of common exchange filters. + """ + + symbols: tuple[str, ...] + qty_step: np.ndarray + min_qty: np.ndarray + min_notional: np.ndarray + + @property + def enabled(self) -> bool: + return bool( + np.any(self.qty_step > 0.0) + or np.any(self.min_qty > 0.0) + or np.any(self.min_notional > 0.0) + ) + + def as_dict(self) -> Dict[str, Dict[str, float]]: + return { + symbol: { + "qty_step": float(self.qty_step[i]), + "lot_size": float(self.qty_step[i]), + "min_qty": float(self.min_qty[i]), + "min_notional": float(self.min_notional[i]), + } + for i, symbol in enumerate(self.symbols) + } + + +def build_quantity_constraints( + symbols: Sequence[str], + *, + instruments: Optional[Union[Dict[str, InstrumentSpec], Sequence[InstrumentSpec]]] = None, + qty_step: NumberOrMap = None, + lot_size: NumberOrMap = None, + slot_size: NumberOrMap = None, + min_qty: NumberOrMap = None, + min_notional: NumberOrMap = None, +) -> QuantityConstraints: + """Resolve quantity constraints from explicit kwargs and InstrumentSpec. + + Explicit kwargs override `InstrumentSpec`. `slot_size` is accepted as a + backward-compatible alias for `lot_size`. + """ + + symbol_list = tuple(symbols) + inst_map = _instrument_map(instruments) + step_source = qty_step if qty_step is not None else (lot_size if lot_size is not None else slot_size) + + steps = [] + min_qtys = [] + min_notionals = [] + for symbol in symbol_list: + inst = inst_map.get(symbol) + default_step = 0.0 if inst is None else float(inst.lot_size) + default_min_qty = 0.0 if inst is None else float(inst.min_qty) + default_min_notional = 0.0 if inst is None else float(inst.min_notional) + steps.append(_value_for(step_source, symbol, default_step)) + min_qtys.append(_value_for(min_qty, symbol, default_min_qty)) + min_notionals.append(_value_for(min_notional, symbol, default_min_notional)) + + out = QuantityConstraints( + symbols=symbol_list, + qty_step=np.asarray(steps, dtype=np.float64), + min_qty=np.asarray(min_qtys, dtype=np.float64), + min_notional=np.asarray(min_notionals, dtype=np.float64), + ) + if np.any(out.qty_step < 0.0) or np.any(out.min_qty < 0.0) or np.any(out.min_notional < 0.0): + raise ValueError("qty_step/lot_size, min_qty, and min_notional must be >= 0") + return out + + +def quantize_target_units_matrix( + target_units: np.ndarray, + prices: np.ndarray, + contract_sizes: np.ndarray, + constraints: QuantityConstraints, +) -> np.ndarray: + """Round target-unit matrix down to exchange-acceptable quantities.""" + + if not constraints.enabled: + return np.ascontiguousarray(target_units, dtype=np.float64) + out = np.asarray(target_units, dtype=np.float64).copy(order="C") + prices_arr = np.asarray(prices, dtype=np.float64) + cs = np.asarray(contract_sizes, dtype=np.float64) + for j in range(out.shape[1]): + step = float(constraints.qty_step[j]) + mnq = float(constraints.min_qty[j]) + mnn = float(constraints.min_notional[j]) + for i in range(out.shape[0]): + out[i, j] = quantize_signed_quantity(out[i, j], prices_arr[i, j], cs[j], step, mnq, mnn) + return np.ascontiguousarray(out, dtype=np.float64) + + +def quantize_signed_quantity( + qty: float, + price: float, + contract_size: float = 1.0, + qty_step: float = 0.0, + min_qty: float = 0.0, + min_notional: float = 0.0, +) -> float: + """Round a signed quantity down and zero it if below exchange minima.""" + + q = float(qty) + if q == 0.0: + return 0.0 + sign = 1.0 if q > 0.0 else -1.0 + abs_q = abs(q) + if qty_step > 0.0: + abs_q = np.floor((abs_q / float(qty_step)) + 1e-12) * float(qty_step) + if abs_q <= 0.0: + return 0.0 + if min_qty > 0.0 and abs_q + 1e-12 < min_qty: + return 0.0 + if min_notional > 0.0 and abs_q * float(price) * float(contract_size) + 1e-12 < min_notional: + return 0.0 + return sign * abs_q + + +def _instrument_map(instruments) -> Dict[str, InstrumentSpec]: + if instruments is None: + return {} + if isinstance(instruments, dict): + return {symbol: spec for symbol, spec in instruments.items() if spec is not None} + return {spec.symbol: spec for spec in instruments} + + +def _value_for(value: NumberOrMap, symbol: str, default: float) -> float: + if value is None: + return float(default) + if isinstance(value, dict): + return float(value.get(symbol, default)) + return float(value) diff --git a/core/engine.py b/core/engine.py index 04abfb3..024f0a5 100644 --- a/core/engine.py +++ b/core/engine.py @@ -24,6 +24,25 @@ from numba import njit +@njit(cache=True) +def _quantize_signed_qty(qty: float, price: float, contract_size: float, qty_step: float, min_qty: float, min_notional: float) -> float: + if qty == 0.0: + return 0.0 + sign = 1.0 + if qty < 0.0: + sign = -1.0 + q = abs(qty) + if qty_step > 0.0: + q = np.floor((q / qty_step) + 1e-12) * qty_step + if q <= 0.0: + return 0.0 + if min_qty > 0.0 and q + 1e-12 < min_qty: + return 0.0 + if min_notional > 0.0 and q * price * contract_size + 1e-12 < min_notional: + return 0.0 + return sign * q + + @njit(cache=True) def _engine_units( n_bars: int, @@ -159,6 +178,9 @@ def _engine_pct_equity( contract_sizes: np.ndarray, slippage: float, alloc_pct: np.ndarray, # (n_syms,) fraction of equity, in (0, 1] + qty_steps: np.ndarray, + min_qtys: np.ndarray, + min_notionals: np.ndarray, ): """ Target units = equity × alloc_pct[s] × weight[i,s] / (close[i,s] × cs[s]) @@ -243,6 +265,9 @@ def _engine_pct_equity( continue target = (equity * alloc_pct[s] * signals[i, s]) / denom + target = _quantize_signed_qty( + target, closes[i, s], contract_sizes[s], qty_steps[s], min_qtys[s], min_notionals[s] + ) if abs(target - current_pos[s]) < 1e-12: continue @@ -318,6 +343,9 @@ def _engine_dca_ladder( max_safety_orders: int, take_profit_pct: np.ndarray, allow_same_bar_exit: bool, + qty_steps: np.ndarray, + min_qtys: np.ndarray, + min_notionals: np.ndarray, ): """ DCA ladder execution model. @@ -473,6 +501,9 @@ def _engine_dca_ladder( if current_lvl[s] == 0: delta = desired_side * base_notional[s] / c exec_p = c * (1.0 + market_slippage if delta > 0.0 else 1.0 - market_slippage) + delta = _quantize_signed_qty(delta, exec_p, cs, qty_steps[s], min_qtys[s], min_notionals[s]) + if delta == 0.0: + continue cur_im = 0.0 for k in range(n_syms): @@ -524,6 +555,9 @@ def _engine_dca_ladder( notional *= mult delta = current_side[s] * notional / trigger + delta = _quantize_signed_qty(delta, trigger, cs, qty_steps[s], min_qtys[s], min_notionals[s]) + if delta == 0.0: + break cur_im = 0.0 for k in range(n_syms): @@ -784,6 +818,9 @@ def _engine_portfolio_equity_sizing( exposure_scalar: float, beta: np.ndarray, inv_vol: np.ndarray, + qty_steps: np.ndarray, + min_qtys: np.ndarray, + min_notionals: np.ndarray, ): """ Portfolio kernel for sizing modes which depend on live equity. @@ -915,6 +952,9 @@ def _engine_portfolio_equity_sizing( target_units[s] = target_notional[s] / denom else: target_units[s] = 0.0 + target_units[s] = _quantize_signed_qty( + target_units[s], closes[i, s], contract_sizes[s], qty_steps[s], min_qtys[s], min_notionals[s] + ) target_out[i, s] = target_units[s] cur_im = 0.0 diff --git a/docs/endpoint.md b/docs/endpoint.md index 73eed0b..89891be 100644 --- a/docs/endpoint.md +++ b/docs/endpoint.md @@ -123,7 +123,10 @@ bt = QuantBTEndpoint.signal_notional( slippage=0.0001, # legacy fraction use_funding=False, funding_rate=0.0001, - contract_size=1.0, + contract_size=1.0, # PnL/notional multiplier, not lot size + qty_step=0.001, # exchange quantity increment + min_qty=0.001, + min_notional=10.0, use_pyramiding=True, ) ``` @@ -137,6 +140,12 @@ Important conventions: - V2 `fee_rate` is one-way; - legacy `slippage` is a decimal fraction, e.g. `0.0001` for 1 bp; - V2 `slippage_bps` is basis points, e.g. `1.0` for 1 bp. +- exchange quantity constraints are shared across native legacy, native + vectorized, native event/order, native portfolio, and Nautilus validation + routes. Use `qty_step` or `lot_size` for the venue step, `slot_size` as a + compatibility alias, and `min_qty`/`min_notional` for exchange minima. + QuantBT rounds target/order quantity down conservatively. `contract_size` + remains the contract multiplier. ## Data Contract @@ -1073,10 +1082,11 @@ Requirements: reporting and the signal adapter uses the Nautilus instrument fee model. Custom endpoint slippage and funding are also not applied by the current Nautilus signal-series adapter. -- for crypto fractional trading, use venue quantity constraints - `qty_step`/`lot_size`/`min_qty`/`min_notional`. `contract_size` is a PnL and - notional multiplier, not the Binance lot size. Do not set - `contract_size=0.001` just to allow fractional ETH/BTC orders. +- for crypto fractional trading, use the same shared venue quantity constraints + as native backends: `qty_step`/`lot_size`/`slot_size`/`min_qty`/ + `min_notional`. `contract_size` is a PnL and notional multiplier, not the + Binance lot size. Do not set `contract_size=0.001` just to allow fractional + ETH/BTC orders. - DCA/grid, explicit order replay, pair trading, and multi-symbol portfolio validation remain on native QuantBT backends until their Nautilus event adapters are added; diff --git a/docs/margin_leverage.md b/docs/margin_leverage.md index 9cef535..af0cf0d 100644 --- a/docs/margin_leverage.md +++ b/docs/margin_leverage.md @@ -55,3 +55,25 @@ Intrabar liquidation uses high/low worst-case prices: - if `High` and `Low` are not provided, close is used as fallback. For robust crypto intraday backtests, always pass `High` and `Low`. + +## Quantity Constraints + +Crypto fractional trading is controlled by exchange quantity filters, not by +`contract_size`. + +Use: + +```python +QuantBTEndpoint.signal_notional( + contract_size=1.0, # PnL/notional multiplier for linear USDT contracts + qty_step=0.001, # venue lot increment + min_qty=0.001, + min_notional=10.0, +) +``` + +`qty_step`, `lot_size`, and the compatibility alias `slot_size` describe the +same venue quantity increment. QuantBT rounds target/order quantity down before +execution and zeroes orders below `min_qty` or `min_notional`. The same shared +constraint layer is used by legacy single-symbol, native vectorized, native +event/orders, native portfolio, and Nautilus validation routes. diff --git a/endpoint.py b/endpoint.py index 74befbf..06d89a5 100644 --- a/endpoint.py +++ b/endpoint.py @@ -36,7 +36,7 @@ ) from .core.orders import OrderIntent from .core.results import BacktestResultV2 -from .core.schema import AccountConfig, BasketLegSpec, BasketSpec, ExecutionConfig, OrderSide, OrderType, TimeInForce +from .core.schema import AccountConfig, BasketLegSpec, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce from .core.structured_orders import ( BracketOrderSpec, DcaGridSpec, @@ -139,6 +139,12 @@ class EndpointConfig: use_funding: bool = True funding_rate: Union[float, pd.Series, Dict] = 0.0 contract_size: Union[float, Dict[str, float]] = 1.0 + instruments: Optional[Union[Dict[str, InstrumentSpec], Sequence[InstrumentSpec]]] = None + qty_step: Optional[Union[float, Dict[str, float]]] = None + lot_size: Optional[Union[float, Dict[str, float]]] = None + slot_size: Optional[Union[float, Dict[str, float]]] = None + min_qty: Optional[Union[float, Dict[str, float]]] = None + min_notional: Optional[Union[float, Dict[str, float]]] = None slippage: float = 0.0001 portfolio_mode: str = "longshort" betas: Union[float, Dict[str, float], None] = None @@ -918,6 +924,12 @@ def _run_single(self, data, signal, signal_col, datetime_index, symbols): hedge_type=self.config.sizing, slippage=self.config.slippage, symbols=None, + instruments=self.config.instruments, + qty_step=self.config.qty_step, + lot_size=self.config.lot_size, + slot_size=self.config.slot_size, + min_qty=self.config.min_qty, + min_notional=self.config.min_notional, **self.config.dca_kwargs, ) self._store_result(self.engine.result) @@ -938,6 +950,12 @@ def _run_single(self, data, signal, signal_col, datetime_index, symbols): use_pyramiding=self.config.use_pyramiding, contract_size=self.config.contract_size, nautilus_config=self.config.nautilus_config, + instruments=self.config.instruments, + qty_step=self.config.qty_step, + lot_size=self.config.lot_size, + slot_size=self.config.slot_size, + min_qty=self.config.min_qty, + min_notional=self.config.min_notional, ) self._store_result(self.engine.result) return self.result @@ -958,6 +976,12 @@ def _run_orders(self, data, orders, datetime_index, symbols): use_funding=self.config.use_funding, funding_rate=self.config.funding_rate, contract_size=self.config.contract_size, + instruments=self.config.instruments, + qty_step=self.config.qty_step, + lot_size=self.config.lot_size, + slot_size=self.config.slot_size, + min_qty=self.config.min_qty, + min_notional=self.config.min_notional, ) self._store_result(self.engine.result) return self.result @@ -1474,6 +1498,12 @@ def _run_portfolio(self, data, positions, closes, highs, lows, datetime_index, s use_pyramiding=self.config.use_pyramiding, betas=self.config.betas, risk_lookback=self.config.risk_lookback, + instruments=self.config.instruments, + qty_step=self.config.qty_step, + lot_size=self.config.lot_size, + slot_size=self.config.slot_size, + min_qty=self.config.min_qty, + min_notional=self.config.min_notional, ) self._store_result(self.engine.result) return self.result diff --git a/engines.py b/engines.py index be26323..f97530b 100644 --- a/engines.py +++ b/engines.py @@ -24,7 +24,7 @@ from .core.orders import OrderIntent from .core.preprocessor import validate_datetime from .core.results import BacktestResultV2 -from .core.schema import AccountConfig, BasketSpec, ExecutionConfig, OrderSide, OrderType, TimeInForce +from .core.schema import AccountConfig, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce from .portfolio import MultiSymbolPortfolio from .sizing.modes import compute_target_units @@ -70,6 +70,12 @@ def __init__( signal: Optional[pd.Series] = None, hedge_ratios: Optional[SeriesMap] = None, nautilus_config=None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, auto_run: bool = True, ): self.backend = backend.lower().strip() @@ -100,6 +106,12 @@ def __init__( self.signal = signal self.hedge_ratios = hedge_ratios self.nautilus_config = nautilus_config + self.instruments = instruments + self.qty_step = qty_step + self.lot_size = lot_size + self.slot_size = slot_size + self.min_qty = min_qty + self.min_notional = min_notional self.result: Optional[BacktestResultV2] = None if auto_run: @@ -137,6 +149,12 @@ def _run_native_vectorized(self) -> BacktestResultV2: contract_size=self.contract_size, leverage=self.leverage, symbols=symbols, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, ) raw_positions = self.positions if self.positions is not None else self.signals @@ -156,6 +174,12 @@ def _run_native_vectorized(self) -> BacktestResultV2: hedge_type=self.hedge_type, use_pyramiding=self.use_pyramiding, symbols=symbols, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, ) def _run_native_event(self) -> BacktestResultV2: @@ -185,6 +209,12 @@ def _run_native_event(self) -> BacktestResultV2: contract_size=self.contract_size, leverage=self.leverage, symbols=symbols, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, ) orders = self.orders @@ -213,6 +243,12 @@ def _run_native_event(self) -> BacktestResultV2: contract_size=self.contract_size, leverage=self.leverage, symbols=symbols, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, ) def _run_nautilus(self) -> BacktestResultV2: @@ -347,6 +383,12 @@ def __init__( maintenance_ratio: Optional[float] = None, highs: Optional[Dict[str, pd.Series]] = None, lows: Optional[Dict[str, pd.Series]] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, auto_run: bool = True, **kwargs, ): @@ -370,6 +412,12 @@ def __init__( ) self.highs = highs self.lows = lows + self.instruments = instruments + self.qty_step = qty_step + self.lot_size = lot_size + self.slot_size = slot_size + self.min_qty = min_qty + self.min_notional = min_notional self.kwargs = kwargs self.portfolio: Optional[MultiSymbolPortfolio] = None self.result: Optional[BacktestResultV2] = None @@ -423,6 +471,12 @@ def run(self) -> BacktestResultV2: hedge_type=self.hedge_type, contract_size=self.contract_size or 1.0, leverage=self.leverage, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, ) self.result = engine.result return self.result @@ -459,6 +513,12 @@ def run(self) -> BacktestResultV2: use_pyramiding=bool(self.kwargs.get("use_pyramiding", True)), betas=self.kwargs.get("betas"), risk_lookback=int(self.kwargs.get("risk_lookback", 60)), + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, ) return self.result diff --git a/metrics/performance.py b/metrics/performance.py index 813c24b..410f0b1 100644 --- a/metrics/performance.py +++ b/metrics/performance.py @@ -28,6 +28,59 @@ def _equity_daily(result: BacktestResult) -> pd.Series: return result.daily_equity +def _finite_returns(series: pd.Series) -> pd.Series: + r = pd.to_numeric(series, errors="coerce").replace([np.inf, -np.inf], np.nan).dropna() + return r.astype(float) + + +def _returns_for_stats(result: BacktestResult) -> pd.Series: + """ + Return sample used by distribution metrics. + + Daily returns are preferred for stable multi-day reports. Very short + intraday/scoped runs can collapse to one daily equity point, producing an + empty daily return sample; in that case we fall back to bar returns so + Sharpe, Omega, PF, and avg win/loss do not become artificial 0/inf values. + """ + daily = _finite_returns(_daily(result)) + if len(daily) > 0: + return daily + bar = _finite_returns(result.returns) + if len(bar) > 0: + return bar + return _finite_returns(result.equity.pct_change().fillna(0.0)) + + +def _annualization_periods(result: BacktestResult, trading_days: int) -> float: + daily = _finite_returns(_daily(result)) + if len(daily) > 0: + return float(trading_days) + idx = result.equity.index + if len(idx) >= 2 and isinstance(idx, pd.DatetimeIndex): + deltas = idx.to_series().diff().dropna().dt.total_seconds() + deltas = deltas[deltas > 0.0] + if len(deltas) > 0: + median_seconds = float(deltas.median()) + if median_seconds > 0.0: + return float(365.25 * 24 * 60 * 60 / median_seconds) + return float(trading_days) + + +def _elapsed_years(result: BacktestResult, trading_days: int) -> float: + eq = result.equity.dropna() + if len(eq) < 2: + return 0.0 + idx = eq.index + if isinstance(idx, pd.DatetimeIndex): + elapsed_days = (idx[-1] - idx[0]).total_seconds() / 86_400.0 + if elapsed_days > 0.0: + return elapsed_days / 365.25 + daily = _equity_daily(result) + if len(daily) >= 2: + return len(daily) / float(trading_days) + return len(eq) / float(trading_days) + + # ── return metrics ─────────────────────────────────────────────────────────── def total_return(result: BacktestResult) -> float: @@ -38,24 +91,40 @@ def total_return(result: BacktestResult) -> float: def cagr(result: BacktestResult, trading_days: int = 365) -> float: """Compound annual growth rate.""" - eq = _equity_daily(result) - years = len(eq) / trading_days + eq = result.equity.dropna() + if len(eq) >= 2 and isinstance(eq.index, pd.DatetimeIndex): + elapsed_days = (eq.index[-1] - eq.index[0]).total_seconds() / 86_400.0 + if 0.0 < elapsed_days < 1.0: + return total_return(result) + years = _elapsed_years(result, trading_days) if years <= 0: return 0.0 - return (eq.iloc[-1] / eq.iloc[0]) ** (1.0 / years) - 1.0 + growth = eq.iloc[-1] / eq.iloc[0] + if growth <= 0.0: + return -1.0 + annual_log = np.log(growth) / years + if annual_log > 50.0: + return float(np.expm1(50.0)) + if annual_log < -50.0: + return float(np.expm1(-50.0)) + return float(np.expm1(annual_log)) def sharpe(result: BacktestResult, trading_days: int = 365, risk_free: float = 0.0) -> float: - r = _daily(result) - risk_free / trading_days + periods = _annualization_periods(result, trading_days) + r = _returns_for_stats(result) - risk_free / periods sd = r.std(ddof=1) - return (r.mean() / sd) * np.sqrt(trading_days) if sd > 0 else 0.0 + return (r.mean() / sd) * np.sqrt(periods) if sd > 0 else 0.0 def sortino(result: BacktestResult, trading_days: int = 365, mar: float = 0.0) -> float: - r = _daily(result) + periods = _annualization_periods(result, trading_days) + r = _returns_for_stats(result) d = r[r < mar] - mar dd = np.sqrt((d ** 2).mean()) if len(d) > 0 else 0.0 - return (r.mean() / dd) * np.sqrt(trading_days) if dd > 0 else 0.0 + if dd == 0.0 and r.mean() > mar: + return np.inf + return (r.mean() / dd) * np.sqrt(periods) if dd > 0 else 0.0 def calmar(result: BacktestResult, trading_days: int = 365) -> float: @@ -66,7 +135,7 @@ def calmar(result: BacktestResult, trading_days: int = 365) -> float: def omega(result: BacktestResult, threshold: float = 0.0) -> float: """Omega ratio (Keating & Shadwick).""" - r = _daily(result) + r = _returns_for_stats(result) gain = (r[r > threshold] - threshold).sum() loss = (threshold - r[r < threshold]).sum() return gain / loss if loss > 0 else np.inf @@ -153,7 +222,7 @@ def number_of_trades(result: BacktestResult) -> int: def profit_factor(result: BacktestResult) -> float: - r = _daily(result) + r = _returns_for_stats(result) gains = r[r > 0].sum() loss = abs(r[r < 0].sum()) return gains / loss if loss > 0 else np.inf @@ -161,7 +230,7 @@ def profit_factor(result: BacktestResult) -> float: def avg_win_loss(result: BacktestResult) -> Tuple[float, float]: """(avg_win_pct, avg_loss_pct) in percent.""" - r = _daily(result) + r = _returns_for_stats(result) w = r[r > 0].mean() * 100 if (r > 0).any() else 0.0 l = r[r < 0].mean() * 100 if (r < 0).any() else 0.0 return float(w), float(l) @@ -212,22 +281,22 @@ def full_report(result: BacktestResult, trading_days: int = 365) -> Dict: return { "initial_capital": result.initial_capital, "final_equity": float(result.equity.iloc[-1]), - "total_return_pct": total_return(result) * 100, - "cagr_pct": cagr(result, trading_days) * 100, - "sharpe": sharpe(result, trading_days), - "sortino": sortino(result, trading_days), - "calmar": calmar(result, trading_days), - "omega": omega(result), - "max_drawdown_pct": max_drawdown_pct(result), - "avg_drawdown_pct": avg_drawdown(result) * 100, + "total_return_pct": float(total_return(result) * 100), + "cagr_pct": float(cagr(result, trading_days) * 100), + "sharpe": float(sharpe(result, trading_days)), + "sortino": float(sortino(result, trading_days)), + "calmar": float(calmar(result, trading_days)), + "omega": float(omega(result)), + "max_drawdown_pct": float(max_drawdown_pct(result)), + "avg_drawdown_pct": float(avg_drawdown(result) * 100), "max_dd_duration_days": md, "avg_dd_duration_days": ad, - "profit_factor": profit_factor(result), - "long_hitrate_pct": lh, - "short_hitrate_pct": sh, - "avg_win_pct": aw, - "avg_loss_pct": al, - "expectancy_pct": expectancy(result), - "num_trades": number_of_trades(result), + "profit_factor": float(profit_factor(result)), + "long_hitrate_pct": float(lh), + "short_hitrate_pct": float(sh), + "avg_win_pct": float(aw), + "avg_loss_pct": float(al), + "expectancy_pct": float(expectancy(result)), + "num_trades": int(number_of_trades(result)), "liquidated": result.liquidated, } diff --git a/tests/test_metrics_intraday_scope.py b/tests/test_metrics_intraday_scope.py new file mode 100644 index 0000000..d7a484d --- /dev/null +++ b/tests/test_metrics_intraday_scope.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd + +from quantbt.core.results import BacktestResultV2 +from quantbt.metrics.performance import full_report + + +def test_full_report_falls_back_to_bar_returns_when_daily_returns_are_empty(): + idx = pd.date_range("2025-01-01 00:00", periods=1_000, freq="1min", tz="UTC") + equity = pd.Series(np.linspace(20_000.0, 190_000.0, len(idx)), index=idx, name="equity") + result = BacktestResultV2( + equity=equity, + returns=equity.pct_change().fillna(0.0), + positions=pd.DataFrame({"Position_ETHUSDT": np.r_[0.0, np.ones(len(idx) - 1)]}, index=idx), + closes=pd.DataFrame({"Close_ETHUSDT": 100.0}, index=idx), + symbols=["ETHUSDT"], + initial_capital=20_000.0, + ) + + report = full_report(result) + + assert len(result.daily_returns) == 0 + assert report["total_return_pct"] == 850.0 + assert report["cagr_pct"] == 850.0 + assert report["sharpe"] > 0.0 + assert report["avg_win_pct"] > 0.0 + assert report["profit_factor"] == np.inf diff --git a/tests/test_shared_quantity_constraints.py b/tests/test_shared_quantity_constraints.py new file mode 100644 index 0000000..9f1dc0e --- /dev/null +++ b/tests/test_shared_quantity_constraints.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from quantbt import ( + BacktestEngine, + BacktestEngineV2, + InstrumentSpec, + OrderIntent, + OrderSide, + OrderType, + QuantBTEndpoint, + TimeInForce, + build_quantity_constraints, +) + + +def _frame(): + idx = pd.date_range("2024-01-01", periods=6, freq="1h", tz="UTC") + close = pd.Series([100, 101, 102, 103, 104, 105], index=idx, dtype=float) + frame = pd.DataFrame( + { + "open": close, + "high": close * 1.01, + "low": close * 0.99, + "close": close, + "volume": 1.0, + }, + index=idx, + ) + signal = pd.Series([0, 1, 1, 0, -1, 0], index=idx, dtype=float) + return idx, close, frame, signal + + +def test_quantity_constraints_accept_lot_size_slot_size_and_instrument_spec(): + constraints = build_quantity_constraints( + ["ETHUSDT", "DOGEUSDT"], + instruments={"ETHUSDT": InstrumentSpec("ETHUSDT", lot_size=0.001, min_qty=0.001, min_notional=10)}, + slot_size={"DOGEUSDT": 1.0}, + min_notional={"DOGEUSDT": 5.0}, + ) + + assert constraints.as_dict()["ETHUSDT"] == { + "qty_step": 0.001, + "lot_size": 0.001, + "min_qty": 0.001, + "min_notional": 10.0, + } + assert constraints.as_dict()["DOGEUSDT"]["qty_step"] == 1.0 + assert constraints.as_dict()["DOGEUSDT"]["min_notional"] == 5.0 + + +def test_legacy_signal_notional_quantizes_target_units_without_contract_size_abuse(): + idx, close, _, signal = _frame() + + bt = BacktestEngine( + Datetime=idx, + Position=signal, + Close=close, + fee=0.0, + use_funding_rate=False, + initial_capital=1_000, + leverage=10, + alloc_per_trade=333.0, + hedge_type="signal_notional", + slippage=0.0, + qty_step=0.1, + min_qty=0.1, + contract_size=1.0, + ) + + pos = bt.result.positions["Position_DEFAULT"] + assert pos.iloc[1] == 3.2 + assert pos.iloc[4] == -3.2 + assert bt.result.metadata["quantity_constraints"]["DEFAULT"]["qty_step"] == 0.1 + + +def test_native_vectorized_endpoint_quantizes_target_units(): + _, _, frame, signal = _frame() + + endpoint = QuantBTEndpoint.signal_notional( + backend="native_vectorized", + initial_capital=1_000, + leverage=10, + alloc_per_trade=333.0, + fee=0.0, + slippage_bps=0.0, + use_funding=False, + qty_step=0.1, + min_qty=0.1, + ) + result = endpoint.backtest(data=frame, signal=signal, symbols=["ETHUSDT"]) + + pos = result.positions["Position_ETHUSDT"] + assert pos.iloc[1] == 3.2 + assert pos.iloc[4] == -3.2 + assert result.metadata["quantity_constraints"]["ETHUSDT"]["lot_size"] == 0.1 + + +def test_native_event_quantizes_and_drops_orders_by_exchange_constraints(): + idx, _, frame, _ = _frame() + orders = [ + OrderIntent(idx[1], "ETHUSDT", OrderSide.BUY, OrderType.MARKET, qty=0.156, tif=TimeInForce.IOC), + OrderIntent(idx[2], "ETHUSDT", OrderSide.BUY, OrderType.MARKET, qty=0.04, tif=TimeInForce.IOC), + ] + + engine = BacktestEngineV2( + data=frame, + backend="native_event", + orders=orders, + symbols=["ETHUSDT"], + fee_rate=0.0, + use_funding=False, + qty_step=0.1, + min_qty=0.1, + min_notional=10.0, + ) + + result = engine.result + assert [fill.qty for fill in result.fills] == [0.1] + assert result.metadata["quantity_preflight"]["changed_count"] == 1 + assert result.metadata["quantity_preflight"]["dropped_count"] == 1 + + +def test_native_portfolio_quantizes_per_symbol_target_units(): + idx, close, _, signal = _frame() + positions = {"ETHUSDT": signal, "BTCUSDT": -signal} + closes = {"ETHUSDT": close, "BTCUSDT": close * 2.0} + + endpoint = QuantBTEndpoint.portfolio( + initial_capital=1_000, + leverage=10, + alloc_per_trade={"ETHUSDT": 333.0, "BTCUSDT": 333.0}, + fee=0.0, + use_funding=False, + qty_step={"ETHUSDT": 0.1, "BTCUSDT": 0.01}, + min_qty=0.01, + ) + result = endpoint.backtest( + positions=positions, + closes=closes, + datetime_index=idx, + symbols=["ETHUSDT", "BTCUSDT"], + ) + + target = result.metadata["target_units_report"] + assert target.loc[idx[1], "ETHUSDT"] == 3.2 + assert target.loc[idx[1], "BTCUSDT"] == pytest.approx(-1.64) + assert result.metadata["quantity_constraints"]["BTCUSDT"]["qty_step"] == 0.01 diff --git a/viz/plots.py b/viz/plots.py index 826070e..88c5b89 100644 --- a/viz/plots.py +++ b/viz/plots.py @@ -65,8 +65,13 @@ def quick_plot( c = apply_theme(theme) eq = result.daily_equity + if len(eq) < 2: + eq = result.equity.dropna() ret = (eq / eq.iloc[0] - 1) * 100 dd = rolling_drawdown(result) * 100 # already daily + if len(dd) < 2: + peak = eq.cummax() + dd = (peak - eq) / peak.replace(0, np.nan) * 100 rpt = full_report(result)