Skip to content

Commit 5d0cf68

Browse files
committed
Gate IBKR fractional quantity sizing
1 parent d87b247 commit 5d0cf68

9 files changed

Lines changed: 167 additions & 21 deletions

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,9 @@ IBKR_FEATURE_SNAPSHOT_PATH=/var/data/tech_communication_pullback_enhancement_fea
157157
IBKR_FEATURE_SNAPSHOT_MANIFEST_PATH=/var/manifests/tech_communication_pullback_enhancement_feature_snapshot_latest.csv.manifest.json
158158
# IBKR_STRATEGY_CONFIG_PATH is optional; the bundled canonical default is used when unset.
159159
IBKR_DRY_RUN_ONLY=true
160+
# Keep whole-share sizing unless fractional API support has been verified for this account/API path.
161+
# IBKR_FRACTIONAL_SHARES_ENABLED=true
162+
# IBKR_ORDER_QUANTITY_STEP=0.000001
160163
GLOBAL_TELEGRAM_CHAT_ID=<telegram-chat-id>
161164
NOTIFY_LANG=zh
162165
```

application/execution_service.py

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
from typing import Any
1212

1313
import pandas as pd
14+
from quant_platform_kit.common.quantity import (
15+
floor_to_quantity_step,
16+
format_quantity,
17+
normalize_order_quantity,
18+
)
1419

1520

1621
def get_market_prices(
@@ -36,7 +41,7 @@ def check_order_submitted(report, *, translator):
3641
"order_filled",
3742
symbol=report.symbol,
3843
side=report.side,
39-
qty=int(report.filled_quantity or report.quantity),
44+
qty=format_quantity(report.filled_quantity or report.quantity),
4045
price=f"{float(report.average_fill_price or 0.0):.2f}",
4146
order_id=order_id,
4247
),
@@ -48,8 +53,8 @@ def check_order_submitted(report, *, translator):
4853
"order_partial",
4954
symbol=report.symbol,
5055
side=report.side,
51-
executed=int(report.filled_quantity or 0),
52-
qty=int(report.quantity or 0),
56+
executed=format_quantity(report.filled_quantity or 0),
57+
qty=format_quantity(report.quantity or 0),
5358
price=f"{float(report.average_fill_price or 0.0):.2f}",
5459
order_id=order_id,
5560
),
@@ -348,6 +353,10 @@ def _build_target_diff_rows(
348353
return rows
349354

350355

356+
def _floor_order_quantity(quantity, *, quantity_step):
357+
return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step))
358+
359+
351360
def _finalize_result(trade_logs, execution_summary, *, return_summary: bool):
352361
if return_summary:
353362
return trade_logs, execution_summary
@@ -375,6 +384,8 @@ def execute_rebalance(
375384
rebalance_threshold_ratio,
376385
limit_buy_premium,
377386
sell_settle_delay_sec,
387+
quantity_step=1.0,
388+
min_order_notional=50.0,
378389
execution_lock_dir=None,
379390
return_summary=False,
380391
):
@@ -425,6 +436,8 @@ def execute_rebalance(
425436
reserved = equity * cash_reserve_ratio
426437
investable = equity - reserved
427438
threshold = equity * rebalance_threshold_ratio
439+
order_quantity_step = float(quantity_step or 1.0)
440+
minimum_order_notional = max(0.0, float(min_order_notional or 0.0))
428441
execution_summary["cash_reserve_dollars"] = float(reserved)
429442

430443
all_symbols = set(target_weights.keys()) | set(positions.keys())
@@ -544,7 +557,11 @@ def execute_rebalance(
544557
if not price:
545558
missing_price_symbols.append(symbol)
546559
continue
547-
if int((current - target) / price) > 0:
560+
qty = _floor_order_quantity(
561+
(current - target) / price,
562+
quantity_step=order_quantity_step,
563+
)
564+
if qty > 0:
548565
has_sell_plan = True
549566
break
550567
quantity_zero_symbols.append(symbol)
@@ -566,11 +583,15 @@ def execute_rebalance(
566583
if buy_value <= 0:
567584
insufficient_buying_power_symbols.append(symbol)
568585
continue
569-
if buy_value < 50:
586+
if buy_value < minimum_order_notional:
570587
min_notional_symbols.append(symbol)
571588
continue
572589
limit_price = round(price * limit_buy_premium, 2)
573-
qty = int(buy_value / limit_price) if limit_price > 0 else 0
590+
qty = (
591+
_floor_order_quantity(buy_value / limit_price, quantity_step=order_quantity_step)
592+
if limit_price > 0
593+
else 0
594+
)
574595
if qty > 0:
575596
has_buy_plan = True
576597
break
@@ -687,7 +708,10 @@ def execute_rebalance(
687708
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "missing_price"})
688709
execution_summary["skipped_reasons"].append(f"missing_price:{symbol}")
689710
continue
690-
qty = int(sell_value / price)
711+
qty = _floor_order_quantity(
712+
sell_value / price,
713+
quantity_step=order_quantity_step,
714+
)
691715
if qty <= 0:
692716
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "quantity_zero"})
693717
continue
@@ -696,7 +720,7 @@ def execute_rebalance(
696720
execution_summary["orders_submitted"].append(
697721
{"symbol": symbol, "side": "sell", "quantity": qty, "status": "dry_run"}
698722
)
699-
trade_logs.append(f"DRY_RUN sell {symbol} {qty}")
723+
trade_logs.append(f"DRY_RUN sell {symbol} {format_quantity(qty)}")
700724
continue
701725
report = submit_order_intent(
702726
ib,
@@ -720,7 +744,7 @@ def execute_rebalance(
720744
else:
721745
execution_summary["orders_skipped"].append({**order_payload, "reason": status or "submit_failed"})
722746
execution_summary["skipped_reasons"].append(f"submit_failed:{symbol}:{status or 'unknown'}")
723-
trade_logs.append(translator("market_sell", symbol=symbol, qty=qty) + f" {status_msg}")
747+
trade_logs.append(translator("market_sell", symbol=symbol, qty=format_quantity(qty)) + f" {status_msg}")
724748
if ok:
725749
sell_executed = True
726750

@@ -741,12 +765,15 @@ def execute_rebalance(
741765
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "missing_price"})
742766
execution_summary["skipped_reasons"].append(f"missing_price:{symbol}")
743767
continue
744-
if buy_value < 50:
768+
if buy_value < minimum_order_notional:
745769
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "min_notional"})
746770
continue
747771

748772
limit_price = round(price * limit_buy_premium, 2)
749-
qty = int(buy_value / limit_price)
773+
qty = _floor_order_quantity(
774+
buy_value / limit_price,
775+
quantity_step=order_quantity_step,
776+
)
750777
if qty <= 0:
751778
execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "quantity_zero"})
752779
continue
@@ -761,7 +788,7 @@ def execute_rebalance(
761788
"status": "dry_run",
762789
}
763790
)
764-
trade_logs.append(f"DRY_RUN buy {symbol} {qty} @{limit_price:.2f}")
791+
trade_logs.append(f"DRY_RUN buy {symbol} {format_quantity(qty)} @{limit_price:.2f}")
765792
buying_power -= qty * limit_price
766793
continue
767794
report = submit_order_intent(
@@ -795,7 +822,7 @@ def execute_rebalance(
795822
execution_summary["orders_skipped"].append({**order_payload, "reason": status or "submit_failed"})
796823
execution_summary["skipped_reasons"].append(f"submit_failed:{symbol}:{status or 'unknown'}")
797824
trade_logs.append(
798-
translator("limit_buy", symbol=symbol, qty=qty, price=f"{limit_price:.2f}") + f" {status_msg}"
825+
translator("limit_buy", symbol=symbol, qty=format_quantity(qty), price=f"{limit_price:.2f}") + f" {status_msg}"
799826
)
800827
if ok:
801828
buying_power -= qty * limit_price

application/rebalance_service.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from notifications.events import NotificationPublisher
1717
from notifications import renderers as notification_renderers
1818
from quant_platform_kit.common.models import PortfolioSnapshot, Position
19+
from quant_platform_kit.common.quantity import format_quantity
1920
from quant_platform_kit.common.notification_localization import (
2021
localize_notification_text as _base_localize_notification_text,
2122
translator_uses_zh as _base_translator_uses_zh,
@@ -180,9 +181,9 @@ def _summarize_orders(orders, *, limit: int = 3) -> str:
180181
preview = []
181182
for order in orders[:limit]:
182183
symbol = str(order.get("symbol") or "").strip().upper()
183-
quantity = int(order.get("quantity") or 0)
184+
quantity = float(order.get("quantity") or 0.0)
184185
if symbol and quantity > 0:
185-
preview.append(f"{symbol} {quantity}")
186+
preview.append(f"{symbol} {format_quantity(quantity)}")
186187
elif symbol:
187188
preview.append(symbol)
188189
remaining = len(orders) - len(preview)
@@ -378,7 +379,7 @@ def build_dashboard(
378379
qty = positions[symbol]["quantity"]
379380
avg = positions[symbol]["avg_cost"]
380381
market_value = qty * avg
381-
position_lines.append(f" - {symbol}: {qty}股 | ${market_value:,.2f}")
382+
position_lines.append(f" - {symbol}: {format_quantity(qty)}股 | ${market_value:,.2f}")
382383
position_text = "\n".join(position_lines) if position_lines else translator("empty_positions")
383384
allocation = _resolve_weight_allocation(signal_metadata, required=False)
384385
target_lines = []
@@ -499,7 +500,7 @@ def _snapshot_to_portfolio_view(snapshot) -> tuple[dict[str, dict[str, float | i
499500
positions = {}
500501
for position in getattr(snapshot, "positions", ()) or ():
501502
positions[str(position.symbol).strip().upper()] = {
502-
"quantity": int(position.quantity),
503+
"quantity": float(position.quantity),
503504
"avg_cost": float(position.average_cost or 0.0),
504505
}
505506
account_values = {

application/runtime_broker_adapters.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ class IBKRRuntimeBrokerAdapters:
3838
cash_reserve_ratio: float
3939
rebalance_threshold_ratio: float
4040
limit_buy_premium: float
41+
quantity_step: float
42+
min_order_notional: float
4143
sell_settle_delay_sec: float
4244
separator: str
4345
strategy_display_name: str
@@ -84,7 +86,7 @@ def get_current_portfolio(self, ib):
8486
positions = {}
8587
for position in snapshot.positions:
8688
positions[position.symbol] = {
87-
"quantity": int(position.quantity),
89+
"quantity": float(position.quantity),
8890
"avg_cost": float(position.average_cost or 0.0),
8991
}
9092
account_values = {
@@ -151,6 +153,8 @@ def execute_rebalance(
151153
cash_reserve_ratio=self.cash_reserve_ratio,
152154
rebalance_threshold_ratio=self.rebalance_threshold_ratio,
153155
limit_buy_premium=self.limit_buy_premium,
156+
quantity_step=self.quantity_step,
157+
min_order_notional=self.min_order_notional,
154158
sell_settle_delay_sec=self.sell_settle_delay_sec,
155159
return_summary=True,
156160
)
@@ -234,6 +238,8 @@ def build_runtime_broker_adapters(
234238
cash_reserve_ratio: float,
235239
rebalance_threshold_ratio: float,
236240
limit_buy_premium: float,
241+
quantity_step: float,
242+
min_order_notional: float,
237243
sell_settle_delay_sec: float,
238244
separator: str,
239245
strategy_display_name: str,
@@ -267,6 +273,8 @@ def build_runtime_broker_adapters(
267273
cash_reserve_ratio=float(cash_reserve_ratio),
268274
rebalance_threshold_ratio=float(rebalance_threshold_ratio),
269275
limit_buy_premium=float(limit_buy_premium),
276+
quantity_step=float(quantity_step),
277+
min_order_notional=float(min_order_notional),
270278
sell_settle_delay_sec=float(sell_settle_delay_sec),
271279
separator=str(separator or ""),
272280
strategy_display_name=str(strategy_display_name or ""),

main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,8 @@ def build_broker_adapters():
315315
cash_reserve_ratio=CASH_RESERVE_RATIO,
316316
rebalance_threshold_ratio=REBALANCE_THRESHOLD_RATIO,
317317
limit_buy_premium=LIMIT_BUY_PREMIUM,
318+
quantity_step=RUNTIME_SETTINGS.quantity_step,
319+
min_order_notional=RUNTIME_SETTINGS.min_order_notional,
318320
sell_settle_delay_sec=SELL_SETTLE_DELAY_SEC,
319321
separator=SEPARATOR,
320322
strategy_display_name=strategy_display_name,

notifications/renderers.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import re
77

88
from notifications.events import RenderedNotification
9+
from quant_platform_kit.common.quantity import format_quantity
910
from quant_platform_kit.common.notification_localization import (
1011
localize_notification_text as _base_localize_notification_text,
1112
translator_uses_zh as _base_translator_uses_zh,
@@ -118,9 +119,9 @@ def _summarize_orders(orders, *, limit: int = 3) -> str:
118119
preview = []
119120
for order in orders[:limit]:
120121
symbol = str(order.get("symbol") or "").strip().upper()
121-
quantity = int(order.get("quantity") or 0)
122+
quantity = float(order.get("quantity") or 0.0)
122123
if symbol and quantity > 0:
123-
preview.append(f"{symbol} {quantity}")
124+
preview.append(f"{symbol} {format_quantity(quantity)}")
124125
elif symbol:
125126
preview.append(symbol)
126127
remaining = len(orders) - len(preview)
@@ -316,7 +317,7 @@ def build_dashboard(
316317
qty = positions[symbol]["quantity"]
317318
avg = positions[symbol]["avg_cost"]
318319
market_value = qty * avg
319-
position_lines.append(f" - {symbol}: {qty}股 | ${market_value:,.2f}")
320+
position_lines.append(f" - {symbol}: {format_quantity(qty)}股 | ${market_value:,.2f}")
320321
position_text = "\n".join(position_lines) if position_lines else translator("empty_positions")
321322
allocation = _resolve_weight_allocation(signal_metadata, required=False)
322323
target_lines = []

runtime_config_support.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ class PlatformRuntimeSettings:
5252
strategy_config_source: str | None
5353
reconciliation_output_path: str | None
5454
dry_run_only: bool
55+
quantity_step: float
56+
min_order_notional: float
5557
account_group: str
5658
service_name: str | None
5759
account_ids: tuple[str, ...]
@@ -135,6 +137,12 @@ def load_platform_runtime_settings(
135137
strategy_config_source=runtime_paths.strategy_config_source,
136138
reconciliation_output_path=runtime_paths.reconciliation_output_path,
137139
dry_run_only=resolve_bool_value(os.getenv("IBKR_DRY_RUN_ONLY")),
140+
quantity_step=_quantity_step_env(
141+
step_env="IBKR_ORDER_QUANTITY_STEP",
142+
fractional_env="IBKR_FRACTIONAL_SHARES_ENABLED",
143+
fractional_default=False,
144+
),
145+
min_order_notional=_float_env("IBKR_MIN_ORDER_NOTIONAL_USD", default=50.0),
138146
account_group=account_group,
139147
service_name=group_config.service_name,
140148
account_ids=group_config.account_ids,
@@ -151,6 +159,36 @@ def resolve_strategy_profile(raw_value: str | None) -> str:
151159
).profile
152160

153161

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+
154192
def resolve_account_group(raw_value: str | None) -> str:
155193
value = (raw_value or "").strip()
156194
if not value:

0 commit comments

Comments
 (0)