diff --git a/README.md b/README.md index 7031342..4994070 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,9 @@ 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.000001 GLOBAL_TELEGRAM_CHAT_ID= NOTIFY_LANG=zh ``` diff --git a/application/execution_service.py b/application/execution_service.py index 67aaccb..9ec0682 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -11,6 +11,11 @@ from typing import Any import pandas as pd +from quant_platform_kit.common.quantity import ( + floor_to_quantity_step, + format_quantity, + normalize_order_quantity, +) def get_market_prices( @@ -36,7 +41,7 @@ def check_order_submitted(report, *, translator): "order_filled", symbol=report.symbol, side=report.side, - qty=int(report.filled_quantity or report.quantity), + qty=format_quantity(report.filled_quantity or report.quantity), price=f"{float(report.average_fill_price or 0.0):.2f}", order_id=order_id, ), @@ -48,8 +53,8 @@ def check_order_submitted(report, *, translator): "order_partial", symbol=report.symbol, side=report.side, - executed=int(report.filled_quantity or 0), - qty=int(report.quantity or 0), + executed=format_quantity(report.filled_quantity or 0), + qty=format_quantity(report.quantity or 0), price=f"{float(report.average_fill_price or 0.0):.2f}", order_id=order_id, ), @@ -348,6 +353,10 @@ def _build_target_diff_rows( return rows +def _floor_order_quantity(quantity, *, quantity_step): + return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step)) + + def _finalize_result(trade_logs, execution_summary, *, return_summary: bool): if return_summary: return trade_logs, execution_summary @@ -375,6 +384,8 @@ def execute_rebalance( rebalance_threshold_ratio, limit_buy_premium, sell_settle_delay_sec, + quantity_step=1.0, + min_order_notional=50.0, execution_lock_dir=None, return_summary=False, ): @@ -425,6 +436,8 @@ def execute_rebalance( reserved = equity * cash_reserve_ratio investable = equity - reserved 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)) execution_summary["cash_reserve_dollars"] = float(reserved) all_symbols = set(target_weights.keys()) | set(positions.keys()) @@ -544,7 +557,11 @@ def execute_rebalance( if not price: missing_price_symbols.append(symbol) continue - if int((current - target) / price) > 0: + qty = _floor_order_quantity( + (current - target) / price, + quantity_step=order_quantity_step, + ) + if qty > 0: has_sell_plan = True break quantity_zero_symbols.append(symbol) @@ -566,11 +583,15 @@ def execute_rebalance( if buy_value <= 0: insufficient_buying_power_symbols.append(symbol) continue - if buy_value < 50: + if buy_value < minimum_order_notional: min_notional_symbols.append(symbol) continue limit_price = round(price * limit_buy_premium, 2) - qty = int(buy_value / limit_price) if limit_price > 0 else 0 + qty = ( + _floor_order_quantity(buy_value / limit_price, quantity_step=order_quantity_step) + if limit_price > 0 + else 0 + ) if qty > 0: has_buy_plan = True break @@ -687,7 +708,10 @@ def execute_rebalance( execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "missing_price"}) execution_summary["skipped_reasons"].append(f"missing_price:{symbol}") continue - qty = int(sell_value / price) + qty = _floor_order_quantity( + sell_value / price, + quantity_step=order_quantity_step, + ) if qty <= 0: execution_summary["orders_skipped"].append({"symbol": symbol, "side": "sell", "reason": "quantity_zero"}) continue @@ -696,7 +720,7 @@ def execute_rebalance( execution_summary["orders_submitted"].append( {"symbol": symbol, "side": "sell", "quantity": qty, "status": "dry_run"} ) - trade_logs.append(f"DRY_RUN sell {symbol} {qty}") + trade_logs.append(f"DRY_RUN sell {symbol} {format_quantity(qty)}") continue report = submit_order_intent( ib, @@ -720,7 +744,7 @@ def execute_rebalance( else: execution_summary["orders_skipped"].append({**order_payload, "reason": status or "submit_failed"}) execution_summary["skipped_reasons"].append(f"submit_failed:{symbol}:{status or 'unknown'}") - trade_logs.append(translator("market_sell", symbol=symbol, qty=qty) + f" {status_msg}") + trade_logs.append(translator("market_sell", symbol=symbol, qty=format_quantity(qty)) + f" {status_msg}") if ok: sell_executed = True @@ -741,12 +765,15 @@ def execute_rebalance( execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "missing_price"}) execution_summary["skipped_reasons"].append(f"missing_price:{symbol}") continue - if buy_value < 50: + if buy_value < minimum_order_notional: execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "min_notional"}) continue limit_price = round(price * limit_buy_premium, 2) - qty = int(buy_value / limit_price) + qty = _floor_order_quantity( + buy_value / limit_price, + quantity_step=order_quantity_step, + ) if qty <= 0: execution_summary["orders_skipped"].append({"symbol": symbol, "side": "buy", "reason": "quantity_zero"}) continue @@ -761,7 +788,7 @@ def execute_rebalance( "status": "dry_run", } ) - trade_logs.append(f"DRY_RUN buy {symbol} {qty} @{limit_price:.2f}") + trade_logs.append(f"DRY_RUN buy {symbol} {format_quantity(qty)} @{limit_price:.2f}") buying_power -= qty * limit_price continue report = submit_order_intent( @@ -795,7 +822,7 @@ def execute_rebalance( execution_summary["orders_skipped"].append({**order_payload, "reason": status or "submit_failed"}) execution_summary["skipped_reasons"].append(f"submit_failed:{symbol}:{status or 'unknown'}") trade_logs.append( - translator("limit_buy", symbol=symbol, qty=qty, price=f"{limit_price:.2f}") + f" {status_msg}" + translator("limit_buy", symbol=symbol, qty=format_quantity(qty), price=f"{limit_price:.2f}") + f" {status_msg}" ) if ok: buying_power -= qty * limit_price diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 83453a4..65bad42 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -16,6 +16,7 @@ from notifications.events import NotificationPublisher from notifications import renderers as notification_renderers from quant_platform_kit.common.models import PortfolioSnapshot, Position +from quant_platform_kit.common.quantity import format_quantity from quant_platform_kit.common.notification_localization import ( localize_notification_text as _base_localize_notification_text, translator_uses_zh as _base_translator_uses_zh, @@ -180,9 +181,9 @@ def _summarize_orders(orders, *, limit: int = 3) -> str: preview = [] for order in orders[:limit]: symbol = str(order.get("symbol") or "").strip().upper() - quantity = int(order.get("quantity") or 0) + quantity = float(order.get("quantity") or 0.0) if symbol and quantity > 0: - preview.append(f"{symbol} {quantity}") + preview.append(f"{symbol} {format_quantity(quantity)}") elif symbol: preview.append(symbol) remaining = len(orders) - len(preview) @@ -378,7 +379,7 @@ def build_dashboard( qty = positions[symbol]["quantity"] avg = positions[symbol]["avg_cost"] market_value = qty * avg - position_lines.append(f" - {symbol}: {qty}股 | ${market_value:,.2f}") + position_lines.append(f" - {symbol}: {format_quantity(qty)}股 | ${market_value:,.2f}") position_text = "\n".join(position_lines) if position_lines else translator("empty_positions") allocation = _resolve_weight_allocation(signal_metadata, required=False) target_lines = [] @@ -499,7 +500,7 @@ def _snapshot_to_portfolio_view(snapshot) -> tuple[dict[str, dict[str, float | i positions = {} for position in getattr(snapshot, "positions", ()) or (): positions[str(position.symbol).strip().upper()] = { - "quantity": int(position.quantity), + "quantity": float(position.quantity), "avg_cost": float(position.average_cost or 0.0), } account_values = { diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index 623b9bc..e11d910 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -38,6 +38,8 @@ class IBKRRuntimeBrokerAdapters: cash_reserve_ratio: float rebalance_threshold_ratio: float limit_buy_premium: float + quantity_step: float + min_order_notional: float sell_settle_delay_sec: float separator: str strategy_display_name: str @@ -84,7 +86,7 @@ def get_current_portfolio(self, ib): positions = {} for position in snapshot.positions: positions[position.symbol] = { - "quantity": int(position.quantity), + "quantity": float(position.quantity), "avg_cost": float(position.average_cost or 0.0), } account_values = { @@ -151,6 +153,8 @@ def execute_rebalance( cash_reserve_ratio=self.cash_reserve_ratio, rebalance_threshold_ratio=self.rebalance_threshold_ratio, limit_buy_premium=self.limit_buy_premium, + quantity_step=self.quantity_step, + min_order_notional=self.min_order_notional, sell_settle_delay_sec=self.sell_settle_delay_sec, return_summary=True, ) @@ -234,6 +238,8 @@ def build_runtime_broker_adapters( cash_reserve_ratio: float, rebalance_threshold_ratio: float, limit_buy_premium: float, + quantity_step: float, + min_order_notional: float, sell_settle_delay_sec: float, separator: str, strategy_display_name: str, @@ -267,6 +273,8 @@ def build_runtime_broker_adapters( cash_reserve_ratio=float(cash_reserve_ratio), rebalance_threshold_ratio=float(rebalance_threshold_ratio), limit_buy_premium=float(limit_buy_premium), + quantity_step=float(quantity_step), + min_order_notional=float(min_order_notional), sell_settle_delay_sec=float(sell_settle_delay_sec), separator=str(separator or ""), strategy_display_name=str(strategy_display_name or ""), diff --git a/main.py b/main.py index 2599a84..9d309dc 100644 --- a/main.py +++ b/main.py @@ -315,6 +315,8 @@ def build_broker_adapters(): cash_reserve_ratio=CASH_RESERVE_RATIO, rebalance_threshold_ratio=REBALANCE_THRESHOLD_RATIO, limit_buy_premium=LIMIT_BUY_PREMIUM, + quantity_step=RUNTIME_SETTINGS.quantity_step, + min_order_notional=RUNTIME_SETTINGS.min_order_notional, sell_settle_delay_sec=SELL_SETTLE_DELAY_SEC, separator=SEPARATOR, strategy_display_name=strategy_display_name, diff --git a/notifications/renderers.py b/notifications/renderers.py index e04a5ed..302f2a8 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -6,6 +6,7 @@ import re from notifications.events import RenderedNotification +from quant_platform_kit.common.quantity import format_quantity from quant_platform_kit.common.notification_localization import ( localize_notification_text as _base_localize_notification_text, translator_uses_zh as _base_translator_uses_zh, @@ -118,9 +119,9 @@ def _summarize_orders(orders, *, limit: int = 3) -> str: preview = [] for order in orders[:limit]: symbol = str(order.get("symbol") or "").strip().upper() - quantity = int(order.get("quantity") or 0) + quantity = float(order.get("quantity") or 0.0) if symbol and quantity > 0: - preview.append(f"{symbol} {quantity}") + preview.append(f"{symbol} {format_quantity(quantity)}") elif symbol: preview.append(symbol) remaining = len(orders) - len(preview) @@ -316,7 +317,7 @@ def build_dashboard( qty = positions[symbol]["quantity"] avg = positions[symbol]["avg_cost"] market_value = qty * avg - position_lines.append(f" - {symbol}: {qty}股 | ${market_value:,.2f}") + position_lines.append(f" - {symbol}: {format_quantity(qty)}股 | ${market_value:,.2f}") position_text = "\n".join(position_lines) if position_lines else translator("empty_positions") allocation = _resolve_weight_allocation(signal_metadata, required=False) target_lines = [] diff --git a/runtime_config_support.py b/runtime_config_support.py index 94d224e..369df61 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -52,12 +52,14 @@ class PlatformRuntimeSettings: strategy_config_source: str | None reconciliation_output_path: str | None dry_run_only: bool - account_group: str - service_name: str | None - account_ids: tuple[str, ...] - tg_token: str | None - tg_chat_id: str | None - notify_lang: str + quantity_step: float = 1.0 + min_order_notional: float = 50.0 + account_group: str = DEFAULT_ACCOUNT_GROUP + service_name: str | None = None + account_ids: tuple[str, ...] = () + tg_token: str | None = None + tg_chat_id: str | None = None + notify_lang: str = "en" def load_platform_runtime_settings( @@ -135,6 +137,12 @@ 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( + 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), account_group=account_group, service_name=group_config.service_name, account_ids=group_config.account_ids, @@ -151,6 +159,36 @@ 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 25380de..6854125 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -1,4 +1,5 @@ from types import SimpleNamespace +import math from application.execution_service import check_order_submitted, execute_rebalance from quant_platform_kit.common.models import OrderIntent @@ -129,6 +130,56 @@ def fake_fetch_quote_snapshots(_ib, symbols): assert any(log.startswith("buy VOO") for log in trade_logs) +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.000001, + 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.298507, rel_tol=0.0, abs_tol=0.000001) + + def test_execute_rebalance_skips_when_pending_orders_exist(): class FakeIB: def openTrades(self): diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index ce1a066..d154416 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -92,6 +92,8 @@ def test_load_platform_runtime_settings_uses_minimal_group_config(monkeypatch): assert settings.strategy_config_source is None assert settings.reconciliation_output_path is None assert settings.dry_run_only is False + assert settings.quantity_step == 1.0 + assert settings.min_order_notional == 50.0 assert settings.account_group == "default" assert settings.service_name is None assert settings.account_ids == () @@ -137,6 +139,19 @@ 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): + monkeypatch.setenv("STRATEGY_PROFILE", SAMPLE_STRATEGY_PROFILE) + monkeypatch.setenv("ACCOUNT_GROUP", "default") + 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.000001 + assert settings.min_order_notional == 5.0 + + def test_load_platform_runtime_settings_rejects_unknown_strategy_profile(monkeypatch): monkeypatch.setenv("STRATEGY_PROFILE", "balanced_income")