Skip to content

Commit f811421

Browse files
committed
Clean up IBKR fractional sizing support
1 parent 22562f5 commit f811421

5 files changed

Lines changed: 99 additions & 38 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ The selected `ACCOUNT_GROUP` is now the runtime identity. Keep broker-specific i
103103
| `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` |
104104
| `ACCOUNT_GROUP` | Yes | Account-group selector. Set explicitly for each deployment. |
105105
| `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`). |
106+
| `IBKR_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `false`; set `true` only after verifying fractional order support for this account/API path. |
107+
| `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`. |
108+
| `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for fractional sizing; defaults to `50.0`. |
106109
| `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Yes for Cloud Run | Secret Manager secret name for account-group config JSON. Recommended production source. |
107110
| `IB_ACCOUNT_GROUP_CONFIG_JSON` | No | Local/dev JSON fallback for account-group config. Not recommended for production Cloud Run. |
108111
| `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 账户
346349
| `IBKR_CLIENT_ID_RETRY_OFFSET` || 每次重试时加到 `ib_client_id` 上的偏移量,用新的 client id 避开超时握手留下的卡住会话。默认 `100`|
347350
| `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` |
348351
| `ACCOUNT_GROUP` || 账号组选择器,每个部署都要显式设置。 |
352+
| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive``tech_communication_pullback_enhancement``mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv``.json``.jsonl``.parquet`)。 |
353+
| `IBKR_FRACTIONAL_SHARES_ENABLED` || 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`|
354+
| `IBKR_ORDER_QUANTITY_STEP` || 显式覆盖下单数量步进;如 `1` 表示整数股,`0.000001` 表示碎股数量步进。优先级高于 `IBKR_FRACTIONAL_SHARES_ENABLED`|
355+
| `IBKR_MIN_ORDER_NOTIONAL_USD` || 碎股买入的最小名义金额;默认 `50.0`|
349356
| `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Cloud Run 建议必填 | 账号组配置 JSON 在 Secret Manager 里的密钥名。生产环境推荐使用。 |
350357
| `IB_ACCOUNT_GROUP_CONFIG_JSON` || 本地开发用的账号组配置 JSON fallback。不建议在生产 Cloud Run 直接使用。 |
351358
| `TELEGRAM_TOKEN` || Telegram 机器人 Token。Cloud Run 上更推荐走 Secret Manager 引用,不要直接写成明文 env。 |

application/execution_service.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,31 @@ def _floor_order_quantity(quantity, *, quantity_step):
357357
return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step))
358358

359359

360+
def _sell_order_quantity(
361+
*,
362+
current_value,
363+
target_value,
364+
price,
365+
position_quantity,
366+
quantity_step,
367+
):
368+
held_quantity = max(0.0, float(position_quantity or 0.0))
369+
if held_quantity <= 0.0:
370+
return 0
371+
372+
target = max(0.0, float(target_value or 0.0))
373+
if target <= 0.0:
374+
return _floor_order_quantity(held_quantity, quantity_step=quantity_step)
375+
376+
sell_value = max(0.0, float(current_value or 0.0) - target)
377+
if sell_value <= 0.0 or float(price or 0.0) <= 0.0:
378+
return 0
379+
return _floor_order_quantity(
380+
min(sell_value / float(price), held_quantity),
381+
quantity_step=quantity_step,
382+
)
383+
384+
360385
def _finalize_result(trade_logs, execution_summary, *, return_summary: bool):
361386
if return_summary:
362387
return trade_logs, execution_summary
@@ -557,8 +582,11 @@ def execute_rebalance(
557582
if not price:
558583
missing_price_symbols.append(symbol)
559584
continue
560-
qty = _floor_order_quantity(
561-
(current - target) / price,
585+
qty = _sell_order_quantity(
586+
current_value=current,
587+
target_value=target,
588+
price=price,
589+
position_quantity=positions.get(symbol, {}).get("quantity", 0),
562590
quantity_step=order_quantity_step,
563591
)
564592
if qty > 0:
@@ -702,14 +730,16 @@ def execute_rebalance(
702730
current = current_mv.get(symbol, 0)
703731
target = target_mv.get(symbol, 0)
704732
if current > target + threshold:
705-
sell_value = current - target
706733
price = prices.get(symbol)
707734
if not price:
708735
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "missing_price"})
709736
execution_summary["skipped_reasons"].append(f"missing_price:{symbol}")
710737
continue
711-
qty = _floor_order_quantity(
712-
sell_value / price,
738+
qty = _sell_order_quantity(
739+
current_value=current,
740+
target_value=target,
741+
price=price,
742+
position_quantity=positions.get(symbol, {}).get("quantity", 0),
713743
quantity_step=order_quantity_step,
714744
)
715745
if qty <= 0:

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
flask
22
gunicorn
3-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@c24d4c52e84c8c696006532590b15e9be92c8d89
3+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@08ed04ae9796f54a2218ffb700f97e0e33bf312f
44
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c0cf04f002fd6348c9af7ebd95c9c0ad03c36bcd
55
pandas
66
numpy

runtime_config_support.py

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from quant_platform_kit.common.runtime_config import (
1010
first_non_empty,
1111
resolve_bool_value,
12+
resolve_float_env,
13+
resolve_quantity_step_env,
1214
resolve_strategy_runtime_path_settings,
1315
)
1416
from strategy_registry import (
@@ -137,12 +139,17 @@ def load_platform_runtime_settings(
137139
strategy_config_source=runtime_paths.strategy_config_source,
138140
reconciliation_output_path=runtime_paths.reconciliation_output_path,
139141
dry_run_only=resolve_bool_value(os.getenv("IBKR_DRY_RUN_ONLY")),
140-
quantity_step=_quantity_step_env(
142+
quantity_step=resolve_quantity_step_env(
143+
os.environ,
141144
step_env="IBKR_ORDER_QUANTITY_STEP",
142145
fractional_env="IBKR_FRACTIONAL_SHARES_ENABLED",
143146
fractional_default=False,
144147
),
145-
min_order_notional=_float_env("IBKR_MIN_ORDER_NOTIONAL_USD", default=50.0),
148+
min_order_notional=resolve_float_env(
149+
os.environ,
150+
"IBKR_MIN_ORDER_NOTIONAL_USD",
151+
default=50.0,
152+
),
146153
account_group=account_group,
147154
service_name=group_config.service_name,
148155
account_ids=group_config.account_ids,
@@ -159,36 +166,6 @@ def resolve_strategy_profile(raw_value: str | None) -> str:
159166
).profile
160167

161168

162-
def _optional_float_env(name: str) -> float | None:
163-
raw_value = os.getenv(name)
164-
if raw_value is None or raw_value.strip() == "":
165-
return None
166-
return float(raw_value)
167-
168-
169-
def _float_env(name: str, *, default: float) -> float:
170-
value = _optional_float_env(name)
171-
return float(default) if value is None else value
172-
173-
174-
def _quantity_step_env(
175-
*,
176-
step_env: str,
177-
fractional_env: str,
178-
fractional_default: bool,
179-
) -> float:
180-
explicit_step = _optional_float_env(step_env)
181-
if explicit_step is not None:
182-
return explicit_step
183-
raw_enabled = os.getenv(fractional_env)
184-
fractional_enabled = (
185-
fractional_default
186-
if raw_enabled is None
187-
else resolve_bool_value(raw_enabled)
188-
)
189-
return 0.000001 if fractional_enabled else 1.0
190-
191-
192169
def resolve_account_group(raw_value: str | None) -> str:
193170
value = (raw_value or "").strip()
194171
if not value:

tests/test_execution_service.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,53 @@ def fake_fetch_quote_snapshots(_ib, symbols):
180180
assert math.isclose(submitted[0].quantity, 0.298507, rel_tol=0.0, abs_tol=0.000001)
181181

182182

183+
def test_execute_rebalance_zero_target_sell_uses_position_quantity(monkeypatch, tmp_path):
184+
class FakeIB:
185+
def openTrades(self):
186+
return []
187+
188+
def fills(self):
189+
return []
190+
191+
def accountValues(self):
192+
return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="1000")]
193+
194+
submitted = []
195+
196+
def fake_submit_order_intent(_ib, intent):
197+
submitted.append(intent)
198+
return SimpleNamespace(broker_order_id="1", status="Submitted")
199+
200+
monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None)
201+
202+
_trade_logs, summary = execute_rebalance(
203+
FakeIB(),
204+
{"VOO": 0.0},
205+
{"VOO": {"quantity": 2}},
206+
{"equity": 327.88, "buying_power": 1000.0},
207+
fetch_quote_snapshots=lambda *_args, **_kwargs: {"VOO": SimpleNamespace(last_price=165.85)},
208+
submit_order_intent=fake_submit_order_intent,
209+
order_intent_cls=OrderIntent,
210+
translator=translate,
211+
strategy_symbols=["VOO"],
212+
strategy_profile="global_etf_rotation",
213+
signal_metadata=_signal_metadata({"VOO": 0.0}, risk_symbols=("VOO",), trade_date="2026-04-01"),
214+
dry_run_only=False,
215+
cash_reserve_ratio=0.0,
216+
rebalance_threshold_ratio=0.02,
217+
limit_buy_premium=1.005,
218+
quantity_step=1.0,
219+
sell_settle_delay_sec=0,
220+
execution_lock_dir=tmp_path,
221+
return_summary=True,
222+
)
223+
224+
assert summary["execution_status"] == "executed"
225+
assert len(submitted) == 1
226+
assert submitted[0].side == "sell"
227+
assert submitted[0].quantity == 2
228+
229+
183230
def test_execute_rebalance_skips_when_pending_orders_exist():
184231
class FakeIB:
185232
def openTrades(self):

0 commit comments

Comments
 (0)