Skip to content

Commit e7b3ff6

Browse files
authored
Skip IBKR fractional orders below API minimum (#62)
1 parent c1eda4a commit e7b3ff6

3 files changed

Lines changed: 133 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac
103103
| `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. |
104104
| `IBKR_FEATURE_SNAPSHOT_PATH` | Conditionally required | Required for snapshot-backed profiles such as `russell_1000_multi_factor_defensive`, `tech_communication_pullback_enhancement`, and `mega_cap_leader_rotation_top50_balanced`. Path to the latest feature snapshot file (`.csv`, `.json`, `.jsonl`, `.parquet`). |
105105
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional IBKR-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. |
106-
| `IBKR_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `false`; set `true` only after verifying fractional order support for this account/API path. |
106+
| `IBKR_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `false`; set `true` only after verifying fractional order support for this account/API path. Orders that floor below roughly `0.01` shares are skipped because the IBKR API rejects them. |
107107
| `IBKR_ORDER_QUANTITY_STEP` | No | Explicit order quantity step override; e.g. `1` for whole shares or `0.0001` for fractional sizing. Takes precedence over `IBKR_FRACTIONAL_SHARES_ENABLED`. |
108108
| `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for fractional sizing; defaults to `50.0`. |
109109
| `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Yes for Cloud Run | Secret Manager secret name for account-group config JSON. Recommended production source. |
@@ -343,7 +343,7 @@ IBKR 账户
343343
| `ACCOUNT_GROUP` || 账号组选择器,每个部署都要显式设置。 |
344344
| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive``tech_communication_pullback_enhancement``mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv``.json``.jsonl``.parquet`)。 |
345345
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` || 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`|
346-
| `IBKR_FRACTIONAL_SHARES_ENABLED` || 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`|
346+
| `IBKR_FRACTIONAL_SHARES_ENABLED` || 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`四舍五入后低于约 `0.01` 股的订单会跳过,因为 IBKR API 会拒单。 |
347347
| `IBKR_ORDER_QUANTITY_STEP` || 显式覆盖下单数量步进;如 `1` 表示整数股,`0.0001` 表示碎股数量步进。优先级高于 `IBKR_FRACTIONAL_SHARES_ENABLED`|
348348
| `IBKR_MIN_ORDER_NOTIONAL_USD` || 碎股买入的最小名义金额;默认 `50.0`|
349349
| `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Cloud Run 建议必填 | 账号组配置 JSON 在 Secret Manager 里的密钥名。生产环境推荐使用。 |

application/execution_service.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
)
2020

2121

22+
MIN_FRACTIONAL_ORDER_QUANTITY = 0.01
23+
24+
2225
def get_market_prices(
2326
ib,
2427
symbols,
@@ -430,6 +433,10 @@ def _floor_order_quantity(quantity, *, quantity_step):
430433
return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step))
431434

432435

436+
def _minimum_supported_order_quantity(quantity_step: float) -> float:
437+
return 1.0 if float(quantity_step or 1.0) >= 1.0 else MIN_FRACTIONAL_ORDER_QUANTITY
438+
439+
433440
def _sell_order_quantity(
434441
*,
435442
current_value,
@@ -542,6 +549,7 @@ def execute_rebalance(
542549
threshold = equity * rebalance_threshold_ratio
543550
order_quantity_step = float(quantity_step or 1.0)
544551
minimum_order_notional = max(0.0, float(min_order_notional or 0.0))
552+
minimum_supported_quantity = _minimum_supported_order_quantity(order_quantity_step)
545553
execution_summary["cash_reserve_dollars"] = float(reserved)
546554

547555
all_symbols = set(target_weights.keys()) | set(positions.keys())
@@ -650,6 +658,7 @@ def execute_rebalance(
650658
insufficient_buying_power_symbols: list[str] = []
651659
min_notional_symbols: list[str] = []
652660
quantity_zero_symbols: list[str] = []
661+
fractional_quantity_too_small_symbols: list[str] = []
653662
anticipated_buying_power = get_available_buying_power(
654663
ib,
655664
account_values.get("buying_power", 0),
@@ -700,6 +709,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
700709
quantity_step=order_quantity_step,
701710
)
702711
if qty > 0:
712+
if qty < minimum_supported_quantity:
713+
fractional_quantity_too_small_symbols.append(symbol)
714+
execution_summary["orders_skipped"].append(
715+
{
716+
"symbol": symbol,
717+
"side": "sell",
718+
"reason": "fractional_quantity_too_small",
719+
"quantity": qty,
720+
"minimum_quantity": minimum_supported_quantity,
721+
}
722+
)
723+
continue
703724
has_sell_plan = True
704725
break
705726
quantity_zero_symbols.append(symbol)
@@ -748,6 +769,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
748769
else 0
749770
)
750771
if qty > 0:
772+
if qty < minimum_supported_quantity:
773+
fractional_quantity_too_small_symbols.append(symbol)
774+
execution_summary["orders_skipped"].append(
775+
{
776+
"symbol": symbol,
777+
"side": "buy",
778+
"reason": "fractional_quantity_too_small",
779+
"quantity": qty,
780+
"minimum_quantity": minimum_supported_quantity,
781+
}
782+
)
783+
continue
751784
has_buy_plan = True
752785
break
753786
quantity_zero_symbols.append(symbol)
@@ -770,6 +803,9 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
770803
elif min_notional_symbols:
771804
symbols = ",".join(sorted(dict.fromkeys(min_notional_symbols)))
772805
reason = f"min_notional:{symbols}"
806+
elif fractional_quantity_too_small_symbols:
807+
symbols = ",".join(sorted(dict.fromkeys(fractional_quantity_too_small_symbols)))
808+
reason = f"fractional_quantity_too_small:{symbols}"
773809
elif quantity_zero_symbols:
774810
symbols = ",".join(sorted(dict.fromkeys(quantity_zero_symbols)))
775811
reason = f"quantity_zero:{symbols}"
@@ -878,6 +914,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
878914
if qty <= 0:
879915
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "quantity_zero"})
880916
continue
917+
if qty < minimum_supported_quantity:
918+
execution_summary["orders_skipped"].append(
919+
{
920+
"symbol": symbol,
921+
"side": "sell",
922+
"reason": "fractional_quantity_too_small",
923+
"quantity": qty,
924+
"minimum_quantity": minimum_supported_quantity,
925+
}
926+
)
927+
execution_summary["skipped_reasons"].append(f"fractional_quantity_too_small:{symbol}")
928+
continue
881929
elif current > target + threshold:
882930
if not price:
883931
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "missing_price"})
@@ -893,6 +941,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
893941
if qty <= 0:
894942
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "quantity_zero"})
895943
continue
944+
if qty < minimum_supported_quantity:
945+
execution_summary["orders_skipped"].append(
946+
{
947+
"symbol": symbol,
948+
"side": "sell",
949+
"reason": "fractional_quantity_too_small",
950+
"quantity": qty,
951+
"minimum_quantity": minimum_supported_quantity,
952+
}
953+
)
954+
execution_summary["skipped_reasons"].append(f"fractional_quantity_too_small:{symbol}")
955+
continue
896956
else:
897957
continue
898958

@@ -968,6 +1028,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
9681028
if qty <= 0:
9691029
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "quantity_zero"})
9701030
continue
1031+
if qty < minimum_supported_quantity:
1032+
execution_summary["orders_skipped"].append(
1033+
{
1034+
"symbol": symbol,
1035+
"side": "buy",
1036+
"reason": "fractional_quantity_too_small",
1037+
"quantity": qty,
1038+
"minimum_quantity": minimum_supported_quantity,
1039+
}
1040+
)
1041+
execution_summary["skipped_reasons"].append(f"fractional_quantity_too_small:{symbol}")
1042+
continue
9711043

9721044
if dry_run_only:
9731045
execution_summary["orders_submitted"].append(

tests/test_execution_service.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,65 @@ def fake_fetch_quote_snapshots(_ib, symbols):
263263
assert math.isclose(submitted[0].quantity, 0.2985, rel_tol=0.0, abs_tol=1e-9)
264264

265265

266+
def test_execute_rebalance_skips_fractional_orders_below_ibkr_minimum_quantity(monkeypatch, tmp_path):
267+
class FakeIB:
268+
def openTrades(self):
269+
return []
270+
271+
def fills(self):
272+
return []
273+
274+
def accountValues(self):
275+
return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="1000")]
276+
277+
submitted = []
278+
279+
def fake_submit_order_intent(_ib, intent):
280+
submitted.append(intent)
281+
return SimpleNamespace(broker_order_id="1", status="Submitted")
282+
283+
def fake_fetch_quote_snapshots(_ib, symbols):
284+
return {symbol: SimpleNamespace(last_price=724.32) for symbol in symbols}
285+
286+
monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None)
287+
288+
_trade_logs, summary = execute_rebalance(
289+
FakeIB(),
290+
{"QQQ": 0.002},
291+
{},
292+
{"equity": 1000.0, "buying_power": 1000.0},
293+
fetch_quote_snapshots=fake_fetch_quote_snapshots,
294+
submit_order_intent=fake_submit_order_intent,
295+
order_intent_cls=OrderIntent,
296+
translator=translate,
297+
strategy_symbols=["QQQ"],
298+
strategy_profile="global_etf_rotation",
299+
signal_metadata=_signal_metadata({"QQQ": 0.002}, risk_symbols=("QQQ",), trade_date="2026-04-01"),
300+
dry_run_only=False,
301+
cash_reserve_ratio=0.0,
302+
rebalance_threshold_ratio=0.0,
303+
limit_buy_premium=1.005,
304+
quantity_step=0.0001,
305+
min_order_notional=1.0,
306+
sell_settle_delay_sec=0,
307+
execution_lock_dir=tmp_path,
308+
return_summary=True,
309+
)
310+
311+
assert summary["execution_status"] == "no_op"
312+
assert summary["no_op_reason"] == "fractional_quantity_too_small:QQQ"
313+
assert summary["orders_skipped"] == [
314+
{
315+
"symbol": "QQQ",
316+
"side": "buy",
317+
"reason": "fractional_quantity_too_small",
318+
"quantity": 0.0027,
319+
"minimum_quantity": 0.01,
320+
}
321+
]
322+
assert submitted == []
323+
324+
266325
def test_execute_rebalance_zero_target_sell_uses_position_quantity(monkeypatch, tmp_path):
267326
class FakeIB:
268327
def openTrades(self):

0 commit comments

Comments
 (0)