Skip to content

Commit ef932ad

Browse files
Fix #219: Use real exchange lot_size (stepSize) and MIN_NOTIONAL for crypto perps (#222)
- Add _get_lot_size_and_min_notional() to CryptoDataSource to fetch exchange precision/limits (stepSize and minNotional) from CCXT market data - Pass lot_size and min_notional through format_kline() into bar data - Update _lot_size() in runtime to use bar.lot_size from exchange data - Add _min_notional() helper and validate MIN_NOTIONAL during order execution - Force sub-lot position residuals to zero to prevent dust blocking re-entry - Fix precision in _round_to_lot (1e-12 instead of 1e-8) - Add tests for integer lot sizes, MIN_NOTIONAL rejection, dust elimination, and capital-independent backtest results Co-authored-by: Henry <220133043+brokermr810@users.noreply.github.com>
1 parent b59352b commit ef932ad

5 files changed

Lines changed: 289 additions & 4 deletions

File tree

backend_api_python/app/data_sources/base.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ def format_kline(
7171
high: float,
7272
low: float,
7373
close: float,
74-
volume: float
74+
volume: float,
75+
lot_size: float = 0.0,
76+
min_notional: float = 0.0
7577
) -> Dict[str, Any]:
7678
"""Normalize one K-line row while preserving provider price precision; volume keeps two decimals."""
7779
return {
@@ -80,7 +82,9 @@ def format_kline(
8082
'high': float(high),
8183
'low': float(low),
8284
'close': float(close),
83-
'volume': round(float(volume), 2)
85+
'volume': round(float(volume), 2),
86+
'lot_size': lot_size,
87+
'min_notional': min_notional,
8488
}
8589

8690
def calculate_time_range(

backend_api_python/app/data_sources/crypto.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,48 @@ def _find_valid_symbol(self, base: str, preferred_quote: str = 'USDT') -> Option
379379

380380
return None
381381

382+
def _get_lot_size_and_min_notional(self, symbol: str) -> tuple[float, float]:
383+
"""
384+
Get lot_size (stepSize) and min_notional for a symbol from exchange markets.
385+
386+
Args:
387+
symbol: Normalized symbol (e.g., 'BTC/USDT')
388+
389+
Returns:
390+
Tuple of (lot_size, min_notional) in base currency units.
391+
Returns (0.0, 0.0) if not available.
392+
"""
393+
if not self._ensure_markets_loaded():
394+
return 0.0, 0.0
395+
396+
markets = self._markets_cache or {}
397+
if not markets or symbol not in markets:
398+
return 0.0, 0.0
399+
400+
market = markets[symbol]
401+
lot_size = 0.0
402+
min_notional = 0.0
403+
404+
# Get lot_size from precision.amount or limits.amount.min
405+
precision = market.get('precision', {})
406+
if isinstance(precision, dict) and precision.get('amount') is not None:
407+
lot_size = float(precision['amount'])
408+
409+
# Fallback to limits.amount.min
410+
if lot_size <= 0:
411+
limits = market.get('limits', {})
412+
amount_limits = limits.get('amount', {}) if isinstance(limits, dict) else {}
413+
if isinstance(amount_limits, dict) and amount_limits.get('min') is not None:
414+
lot_size = float(amount_limits['min'])
415+
416+
# Get min_notional from limits.cost.min
417+
limits = market.get('limits', {})
418+
cost_limits = limits.get('cost', {}) if isinstance(limits, dict) else {}
419+
if isinstance(cost_limits, dict) and cost_limits.get('min') is not None:
420+
min_notional = float(cost_limits['min'])
421+
422+
return lot_size, min_notional
423+
382424
def _normalize_symbol_for_exchange(self, symbol: str) -> str:
383425
"""
384426
根据交易所特性规范化符号
@@ -595,13 +637,21 @@ def get_kline(
595637
for candle in ohlcv:
596638
if len(candle) < 6:
597639
continue
640+
# Get lot_size and min_notional from exchange market data
641+
lot_size = 0.0
642+
min_notional = 0.0
643+
if self._ensure_markets_loaded() and symbol_pair in (self._markets_cache or {}):
644+
lot_size, min_notional = self._get_lot_size_and_min_notional(symbol_pair)
645+
598646
klines.append(self.format_kline(
599647
timestamp=int(candle[0] / 1000), # 毫秒转秒
600648
open_price=candle[1],
601649
high=candle[2],
602650
low=candle[3],
603651
close=candle[4],
604-
volume=candle[5]
652+
volume=candle[5],
653+
lot_size=lot_size,
654+
min_notional=min_notional
605655
))
606656

607657
klines = self.filter_and_limit(

backend_api_python/app/services/strategy_v2/data.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ def bar_at(self, symbol: object, timestamp: Any) -> dict[str, Any] | None:
192192
"limit_down",
193193
"is_limit_down",
194194
"lot_size",
195+
"min_notional",
195196
"industry",
196197
):
197198
if name in row.index:

backend_api_python/app/services/strategy_v2/runtime.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -791,6 +791,14 @@ def execute(
791791
liquidity_cap = None if forced_liquidation else self._liquidity_cap(bar, lot_size)
792792
if liquidity_cap is not None and abs(delta) > liquidity_cap:
793793
delta = math.copysign(liquidity_cap, delta)
794+
# Validate MIN_NOTIONAL: if the order notional is below the exchange minimum, reject
795+
min_notional = self._min_notional(bar)
796+
if min_notional > 0 and fill_price > 0 and abs(delta * fill_price) < min_notional:
797+
batch_event_indexes.append(self._append_order_event(self._order_event(
798+
order_id, order, timestamp, "rejected", "min_notional",
799+
requested_quantity=abs(requested_delta),
800+
)))
801+
continue
794802
if forced_liquidation:
795803
feasible_delta, constraint_reason = delta, ""
796804
else:
@@ -829,6 +837,9 @@ def execute(
829837
current.avg_cost = _next_average_cost(old_amount, current.avg_cost, delta, fill_price)
830838
current.last_price = fill_price
831839
self.portfolio.available_cash = projected_cash
840+
# Force sub-lot residuals to zero: if position amount is below lot_size, treat as fully closed
841+
if abs(current.amount) <= lot_size - 1e-12:
842+
current.amount = 0.0
832843
if abs(current.amount) <= 1e-12:
833844
self.portfolio.positions.pop(position_key, None)
834845
self._protections.pop(position_key, None)
@@ -1061,13 +1072,19 @@ def _lot_size(symbol: str, bar: Mapping[str, Any] | None) -> float:
10611072
explicit = float((bar or {}).get("lot_size") or 0.0)
10621073
if explicit > 0:
10631074
return explicit
1075+
# Fallback for backward compatibility: Crypto perpetuals on Binance use integer coin lots
1076+
# (stepSize = "1" meaning 1 coin), not 1e-8.
10641077
return 1e-8 if str(symbol).startswith("Crypto:") else 1.0
10651078

1079+
@staticmethod
1080+
def _min_notional(bar: Mapping[str, Any] | None) -> float:
1081+
return float((bar or {}).get("min_notional") or 0.0)
1082+
10661083
@staticmethod
10671084
def _round_to_lot(value: float, lot_size: float) -> float:
10681085
if lot_size <= 0:
10691086
return value
1070-
units = math.floor(abs(value) / lot_size + 1e-8)
1087+
units = math.floor(abs(value) / lot_size + 1e-12)
10711088
return math.copysign(units * lot_size, value) if units else 0.0
10721089

10731090
@staticmethod

backend_api_python/tests/test_strategy_v2_runtime.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,6 +1067,219 @@ def handle_data(context, data):
10671067
assert restored.program.state.counter == 0
10681068

10691069

1070+
def test_crypto_integer_lot_size_no_dust_on_close():
1071+
"""
1072+
Test that when using real exchange lot_size (fractional for BTC, integer for low-priced perps),
1073+
closing a position does not leave sub-lot dust that blocks re-entry.
1074+
1075+
This reproduces the issue from #219 where 1e-8 hardcoded lot_size caused
1076+
dust to remain after partial fills due to liquidity caps.
1077+
"""
1078+
# Simulate a crypto perp with realistic BTC lot_size (0.001 BTC)
1079+
# Price: ~50,000 USDT, volume allows only 0.1 BTC per bar due to 10% liquidity cap
1080+
prices = [50000, 51000, 52000, 53000, 54000]
1081+
index = pd.date_range("2026-01-01", periods=len(prices), freq="1min")
1082+
frame = pd.DataFrame({
1083+
"open": prices,
1084+
"high": [p * 1.001 for p in prices],
1085+
"low": [p * 0.999 for p in prices],
1086+
"close": prices,
1087+
"volume": [0.1] * len(prices), # Low volume so liquidity cap = 0.1 BTC
1088+
"lot_size": [0.001] * len(prices), # BTC perp lot size (0.001 BTC)
1089+
"min_notional": [5.0] * len(prices), # Min 5 USDT notional
1090+
}, index=index)
1091+
1092+
code = """
1093+
def initialize(context):
1094+
g.symbol = "Crypto:BTC/USDT@swap"
1095+
g.step = 0
1096+
context.set_universe([g.symbol])
1097+
context.subscribe(frequency="1m")
1098+
1099+
def handle_data(context, data):
1100+
if g.step == 0:
1101+
order_target_value(g.symbol, 5000, reason="entry") # ~0.1 BTC
1102+
elif g.step == 1:
1103+
order_target_value(g.symbol, 0, reason="exit") # Full close
1104+
g.step += 1
1105+
"""
1106+
result = StrategyV2BacktestRunner(
1107+
code=code,
1108+
frames={"Crypto:BTC/USDT@swap": frame},
1109+
initial_capital=10_000,
1110+
commission=0.0005,
1111+
slippage=0.0005,
1112+
).run()
1113+
1114+
# Should have 2 executions (entry + exit)
1115+
assert result["totalExecutions"] == 2
1116+
assert result["totalTrades"] == 1
1117+
1118+
# Position should be fully closed (no dust remaining)
1119+
trade = result["closedTrades"][0]
1120+
assert trade["profit"] != 0 # Trade actually happened
1121+
1122+
# No rejected orders due to minimum_trade_unit dust
1123+
rejected_reasons = [item["statusReason"] for item in result["orderLedger"] if item["status"] == "rejected"]
1124+
assert "minimum_trade_unit" not in rejected_reasons, f"Dust caused minimum_trade_unit rejection: {rejected_reasons}"
1125+
1126+
# Position should be cleanly closed
1127+
assert len(result["executions"]) == 2
1128+
assert result["executions"][0]["side"] == "buy"
1129+
assert result["executions"][1]["side"] == "sell"
1130+
1131+
1132+
def test_crypto_min_notional_rejection():
1133+
"""
1134+
Test that orders below MIN_NOTIONAL are rejected.
1135+
"""
1136+
prices = [100, 100, 100]
1137+
index = pd.date_range("2026-01-01", periods=len(prices), freq="1min")
1138+
frame = pd.DataFrame({
1139+
"open": prices,
1140+
"high": prices,
1141+
"low": prices,
1142+
"close": prices,
1143+
"volume": [10000] * len(prices),
1144+
"lot_size": [0.01] * len(prices), # Realistic lot size
1145+
"min_notional": [100.0] * len(prices), # Min 100 USDT notional
1146+
}, index=index)
1147+
1148+
code = """
1149+
def initialize(context):
1150+
g.symbol = "Crypto:BTC/USDT@swap"
1151+
g.sent = False
1152+
context.set_universe([g.symbol])
1153+
context.subscribe(frequency="1m")
1154+
1155+
def handle_data(context, data):
1156+
if not g.sent:
1157+
order_target_value(g.symbol, 50, reason="entry") # Below min notional of 100
1158+
g.sent = True
1159+
"""
1160+
result = StrategyV2BacktestRunner(
1161+
code=code,
1162+
frames={"Crypto:BTC/USDT@swap": frame},
1163+
initial_capital=10_000,
1164+
commission=0.0005,
1165+
slippage=0.0005,
1166+
).run()
1167+
1168+
# Order should be rejected due to min_notional
1169+
rejected_reasons = [item["statusReason"] for item in result["orderLedger"] if item["status"] == "rejected"]
1170+
assert "min_notional" in rejected_reasons, f"Expected min_notional rejection, got: {rejected_reasons}"
1171+
1172+
1173+
def test_crypto_position_dust_forced_to_zero():
1174+
"""
1175+
Test that sub-lot position residuals are forced to zero.
1176+
"""
1177+
prices = [100, 100, 100]
1178+
index = pd.date_range("2026-01-01", periods=len(prices), freq="1min")
1179+
frame = pd.DataFrame({
1180+
"open": prices,
1181+
"high": prices,
1182+
"low": prices,
1183+
"close": prices,
1184+
"volume": [10000] * len(prices),
1185+
"lot_size": [0.01] * len(prices), # Lot size = 0.01 units
1186+
"min_notional": [1.0] * len(prices),
1187+
}, index=index)
1188+
1189+
code = """
1190+
def initialize(context):
1191+
g.symbol = "Crypto:BTC/USDT@swap"
1192+
g.step = 0
1193+
context.set_universe([g.symbol])
1194+
context.subscribe(frequency="1m")
1195+
1196+
def handle_data(context, data):
1197+
if g.step == 0:
1198+
order(g.symbol, 0.25, reason="entry") # 0.25 units, will be rounded to 0.20 (20 lots)
1199+
elif g.step == 1:
1200+
order(g.symbol, -0.25, reason="exit") # Try to close 0.25, but only 0.20 exist
1201+
g.step += 1
1202+
"""
1203+
result = StrategyV2BacktestRunner(
1204+
code=code,
1205+
frames={"Crypto:BTC/USDT@swap": frame},
1206+
initial_capital=10_000,
1207+
commission=0.0005,
1208+
slippage=0.0005,
1209+
).run()
1210+
1211+
# Should have 2 executions
1212+
assert result["totalExecutions"] == 2
1213+
assert result["totalTrades"] == 1
1214+
1215+
# Position should be fully closed (no 0.05-unit dust remaining)
1216+
trade = result["closedTrades"][0]
1217+
assert abs(trade.get("exit_price", 0) - 100) < 1 # Exit at expected price
1218+
1219+
# No minimum_trade_unit rejection
1220+
rejected_reasons = [item["statusReason"] for item in result["orderLedger"] if item["status"] == "rejected"]
1221+
assert "minimum_trade_unit" not in rejected_reasons
1222+
1223+
1224+
def test_backtest_results_independent_of_initial_capital():
1225+
"""
1226+
Test that backtest execution results are independent of initial capital
1227+
(the core issue from #219: larger positions hit liquidity cap more often,
1228+
leaving more dust with hardcoded 1e-8 lot_size).
1229+
"""
1230+
prices = [50000, 51000, 52000, 53000]
1231+
index = pd.date_range("2026-01-01", periods=len(prices), freq="1min")
1232+
frame = pd.DataFrame({
1233+
"open": prices,
1234+
"high": [p * 1.001 for p in prices],
1235+
"low": [p * 0.999 for p in prices],
1236+
"close": prices,
1237+
"volume": [0.2] * len(prices), # Very low volume -> tight liquidity cap (0.02 BTC per bar)
1238+
"lot_size": [0.001] * len(prices), # BTC perp lot size (0.001 BTC)
1239+
"min_notional": [5.0] * len(prices),
1240+
}, index=index)
1241+
1242+
code = """
1243+
def initialize(context):
1244+
g.symbol = "Crypto:BTC/USDT@swap"
1245+
g.step = 0
1246+
context.set_universe([g.symbol])
1247+
context.subscribe(frequency="1m")
1248+
1249+
def handle_data(context, data):
1250+
if g.step == 0:
1251+
order_target_value(g.symbol, 100000, reason="entry") # 2 BTC
1252+
elif g.step == 1:
1253+
order_target_value(g.symbol, 0, reason="exit") # Full close
1254+
g.step += 1
1255+
"""
1256+
# Run with different initial capitals
1257+
result_small = StrategyV2BacktestRunner(
1258+
code=code,
1259+
frames={"Crypto:BTC/USDT@swap": frame},
1260+
initial_capital=50_000, # Can only afford ~1 BTC
1261+
commission=0.0005,
1262+
slippage=0.0005,
1263+
).run()
1264+
1265+
result_large = StrategyV2BacktestRunner(
1266+
code=code,
1267+
frames={"Crypto:BTC/USDT@swap": frame},
1268+
initial_capital=500_000, # Can afford 10 BTC
1269+
commission=0.0005,
1270+
slippage=0.0005,
1271+
).run()
1272+
1273+
# Both should complete the trade (no dust blocking)
1274+
assert result_small["totalTrades"] == 1, f"Small capital: {result_small['totalTrades']} trades"
1275+
assert result_large["totalTrades"] == 1, f"Large capital: {result_large['totalTrades']} trades"
1276+
1277+
# Both should have no minimum_trade_unit rejections
1278+
for result in [result_small, result_large]:
1279+
rejected = [item["statusReason"] for item in result["orderLedger"] if item["status"] == "rejected"]
1280+
assert "minimum_trade_unit" not in rejected, f"Dust rejection: {rejected}"
1281+
1282+
10701283
def test_strategy_can_cancel_a_resting_limit_before_a_later_bar_crosses_it():
10711284
index = pd.date_range("2026-01-01", periods=4, freq="1min")
10721285
frame = pd.DataFrame({

0 commit comments

Comments
 (0)