@@ -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+
10701283def 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