diff --git a/backend_api_python/app/data_sources/base.py b/backend_api_python/app/data_sources/base.py index f5a6cda5e..609e9875e 100644 --- a/backend_api_python/app/data_sources/base.py +++ b/backend_api_python/app/data_sources/base.py @@ -71,7 +71,9 @@ def format_kline( high: float, low: float, close: float, - volume: float + volume: float, + lot_size: float = 0.0, + min_notional: float = 0.0 ) -> Dict[str, Any]: """Normalize one K-line row.""" return { @@ -80,7 +82,9 @@ def format_kline( 'high': round(float(high), 4), 'low': round(float(low), 4), 'close': round(float(close), 4), - 'volume': round(float(volume), 2) + 'volume': round(float(volume), 2), + 'lot_size': lot_size, + 'min_notional': min_notional } def calculate_time_range( diff --git a/backend_api_python/app/data_sources/crypto.py b/backend_api_python/app/data_sources/crypto.py index 62f06b48d..3a51667e8 100644 --- a/backend_api_python/app/data_sources/crypto.py +++ b/backend_api_python/app/data_sources/crypto.py @@ -379,6 +379,48 @@ def _find_valid_symbol(self, base: str, preferred_quote: str = 'USDT') -> Option return None + def _get_lot_size_and_min_notional(self, symbol: str) -> tuple[float, float]: + """ + Get lot_size (stepSize) and min_notional for a symbol from exchange markets. + + Args: + symbol: Normalized symbol (e.g., 'BTC/USDT') + + Returns: + Tuple of (lot_size, min_notional) in base currency units. + Returns (0.0, 0.0) if not available. + """ + if not self._ensure_markets_loaded(): + return 0.0, 0.0 + + markets = self._markets_cache or {} + if not markets or symbol not in markets: + return 0.0, 0.0 + + market = markets[symbol] + lot_size = 0.0 + min_notional = 0.0 + + # Get lot_size from precision.amount or limits.amount.min + precision = market.get('precision', {}) + if isinstance(precision, dict) and precision.get('amount') is not None: + lot_size = float(precision['amount']) + + # Fallback to limits.amount.min + if lot_size <= 0: + limits = market.get('limits', {}) + amount_limits = limits.get('amount', {}) if isinstance(limits, dict) else {} + if isinstance(amount_limits, dict) and amount_limits.get('min') is not None: + lot_size = float(amount_limits['min']) + + # Get min_notional from limits.cost.min + limits = market.get('limits', {}) + cost_limits = limits.get('cost', {}) if isinstance(limits, dict) else {} + if isinstance(cost_limits, dict) and cost_limits.get('min') is not None: + min_notional = float(cost_limits['min']) + + return lot_size, min_notional + def _normalize_symbol_for_exchange(self, symbol: str) -> str: """ 根据交易所特性规范化符号 @@ -595,13 +637,21 @@ def get_kline( for candle in ohlcv: if len(candle) < 6: continue + # Get lot_size and min_notional from exchange market data + lot_size = 0.0 + min_notional = 0.0 + if self._ensure_markets_loaded() and symbol_pair in (self._markets_cache or {}): + lot_size, min_notional = self._get_lot_size_and_min_notional(symbol_pair) + klines.append(self.format_kline( timestamp=int(candle[0] / 1000), # 毫秒转秒 open_price=candle[1], high=candle[2], low=candle[3], close=candle[4], - volume=candle[5] + volume=candle[5], + lot_size=lot_size, + min_notional=min_notional )) klines = self.filter_and_limit( diff --git a/backend_api_python/app/services/strategy_v2/data.py b/backend_api_python/app/services/strategy_v2/data.py index 06285695c..be3ca5fc9 100644 --- a/backend_api_python/app/services/strategy_v2/data.py +++ b/backend_api_python/app/services/strategy_v2/data.py @@ -192,6 +192,7 @@ def bar_at(self, symbol: object, timestamp: Any) -> dict[str, Any] | None: "limit_down", "is_limit_down", "lot_size", + "min_notional", "industry", ): if name in row.index: diff --git a/backend_api_python/app/services/strategy_v2/runtime.py b/backend_api_python/app/services/strategy_v2/runtime.py index 0083ec796..d95f170bb 100644 --- a/backend_api_python/app/services/strategy_v2/runtime.py +++ b/backend_api_python/app/services/strategy_v2/runtime.py @@ -791,6 +791,14 @@ def execute( liquidity_cap = None if forced_liquidation else self._liquidity_cap(bar, lot_size) if liquidity_cap is not None and abs(delta) > liquidity_cap: delta = math.copysign(liquidity_cap, delta) + # Validate MIN_NOTIONAL: if the order notional is below the exchange minimum, reject + min_notional = self._min_notional(bar) + if min_notional > 0 and fill_price > 0 and abs(delta * fill_price) < min_notional: + batch_event_indexes.append(self._append_order_event(self._order_event( + order_id, order, timestamp, "rejected", "min_notional", + requested_quantity=abs(requested_delta), + ))) + continue if forced_liquidation: feasible_delta, constraint_reason = delta, "" else: @@ -829,6 +837,9 @@ def execute( current.avg_cost = _next_average_cost(old_amount, current.avg_cost, delta, fill_price) current.last_price = fill_price self.portfolio.available_cash = projected_cash + # Force sub-lot residuals to zero: if position amount is below lot_size, treat as fully closed + if abs(current.amount) <= lot_size - 1e-12: + current.amount = 0.0 if abs(current.amount) <= 1e-12: self.portfolio.positions.pop(position_key, None) self._protections.pop(position_key, None) @@ -1061,13 +1072,19 @@ def _lot_size(symbol: str, bar: Mapping[str, Any] | None) -> float: explicit = float((bar or {}).get("lot_size") or 0.0) if explicit > 0: return explicit + # Fallback for backward compatibility: Crypto perpetuals on Binance use integer coin lots + # (stepSize = "1" meaning 1 coin), not 1e-8. return 1e-8 if str(symbol).startswith("Crypto:") else 1.0 + @staticmethod + def _min_notional(bar: Mapping[str, Any] | None) -> float: + return float((bar or {}).get("min_notional") or 0.0) + @staticmethod def _round_to_lot(value: float, lot_size: float) -> float: if lot_size <= 0: return value - units = math.floor(abs(value) / lot_size + 1e-8) + units = math.floor(abs(value) / lot_size + 1e-12) return math.copysign(units * lot_size, value) if units else 0.0 @staticmethod diff --git a/backend_api_python/tests/test_strategy_v2_runtime.py b/backend_api_python/tests/test_strategy_v2_runtime.py index b262a8168..682450693 100644 --- a/backend_api_python/tests/test_strategy_v2_runtime.py +++ b/backend_api_python/tests/test_strategy_v2_runtime.py @@ -1067,6 +1067,219 @@ def handle_data(context, data): assert restored.program.state.counter == 0 +def test_crypto_integer_lot_size_no_dust_on_close(): + """ + Test that when using real exchange lot_size (fractional for BTC, integer for low-priced perps), + closing a position does not leave sub-lot dust that blocks re-entry. + + This reproduces the issue from #219 where 1e-8 hardcoded lot_size caused + dust to remain after partial fills due to liquidity caps. + """ + # Simulate a crypto perp with realistic BTC lot_size (0.001 BTC) + # Price: ~50,000 USDT, volume allows only 0.1 BTC per bar due to 10% liquidity cap + prices = [50000, 51000, 52000, 53000, 54000] + index = pd.date_range("2026-01-01", periods=len(prices), freq="1min") + frame = pd.DataFrame({ + "open": prices, + "high": [p * 1.001 for p in prices], + "low": [p * 0.999 for p in prices], + "close": prices, + "volume": [0.1] * len(prices), # Low volume so liquidity cap = 0.1 BTC + "lot_size": [0.001] * len(prices), # BTC perp lot size (0.001 BTC) + "min_notional": [5.0] * len(prices), # Min 5 USDT notional + }, index=index) + + code = """ +def initialize(context): + g.symbol = "Crypto:BTC/USDT@swap" + g.step = 0 + context.set_universe([g.symbol]) + context.subscribe(frequency="1m") + +def handle_data(context, data): + if g.step == 0: + order_target_value(g.symbol, 5000, reason="entry") # ~0.1 BTC + elif g.step == 1: + order_target_value(g.symbol, 0, reason="exit") # Full close + g.step += 1 +""" + result = StrategyV2BacktestRunner( + code=code, + frames={"Crypto:BTC/USDT@swap": frame}, + initial_capital=10_000, + commission=0.0005, + slippage=0.0005, + ).run() + + # Should have 2 executions (entry + exit) + assert result["totalExecutions"] == 2 + assert result["totalTrades"] == 1 + + # Position should be fully closed (no dust remaining) + trade = result["closedTrades"][0] + assert trade["profit"] != 0 # Trade actually happened + + # No rejected orders due to minimum_trade_unit dust + rejected_reasons = [item["statusReason"] for item in result["orderLedger"] if item["status"] == "rejected"] + assert "minimum_trade_unit" not in rejected_reasons, f"Dust caused minimum_trade_unit rejection: {rejected_reasons}" + + # Position should be cleanly closed + assert len(result["executions"]) == 2 + assert result["executions"][0]["side"] == "buy" + assert result["executions"][1]["side"] == "sell" + + +def test_crypto_min_notional_rejection(): + """ + Test that orders below MIN_NOTIONAL are rejected. + """ + prices = [100, 100, 100] + index = pd.date_range("2026-01-01", periods=len(prices), freq="1min") + frame = pd.DataFrame({ + "open": prices, + "high": prices, + "low": prices, + "close": prices, + "volume": [10000] * len(prices), + "lot_size": [0.01] * len(prices), # Realistic lot size + "min_notional": [100.0] * len(prices), # Min 100 USDT notional + }, index=index) + + code = """ +def initialize(context): + g.symbol = "Crypto:BTC/USDT@swap" + g.sent = False + context.set_universe([g.symbol]) + context.subscribe(frequency="1m") + +def handle_data(context, data): + if not g.sent: + order_target_value(g.symbol, 50, reason="entry") # Below min notional of 100 + g.sent = True +""" + result = StrategyV2BacktestRunner( + code=code, + frames={"Crypto:BTC/USDT@swap": frame}, + initial_capital=10_000, + commission=0.0005, + slippage=0.0005, + ).run() + + # Order should be rejected due to min_notional + rejected_reasons = [item["statusReason"] for item in result["orderLedger"] if item["status"] == "rejected"] + assert "min_notional" in rejected_reasons, f"Expected min_notional rejection, got: {rejected_reasons}" + + +def test_crypto_position_dust_forced_to_zero(): + """ + Test that sub-lot position residuals are forced to zero. + """ + prices = [100, 100, 100] + index = pd.date_range("2026-01-01", periods=len(prices), freq="1min") + frame = pd.DataFrame({ + "open": prices, + "high": prices, + "low": prices, + "close": prices, + "volume": [10000] * len(prices), + "lot_size": [0.01] * len(prices), # Lot size = 0.01 units + "min_notional": [1.0] * len(prices), + }, index=index) + + code = """ +def initialize(context): + g.symbol = "Crypto:BTC/USDT@swap" + g.step = 0 + context.set_universe([g.symbol]) + context.subscribe(frequency="1m") + +def handle_data(context, data): + if g.step == 0: + order(g.symbol, 0.25, reason="entry") # 0.25 units, will be rounded to 0.20 (20 lots) + elif g.step == 1: + order(g.symbol, -0.25, reason="exit") # Try to close 0.25, but only 0.20 exist + g.step += 1 +""" + result = StrategyV2BacktestRunner( + code=code, + frames={"Crypto:BTC/USDT@swap": frame}, + initial_capital=10_000, + commission=0.0005, + slippage=0.0005, + ).run() + + # Should have 2 executions + assert result["totalExecutions"] == 2 + assert result["totalTrades"] == 1 + + # Position should be fully closed (no 0.05-unit dust remaining) + trade = result["closedTrades"][0] + assert abs(trade.get("exit_price", 0) - 100) < 1 # Exit at expected price + + # No minimum_trade_unit rejection + rejected_reasons = [item["statusReason"] for item in result["orderLedger"] if item["status"] == "rejected"] + assert "minimum_trade_unit" not in rejected_reasons + + +def test_backtest_results_independent_of_initial_capital(): + """ + Test that backtest execution results are independent of initial capital + (the core issue from #219: larger positions hit liquidity cap more often, + leaving more dust with hardcoded 1e-8 lot_size). + """ + prices = [50000, 51000, 52000, 53000] + index = pd.date_range("2026-01-01", periods=len(prices), freq="1min") + frame = pd.DataFrame({ + "open": prices, + "high": [p * 1.001 for p in prices], + "low": [p * 0.999 for p in prices], + "close": prices, + "volume": [0.2] * len(prices), # Very low volume -> tight liquidity cap (0.02 BTC per bar) + "lot_size": [0.001] * len(prices), # BTC perp lot size (0.001 BTC) + "min_notional": [5.0] * len(prices), + }, index=index) + + code = """ +def initialize(context): + g.symbol = "Crypto:BTC/USDT@swap" + g.step = 0 + context.set_universe([g.symbol]) + context.subscribe(frequency="1m") + +def handle_data(context, data): + if g.step == 0: + order_target_value(g.symbol, 100000, reason="entry") # 2 BTC + elif g.step == 1: + order_target_value(g.symbol, 0, reason="exit") # Full close + g.step += 1 +""" + # Run with different initial capitals + result_small = StrategyV2BacktestRunner( + code=code, + frames={"Crypto:BTC/USDT@swap": frame}, + initial_capital=50_000, # Can only afford ~1 BTC + commission=0.0005, + slippage=0.0005, + ).run() + + result_large = StrategyV2BacktestRunner( + code=code, + frames={"Crypto:BTC/USDT@swap": frame}, + initial_capital=500_000, # Can afford 10 BTC + commission=0.0005, + slippage=0.0005, + ).run() + + # Both should complete the trade (no dust blocking) + assert result_small["totalTrades"] == 1, f"Small capital: {result_small['totalTrades']} trades" + assert result_large["totalTrades"] == 1, f"Large capital: {result_large['totalTrades']} trades" + + # Both should have no minimum_trade_unit rejections + for result in [result_small, result_large]: + rejected = [item["statusReason"] for item in result["orderLedger"] if item["status"] == "rejected"] + assert "minimum_trade_unit" not in rejected, f"Dust rejection: {rejected}" + + def test_strategy_can_cancel_a_resting_limit_before_a_later_bar_crosses_it(): index = pd.date_range("2026-01-01", periods=4, freq="1min") frame = pd.DataFrame({