Skip to content

Commit 14f2f18

Browse files
authored
Disable IBKR fractional API support (#63)
1 parent e7b3ff6 commit 14f2f18

5 files changed

Lines changed: 6 additions & 202 deletions

File tree

README.md

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +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. Orders that floor below roughly `0.01` shares are skipped because the IBKR API rejects them. |
107-
| `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`. |
108-
| `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for fractional sizing; defaults to `50.0`. |
106+
| `IBKR_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for limit buys; defaults to `50.0`. |
109107
| `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Yes for Cloud Run | Secret Manager secret name for account-group config JSON. Recommended production source. |
110108
| `IB_ACCOUNT_GROUP_CONFIG_JSON` | No | Local/dev JSON fallback for account-group config. Not recommended for production Cloud Run. |
111109
| `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
160158
IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=/var/manifests/tech_communication_pullback_enhancement_feature_snapshot_latest.csv.manifest.json
161159
# IBKR_STRATEGY_CONFIG_PATH is optional; the bundled canonical default is used when unset.
162160
IBKR_DRY_RUN_ONLY=true
163-
# Keep whole-share sizing unless fractional API support has been verified for this account/API path.
164-
# IBKR_FRACTIONAL_SHARES_ENABLED=true
165-
# IBKR_ORDER_QUANTITY_STEP=0.0001
161+
# IBKR orders run on whole shares only.
166162
GLOBAL_TELEGRAM_CHAT_ID=<telegram-chat-id>
167163
NOTIFY_LANG=zh
168164
```
@@ -343,9 +339,7 @@ IBKR 账户
343339
| `ACCOUNT_GROUP` || 账号组选择器,每个部署都要显式设置。 |
344340
| `IBKR_FEATURE_SNAPSHOT_PATH` | 条件必填 | `russell_1000_multi_factor_defensive``tech_communication_pullback_enhancement``mega_cap_leader_rotation_top50_balanced` 等快照策略需要。指向最新特征快照文件(`.csv``.json``.jsonl``.parquet`)。 |
345341
| `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` || 可选的 IBKR 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`|
346-
| `IBKR_FRACTIONAL_SHARES_ENABLED` || 默认 `false`;只有确认当前账户/API 路径支持碎股单后再设为 `true`。四舍五入后低于约 `0.01` 股的订单会跳过,因为 IBKR API 会拒单。 |
347-
| `IBKR_ORDER_QUANTITY_STEP` || 显式覆盖下单数量步进;如 `1` 表示整数股,`0.0001` 表示碎股数量步进。优先级高于 `IBKR_FRACTIONAL_SHARES_ENABLED`|
348-
| `IBKR_MIN_ORDER_NOTIONAL_USD` || 碎股买入的最小名义金额;默认 `50.0`|
342+
| `IBKR_MIN_ORDER_NOTIONAL_USD` || 限价买入的最小名义金额;默认 `50.0`|
349343
| `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME` | Cloud Run 建议必填 | 账号组配置 JSON 在 Secret Manager 里的密钥名。生产环境推荐使用。 |
350344
| `IB_ACCOUNT_GROUP_CONFIG_JSON` || 本地开发用的账号组配置 JSON fallback。不建议在生产 Cloud Run 直接使用。 |
351345
| `TELEGRAM_TOKEN` || Telegram 机器人 Token。Cloud Run 上更推荐走 Secret Manager 引用,不要直接写成明文 env。 |

application/execution_service.py

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

2121

22-
MIN_FRACTIONAL_ORDER_QUANTITY = 0.01
23-
24-
2522
def get_market_prices(
2623
ib,
2724
symbols,
@@ -433,10 +430,6 @@ def _floor_order_quantity(quantity, *, quantity_step):
433430
return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step))
434431

435432

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-
440433
def _sell_order_quantity(
441434
*,
442435
current_value,
@@ -549,7 +542,6 @@ def execute_rebalance(
549542
threshold = equity * rebalance_threshold_ratio
550543
order_quantity_step = float(quantity_step or 1.0)
551544
minimum_order_notional = max(0.0, float(min_order_notional or 0.0))
552-
minimum_supported_quantity = _minimum_supported_order_quantity(order_quantity_step)
553545
execution_summary["cash_reserve_dollars"] = float(reserved)
554546

555547
all_symbols = set(target_weights.keys()) | set(positions.keys())
@@ -658,7 +650,6 @@ def execute_rebalance(
658650
insufficient_buying_power_symbols: list[str] = []
659651
min_notional_symbols: list[str] = []
660652
quantity_zero_symbols: list[str] = []
661-
fractional_quantity_too_small_symbols: list[str] = []
662653
anticipated_buying_power = get_available_buying_power(
663654
ib,
664655
account_values.get("buying_power", 0),
@@ -709,18 +700,6 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
709700
quantity_step=order_quantity_step,
710701
)
711702
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
724703
has_sell_plan = True
725704
break
726705
quantity_zero_symbols.append(symbol)
@@ -769,18 +748,6 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
769748
else 0
770749
)
771750
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
784751
has_buy_plan = True
785752
break
786753
quantity_zero_symbols.append(symbol)
@@ -803,9 +770,6 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
803770
elif min_notional_symbols:
804771
symbols = ",".join(sorted(dict.fromkeys(min_notional_symbols)))
805772
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}"
809773
elif quantity_zero_symbols:
810774
symbols = ",".join(sorted(dict.fromkeys(quantity_zero_symbols)))
811775
reason = f"quantity_zero:{symbols}"
@@ -914,18 +878,6 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
914878
if qty <= 0:
915879
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "quantity_zero"})
916880
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
929881
elif current > target + threshold:
930882
if not price:
931883
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
941893
if qty <= 0:
942894
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "quantity_zero"})
943895
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
956896
else:
957897
continue
958898

@@ -1028,18 +968,6 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
1028968
if qty <= 0:
1029969
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "quantity_zero"})
1030970
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
1043971

1044972
if dry_run_only:
1045973
execution_summary["orders_submitted"].append(

runtime_config_support.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
first_non_empty,
1111
resolve_bool_value,
1212
resolve_float_env,
13-
resolve_quantity_step_env,
1413
resolve_strategy_runtime_path_settings,
1514
)
1615
from quant_platform_kit.common.runtime_target import (
@@ -146,13 +145,7 @@ def load_platform_runtime_settings(
146145
strategy_config_source=runtime_paths.strategy_config_source,
147146
reconciliation_output_path=runtime_paths.reconciliation_output_path,
148147
dry_run_only=resolve_bool_value(os.getenv("IBKR_DRY_RUN_ONLY")),
149-
quantity_step=resolve_quantity_step_env(
150-
os.environ,
151-
step_env="IBKR_ORDER_QUANTITY_STEP",
152-
fractional_env="IBKR_FRACTIONAL_SHARES_ENABLED",
153-
fractional_default=False,
154-
fractional_step=0.0001,
155-
),
148+
quantity_step=1.0,
156149
min_order_notional=resolve_float_env(
157150
os.environ,
158151
"IBKR_MIN_ORDER_NOTIONAL_USD",

tests/test_execution_service.py

Lines changed: 0 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from types import SimpleNamespace
2-
import math
32

43
from application.execution_service import check_order_submitted, execute_rebalance
54
from quant_platform_kit.common.models import OrderIntent
@@ -213,115 +212,6 @@ def accountValues(self):
213212
raise AssertionError("expected ValueError")
214213

215214

216-
def test_execute_rebalance_can_submit_fractional_buy_when_quantity_step_allows(monkeypatch, tmp_path):
217-
class FakeIB:
218-
def openTrades(self):
219-
return []
220-
221-
def fills(self):
222-
return []
223-
224-
def accountValues(self):
225-
return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="1000")]
226-
227-
submitted = []
228-
229-
def fake_submit_order_intent(_ib, intent):
230-
submitted.append(intent)
231-
return SimpleNamespace(broker_order_id="1", status="Submitted")
232-
233-
def fake_fetch_quote_snapshots(_ib, symbols):
234-
return {symbol: SimpleNamespace(last_price=500.0) for symbol in symbols}
235-
236-
monkeypatch.setattr("application.execution_service.time.sleep", lambda _seconds: None)
237-
238-
_trade_logs, summary = execute_rebalance(
239-
FakeIB(),
240-
{"VOO": 0.15},
241-
{},
242-
{"equity": 1000.0, "buying_power": 1000.0},
243-
fetch_quote_snapshots=fake_fetch_quote_snapshots,
244-
submit_order_intent=fake_submit_order_intent,
245-
order_intent_cls=OrderIntent,
246-
translator=translate,
247-
strategy_symbols=["VOO"],
248-
strategy_profile="global_etf_rotation",
249-
signal_metadata=_signal_metadata({"VOO": 0.15}, risk_symbols=("VOO",), trade_date="2026-04-01"),
250-
dry_run_only=False,
251-
cash_reserve_ratio=0.0,
252-
rebalance_threshold_ratio=0.02,
253-
limit_buy_premium=1.005,
254-
quantity_step=0.0001,
255-
min_order_notional=50.0,
256-
sell_settle_delay_sec=0,
257-
execution_lock_dir=tmp_path,
258-
return_summary=True,
259-
)
260-
261-
assert summary["execution_status"] == "executed"
262-
assert len(submitted) == 1
263-
assert math.isclose(submitted[0].quantity, 0.2985, rel_tol=0.0, abs_tol=1e-9)
264-
265-
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-
325215
def test_execute_rebalance_zero_target_sell_uses_position_quantity(monkeypatch, tmp_path):
326216
class FakeIB:
327217
def openTrades(self):

tests/test_runtime_config_support.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,16 +193,15 @@ def test_load_platform_runtime_settings_supports_explicit_group_config_values(mo
193193
assert settings.notify_lang == "zh"
194194

195195

196-
def test_load_platform_runtime_settings_supports_fractional_quantity_step(monkeypatch):
196+
def test_load_platform_runtime_settings_uses_whole_share_quantity_step(monkeypatch):
197197
monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE))
198198
monkeypatch.setenv("ACCOUNT_GROUP", "paper")
199199
monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON)
200-
monkeypatch.setenv("IBKR_FRACTIONAL_SHARES_ENABLED", "true")
201200
monkeypatch.setenv("IBKR_MIN_ORDER_NOTIONAL_USD", "5")
202201

203202
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
204203

205-
assert settings.quantity_step == 0.0001
204+
assert settings.quantity_step == 1.0
206205
assert settings.min_order_notional == 5.0
207206

208207

0 commit comments

Comments
 (0)