diff --git a/README.md b/README.md index 3210e8b..3c974ff 100644 --- a/README.md +++ b/README.md @@ -103,9 +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. 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`. | +| `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for limit buys; 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. | | `IB_ACCOUNT_GROUP_CONFIG_JSON` | No | Local/dev JSON fallback for account-group config. Not recommended for production Cloud Run. | | `TELEGRAM_TOKEN` | Yes | Telegram bot token. For Cloud Run, prefer a Secret Manager reference instead of a literal env var. | @@ -160,9 +158,7 @@ IBKR_FEATURE_SNAPSHOT_PATH=/var/data/tech_communication_pullback_enhancement_fea IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=/var/manifests/tech_communication_pullback_enhancement_feature_snapshot_latest.csv.manifest.json # IBKR_STRATEGY_CONFIG_PATH is optional; the bundled canonical default is used when unset. IBKR_DRY_RUN_ONLY=true -# Keep whole-share sizing unless fractional API support has been verified for this account/API path. -# IBKR_FRACTIONAL_SHARES_ENABLED=true -# IBKR_ORDER_QUANTITY_STEP=0.0001 +# IBKR orders run on whole shares only. GLOBAL_TELEGRAM_CHAT_ID= NOTIFY_LANG=zh ``` @@ -343,9 +339,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`。四舍五入后低于约 `0.01` 股的订单会跳过,因为 IBKR API 会拒单。 | -| `IBKR_ORDER_QUANTITY_STEP` | 否 | 显式覆盖下单数量步进;如 `1` 表示整数股,`0.0001` 表示碎股数量步进。优先级高于 `IBKR_FRACTIONAL_SHARES_ENABLED`。 | -| `IBKR_MIN_ORDER_NOTIONAL_USD` | 否 | 碎股买入的最小名义金额;默认 `50.0`。 | +| `IBKR_MIN_ORDER_NOTIONAL_USD` | 否 | 限价买入的最小名义金额;默认 `50.0`。 | | `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Cloud Run 建议必填 | 账号组配置 JSON 在 Secret Manager 里的密钥名。生产环境推荐使用。 | | `IB_ACCOUNT_GROUP_CONFIG_JSON` | 否 | 本地开发用的账号组配置 JSON fallback。不建议在生产 Cloud Run 直接使用。 | | `TELEGRAM_TOKEN` | 是 | Telegram 机器人 Token。Cloud Run 上更推荐走 Secret Manager 引用,不要直接写成明文 env。 | diff --git a/application/execution_service.py b/application/execution_service.py index c7a5b7e..a746859 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -19,9 +19,6 @@ ) -MIN_FRACTIONAL_ORDER_QUANTITY = 0.01 - - def get_market_prices( ib, symbols, @@ -433,10 +430,6 @@ 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, @@ -549,7 +542,6 @@ 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()) @@ -658,7 +650,6 @@ 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), @@ -709,18 +700,6 @@ 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) @@ -769,18 +748,6 @@ 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) @@ -803,9 +770,6 @@ 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}" @@ -914,18 +878,6 @@ 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"}) @@ -941,18 +893,6 @@ 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 @@ -1028,18 +968,6 @@ 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/runtime_config_support.py b/runtime_config_support.py index 9cd512e..8d4781c 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -10,7 +10,6 @@ first_non_empty, resolve_bool_value, resolve_float_env, - resolve_quantity_step_env, resolve_strategy_runtime_path_settings, ) from quant_platform_kit.common.runtime_target import ( @@ -146,13 +145,7 @@ def load_platform_runtime_settings( strategy_config_source=runtime_paths.strategy_config_source, reconciliation_output_path=runtime_paths.reconciliation_output_path, dry_run_only=resolve_bool_value(os.getenv("IBKR_DRY_RUN_ONLY")), - quantity_step=resolve_quantity_step_env( - os.environ, - step_env="IBKR_ORDER_QUANTITY_STEP", - fractional_env="IBKR_FRACTIONAL_SHARES_ENABLED", - fractional_default=False, - fractional_step=0.0001, - ), + quantity_step=1.0, min_order_notional=resolve_float_env( os.environ, "IBKR_MIN_ORDER_NOTIONAL_USD", diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 016aa55..46f16d2 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -1,5 +1,4 @@ from types import SimpleNamespace -import math from application.execution_service import check_order_submitted, execute_rebalance from quant_platform_kit.common.models import OrderIntent @@ -213,115 +212,6 @@ def accountValues(self): raise AssertionError("expected ValueError") -def test_execute_rebalance_can_submit_fractional_buy_when_quantity_step_allows(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=500.0) for symbol in symbols} - - monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) - - _trade_logs, summary = execute_rebalance( - FakeIB(), - {"VOO": 0.15}, - {}, - {"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=["VOO"], - strategy_profile="global_etf_rotation", - signal_metadata=_signal_metadata({"VOO": 0.15}, risk_symbols=("VOO",), trade_date="2026-04-01"), - dry_run_only=False, - cash_reserve_ratio=0.0, - rebalance_threshold_ratio=0.02, - limit_buy_premium=1.005, - quantity_step=0.0001, - min_order_notional=50.0, - sell_settle_delay_sec=0, - execution_lock_dir=tmp_path, - return_summary=True, - ) - - assert summary["execution_status"] == "executed" - assert len(submitted) == 1 - 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): diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index f06bf6c..cab1f71 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -193,16 +193,15 @@ def test_load_platform_runtime_settings_supports_explicit_group_config_values(mo assert settings.notify_lang == "zh" -def test_load_platform_runtime_settings_supports_fractional_quantity_step(monkeypatch): +def test_load_platform_runtime_settings_uses_whole_share_quantity_step(monkeypatch): monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE)) monkeypatch.setenv("ACCOUNT_GROUP", "paper") monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) - monkeypatch.setenv("IBKR_FRACTIONAL_SHARES_ENABLED", "true") monkeypatch.setenv("IBKR_MIN_ORDER_NOTIONAL_USD", "5") settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") - assert settings.quantity_step == 0.0001 + assert settings.quantity_step == 1.0 assert settings.min_order_notional == 5.0