diff --git a/README.md b/README.md index 18d3b1d..3210e8b 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac | `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. | | `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`). | | `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional IBKR-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. | -| `IBKR_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `false`; set `true` only after verifying fractional order support for this account/API path. | +| `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. | | `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`. | | `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for fractional sizing; defaults to `50.0`. | | `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 账户 | `ACCOUNT_GROUP` | 是 | 账号组选择器,每个部署都要显式设置。 | | `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv`、`.json`、`.jsonl`、`.parquet`)。 | | `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 | -| `IBKR_FRACTIONAL_SHARES_ENABLED` | 否 | 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`。 | +| `IBKR_FRACTIONAL_SHARES_ENABLED` | 否 | 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`。四舍五入后低于约 `0.01` 股的订单会跳过,因为 IBKR API 会拒单。 | | `IBKR_ORDER_QUANTITY_STEP` | 否 | 显式覆盖下单数量步进;如 `1` 表示整数股,`0.0001` 表示碎股数量步进。优先级高于 `IBKR_FRACTIONAL_SHARES_ENABLED`。 | | `IBKR_MIN_ORDER_NOTIONAL_USD` | 否 | 碎股买入的最小名义金额;默认 `50.0`。 | | `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Cloud Run 建议必填 | 账号组配置 JSON 在 Secret Manager 里的密钥名。生产环境推荐使用。 | diff --git a/application/execution_service.py b/application/execution_service.py index a746859..c7a5b7e 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -19,6 +19,9 @@ ) +MIN_FRACTIONAL_ORDER_QUANTITY = 0.01 + + def get_market_prices( ib, symbols, @@ -430,6 +433,10 @@ def _floor_order_quantity(quantity, *, quantity_step): return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step)) +def _minimum_supported_order_quantity(quantity_step: float) -> float: + return 1.0 if float(quantity_step or 1.0) >= 1.0 else MIN_FRACTIONAL_ORDER_QUANTITY + + def _sell_order_quantity( *, current_value, @@ -542,6 +549,7 @@ def execute_rebalance( threshold = equity * rebalance_threshold_ratio order_quantity_step = float(quantity_step or 1.0) minimum_order_notional = max(0.0, float(min_order_notional or 0.0)) + minimum_supported_quantity = _minimum_supported_order_quantity(order_quantity_step) execution_summary["cash_reserve_dollars"] = float(reserved) all_symbols = set(target_weights.keys()) | set(positions.keys()) @@ -650,6 +658,7 @@ def execute_rebalance( insufficient_buying_power_symbols: list[str] = [] min_notional_symbols: list[str] = [] quantity_zero_symbols: list[str] = [] + fractional_quantity_too_small_symbols: list[str] = [] anticipated_buying_power = get_available_buying_power( ib, account_values.get("buying_power", 0), @@ -700,6 +709,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t quantity_step=order_quantity_step, ) if qty > 0: + if qty < minimum_supported_quantity: + fractional_quantity_too_small_symbols.append(symbol) + execution_summary["orders_skipped"].append( + { + "symbol": symbol, + "side": "sell", + "reason": "fractional_quantity_too_small", + "quantity": qty, + "minimum_quantity": minimum_supported_quantity, + } + ) + continue has_sell_plan = True break quantity_zero_symbols.append(symbol) @@ -748,6 +769,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t else 0 ) if qty > 0: + if qty < minimum_supported_quantity: + fractional_quantity_too_small_symbols.append(symbol) + execution_summary["orders_skipped"].append( + { + "symbol": symbol, + "side": "buy", + "reason": "fractional_quantity_too_small", + "quantity": qty, + "minimum_quantity": minimum_supported_quantity, + } + ) + continue has_buy_plan = True break quantity_zero_symbols.append(symbol) @@ -770,6 +803,9 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t elif min_notional_symbols: symbols = ",".join(sorted(dict.fromkeys(min_notional_symbols))) reason = f"min_notional:{symbols}" + elif fractional_quantity_too_small_symbols: + symbols = ",".join(sorted(dict.fromkeys(fractional_quantity_too_small_symbols))) + reason = f"fractional_quantity_too_small:{symbols}" elif quantity_zero_symbols: symbols = ",".join(sorted(dict.fromkeys(quantity_zero_symbols))) reason = f"quantity_zero:{symbols}" @@ -878,6 +914,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t if qty <= 0: execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "quantity_zero"}) continue + if qty < minimum_supported_quantity: + execution_summary["orders_skipped"].append( + { + "symbol": symbol, + "side": "sell", + "reason": "fractional_quantity_too_small", + "quantity": qty, + "minimum_quantity": minimum_supported_quantity, + } + ) + execution_summary["skipped_reasons"].append(f"fractional_quantity_too_small:{symbol}") + continue elif current > target + threshold: if not price: 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 if qty <= 0: execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "quantity_zero"}) continue + if qty < minimum_supported_quantity: + execution_summary["orders_skipped"].append( + { + "symbol": symbol, + "side": "sell", + "reason": "fractional_quantity_too_small", + "quantity": qty, + "minimum_quantity": minimum_supported_quantity, + } + ) + execution_summary["skipped_reasons"].append(f"fractional_quantity_too_small:{symbol}") + continue else: continue @@ -968,6 +1028,18 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t if qty <= 0: execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "quantity_zero"}) continue + if qty < minimum_supported_quantity: + execution_summary["orders_skipped"].append( + { + "symbol": symbol, + "side": "buy", + "reason": "fractional_quantity_too_small", + "quantity": qty, + "minimum_quantity": minimum_supported_quantity, + } + ) + execution_summary["skipped_reasons"].append(f"fractional_quantity_too_small:{symbol}") + continue if dry_run_only: execution_summary["orders_submitted"].append( diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 8043820..016aa55 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -263,6 +263,65 @@ def fake_fetch_quote_snapshots(_ib, symbols): assert math.isclose(submitted[0].quantity, 0.2985, rel_tol=0.0, abs_tol=1e-9) +def test_execute_rebalance_skips_fractional_orders_below_ibkr_minimum_quantity(monkeypatch, tmp_path): + class FakeIB: + def openTrades(self): + return [] + + def fills(self): + return [] + + def accountValues(self): + return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="1000")] + + submitted = [] + + def fake_submit_order_intent(_ib, intent): + submitted.append(intent) + return SimpleNamespace(broker_order_id="1", status="Submitted") + + def fake_fetch_quote_snapshots(_ib, symbols): + return {symbol: SimpleNamespace(last_price=724.32) for symbol in symbols} + + monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) + + _trade_logs, summary = execute_rebalance( + FakeIB(), + {"QQQ": 0.002}, + {}, + {"equity": 1000.0, "buying_power": 1000.0}, + fetch_quote_snapshots=fake_fetch_quote_snapshots, + submit_order_intent=fake_submit_order_intent, + order_intent_cls=OrderIntent, + translator=translate, + strategy_symbols=["QQQ"], + strategy_profile="global_etf_rotation", + signal_metadata=_signal_metadata({"QQQ": 0.002}, risk_symbols=("QQQ",), trade_date="2026-04-01"), + dry_run_only=False, + cash_reserve_ratio=0.0, + rebalance_threshold_ratio=0.0, + limit_buy_premium=1.005, + quantity_step=0.0001, + min_order_notional=1.0, + sell_settle_delay_sec=0, + execution_lock_dir=tmp_path, + return_summary=True, + ) + + assert summary["execution_status"] == "no_op" + assert summary["no_op_reason"] == "fractional_quantity_too_small:QQQ" + assert summary["orders_skipped"] == [ + { + "symbol": "QQQ", + "side": "buy", + "reason": "fractional_quantity_too_small", + "quantity": 0.0027, + "minimum_quantity": 0.01, + } + ] + assert submitted == [] + + def test_execute_rebalance_zero_target_sell_uses_position_quantity(monkeypatch, tmp_path): class FakeIB: def openTrades(self):