Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions backend_api_python/app/data_sources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand Down
52 changes: 51 additions & 1 deletion backend_api_python/app/data_sources/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
根据交易所特性规范化符号
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions backend_api_python/app/services/strategy_v2/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 18 additions & 1 deletion backend_api_python/app/services/strategy_v2/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
213 changes: 213 additions & 0 deletions backend_api_python/tests/test_strategy_v2_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down