From f81142196246a27895f2a4bc287d9bd219dbc28a Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 7 May 2026 05:16:59 +0800 Subject: [PATCH 1/3] Clean up IBKR fractional sizing support --- README.md | 7 +++++ application/execution_service.py | 40 +++++++++++++++++++++++---- requirements.txt | 2 +- runtime_config_support.py | 41 ++++++---------------------- tests/test_execution_service.py | 47 ++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 4994070..56bd1ce 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,9 @@ The selected `ACCOUNT_GROUP` is now the runtime identity. Keep broker-specific i | `STRATEGY_PROFILE` | Yes | Strategy profile selector. Supported `us_equity` values: `global_etf_rotation`, `russell_1000_multi_factor_defensive`, `tqqq_growth_income`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, `mega_cap_leader_rotation_top50_balanced` | | `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_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `false`; set `true` only after verifying fractional order support for this account/API path. | +| `IBKR_ORDER_QUANTITY_STEP` | No | Explicit order quantity step override; e.g. `1` for whole shares or `0.000001` 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. | | `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. | @@ -346,6 +349,10 @@ IBKR 账户 | `IBKR_CLIENT_ID_RETRY_OFFSET` | 否 | 每次重试时加到 `ib_client_id` 上的偏移量,用新的 client id 避开超时握手留下的卡住会话。默认 `100`。 | | `STRATEGY_PROFILE` | 是 | 策略档位选择。当前可用的 `us_equity` 值:`global_etf_rotation`、`russell_1000_multi_factor_defensive`、`tqqq_growth_income`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement`、`mega_cap_leader_rotation_top50_balanced` | | `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_FRACTIONAL_SHARES_ENABLED` | 否 | 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`。 | +| `IBKR_ORDER_QUANTITY_STEP` | 否 | 显式覆盖下单数量步进;如 `1` 表示整数股,`0.000001` 表示碎股数量步进。优先级高于 `IBKR_FRACTIONAL_SHARES_ENABLED`。 | +| `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 9ec0682..78f5c94 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -357,6 +357,31 @@ def _floor_order_quantity(quantity, *, quantity_step): return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step)) +def _sell_order_quantity( + *, + current_value, + target_value, + price, + position_quantity, + quantity_step, +): + held_quantity = max(0.0, float(position_quantity or 0.0)) + if held_quantity <= 0.0: + return 0 + + target = max(0.0, float(target_value or 0.0)) + if target <= 0.0: + return _floor_order_quantity(held_quantity, quantity_step=quantity_step) + + sell_value = max(0.0, float(current_value or 0.0) - target) + if sell_value <= 0.0 or float(price or 0.0) <= 0.0: + return 0 + return _floor_order_quantity( + min(sell_value / float(price), held_quantity), + quantity_step=quantity_step, + ) + + def _finalize_result(trade_logs, execution_summary, *, return_summary: bool): if return_summary: return trade_logs, execution_summary @@ -557,8 +582,11 @@ def execute_rebalance( if not price: missing_price_symbols.append(symbol) continue - qty = _floor_order_quantity( - (current - target) / price, + qty = _sell_order_quantity( + current_value=current, + target_value=target, + price=price, + position_quantity=positions.get(symbol, {}).get("quantity", 0), quantity_step=order_quantity_step, ) if qty > 0: @@ -702,14 +730,16 @@ def execute_rebalance( current = current_mv.get(symbol, 0) target = target_mv.get(symbol, 0) if current > target + threshold: - sell_value = current - target price = prices.get(symbol) if not price: execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "missing_price"}) execution_summary["skipped_reasons"].append(f"missing_price:{symbol}") continue - qty = _floor_order_quantity( - sell_value / price, + qty = _sell_order_quantity( + current_value=current, + target_value=target, + price=price, + position_quantity=positions.get(symbol, {}).get("quantity", 0), quantity_step=order_quantity_step, ) if qty <= 0: diff --git a/requirements.txt b/requirements.txt index 808e8cf..a88993d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@c24d4c52e84c8c696006532590b15e9be92c8d89 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@08ed04ae9796f54a2218ffb700f97e0e33bf312f us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c0cf04f002fd6348c9af7ebd95c9c0ad03c36bcd pandas numpy diff --git a/runtime_config_support.py b/runtime_config_support.py index 369df61..a82efd7 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -9,6 +9,8 @@ from quant_platform_kit.common.runtime_config import ( first_non_empty, resolve_bool_value, + resolve_float_env, + resolve_quantity_step_env, resolve_strategy_runtime_path_settings, ) from strategy_registry import ( @@ -137,12 +139,17 @@ 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=_quantity_step_env( + quantity_step=resolve_quantity_step_env( + os.environ, step_env="IBKR_ORDER_QUANTITY_STEP", fractional_env="IBKR_FRACTIONAL_SHARES_ENABLED", fractional_default=False, ), - min_order_notional=_float_env("IBKR_MIN_ORDER_NOTIONAL_USD", default=50.0), + min_order_notional=resolve_float_env( + os.environ, + "IBKR_MIN_ORDER_NOTIONAL_USD", + default=50.0, + ), account_group=account_group, service_name=group_config.service_name, account_ids=group_config.account_ids, @@ -159,36 +166,6 @@ def resolve_strategy_profile(raw_value: str | None) -> str: ).profile -def _optional_float_env(name: str) -> float | None: - raw_value = os.getenv(name) - if raw_value is None or raw_value.strip() == "": - return None - return float(raw_value) - - -def _float_env(name: str, *, default: float) -> float: - value = _optional_float_env(name) - return float(default) if value is None else value - - -def _quantity_step_env( - *, - step_env: str, - fractional_env: str, - fractional_default: bool, -) -> float: - explicit_step = _optional_float_env(step_env) - if explicit_step is not None: - return explicit_step - raw_enabled = os.getenv(fractional_env) - fractional_enabled = ( - fractional_default - if raw_enabled is None - else resolve_bool_value(raw_enabled) - ) - return 0.000001 if fractional_enabled else 1.0 - - def resolve_account_group(raw_value: str | None) -> str: value = (raw_value or "").strip() if not value: diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 6854125..5b682e3 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -180,6 +180,53 @@ def fake_fetch_quote_snapshots(_ib, symbols): assert math.isclose(submitted[0].quantity, 0.298507, rel_tol=0.0, abs_tol=0.000001) +def test_execute_rebalance_zero_target_sell_uses_position_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") + + monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None) + + _trade_logs, summary = execute_rebalance( + FakeIB(), + {"VOO": 0.0}, + {"VOO": {"quantity": 2}}, + {"equity": 327.88, "buying_power": 1000.0}, + fetch_quote_snapshots=lambda *_args, **_kwargs: {"VOO": SimpleNamespace(last_price=165.85)}, + 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.0}, 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=1.0, + sell_settle_delay_sec=0, + execution_lock_dir=tmp_path, + return_summary=True, + ) + + assert summary["execution_status"] == "executed" + assert len(submitted) == 1 + assert submitted[0].side == "sell" + assert submitted[0].quantity == 2 + + def test_execute_rebalance_skips_when_pending_orders_exist(): class FakeIB: def openTrades(self): From 4a11b9a4dc93dac453221a81089e2cbfca13cb0b Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 7 May 2026 05:24:56 +0800 Subject: [PATCH 2/3] Pin UsEquityStrategies to runtime helper update --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a88993d..76ae5ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@08ed04ae9796f54a2218ffb700f97e0e33bf312f -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c0cf04f002fd6348c9af7ebd95c9c0ad03c36bcd +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@d3ef5b9ce1f1414af073e21db1d498b3ab55ab1f pandas numpy requests From dd25a9b73e760b5e6b90fea7d84c53de865c1b60 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 7 May 2026 05:28:15 +0800 Subject: [PATCH 3/3] Pin UsEquityStrategies runtime helper release --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 76ae5ae..69e09d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@08ed04ae9796f54a2218ffb700f97e0e33bf312f -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@d3ef5b9ce1f1414af073e21db1d498b3ab55ab1f +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c9ec484c9a12cdffedf7d87c8906b93b21f50b1c pandas numpy requests