From 2ffba54dfc40931cba336542b4a37fd60ec3c94c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 7 May 2026 04:48:03 +0800 Subject: [PATCH] Enable fractional LongBridge orders --- README.md | 16 +++- application/execution_service.py | 85 ++++++++++++++++---- application/rebalance_service.py | 2 + application/runtime_broker_adapters.py | 2 +- application/runtime_composer.py | 12 ++- application/runtime_dependencies.py | 4 +- application/runtime_notification_adapters.py | 3 +- main.py | 13 +++ notifications/order_alerts.py | 2 +- notifications/telegram.py | 2 + runtime_config_support.py | 36 ++++++++- strategy_runtime.py | 3 + tests/test_rebalance_service.py | 71 ++++++++++++++++ tests/test_runtime_composer.py | 4 + tests/test_runtime_config_support.py | 31 +++++++ 15 files changed, 258 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 4908134..7ed96ef 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ Telegram notifications include structured execution and heartbeat messages, with | `STRATEGY_PROFILE` | Yes | Strategy profile selector. Set explicitly per deployment; enabled values include `global_etf_rotation`, `mega_cap_leader_rotation_top50_balanced`, `russell_1000_multi_factor_defensive`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, and `tqqq_growth_income` | | `ACCOUNT_REGION` | No | Account region marker for platform-style deployment (e.g. `HK`, `SG`; defaults to `ACCOUNT_PREFIX` / `DEFAULT`) | | `LONGBRIDGE_DRY_RUN_ONLY` | No | Set to `true` to keep the selected deployment in dry-run mode. | +| `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED` | No | Defaults to `true`; set `false` to force whole-share sizing. | +| `LONGBRIDGE_ORDER_QUANTITY_STEP` | No | Explicit order quantity step override; e.g. `1` for whole shares or `0.000001` for fractional sizing. | +| `LONGBRIDGE_MIN_ORDER_NOTIONAL_USD` | No | Minimum buy notional for fractional sizing; defaults to `1.0`. | +| `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | No | Set to `true` to log raw LongBridge position quantity and available quantity for troubleshooting. | | `INCOME_THRESHOLD_USD` | No | Optional override for the `tqqq_growth_income` income-layer threshold. Leave unset to use the strategy package default. | | `QQQI_INCOME_RATIO` | No | Optional override for QQQI's share of the `tqqq_growth_income` income layer, 0–1. | | `NOTIFY_LANG` | No | Notification language: `en` (English, default) or `zh` (Chinese) | @@ -109,12 +113,12 @@ Recommended setup: - Optional fallback only: `TELEGRAM_TOKEN` - **GitHub Environment: `longbridge-hk`** - Variables: `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME` - - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` + - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` - Current live example: `STRATEGY_PROFILE=tech_communication_pullback_enhancement` - Recommended secret-name values: `longport-app-key-hk`, `longport-app-secret-hk` - **GitHub Environment: `longbridge-sg`** - Variables: `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME` - - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` + - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` - Current live example: `STRATEGY_PROFILE=soxl_soxx_trend_income` - Recommended secret-name values: `longport-app-key-sg`, `longport-app-secret-sg` @@ -210,6 +214,10 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换 | `STRATEGY_PROFILE` | 是 | 策略档位选择。每个部署都要显式设置;已启用值包括 `global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement` 和 `tqqq_growth_income` | | `ACCOUNT_REGION` | 否 | 平台化部署时的账户区域标记(如 `HK`、`SG`;默认按 `ACCOUNT_PREFIX` / `DEFAULT` 推断) | | `LONGBRIDGE_DRY_RUN_ONLY` | 否 | 设为 `true` 时,该部署保持 dry-run。 | +| `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED` | 否 | 默认 `true`;设为 `false` 时强制按整数股计算。 | +| `LONGBRIDGE_ORDER_QUANTITY_STEP` | 否 | 显式覆盖下单数量步进;如 `1` 表示整数股,`0.000001` 表示碎股数量步进。 | +| `LONGBRIDGE_MIN_ORDER_NOTIONAL_USD` | 否 | 碎股买入的最小名义金额;默认 `1.0`。 | +| `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | 否 | 设为 `true` 时输出 LongBridge 原始持仓数量和可卖数量,便于排查。 | | `INCOME_THRESHOLD_USD` | 否 | 可选的 `tqqq_growth_income` 收入层启动阈值覆盖。不填时使用策略包默认值。 | | `QQQI_INCOME_RATIO` | 否 | 可选的 QQQI 收入层占比覆盖,0–1。 | | `NOTIFY_LANG` | 否 | 通知语言: `en`(英文,默认)或 `zh`(中文) | @@ -254,12 +262,12 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo - 仅保留为 fallback:`TELEGRAM_TOKEN` - **GitHub Environment: `longbridge-hk`** - Variables: `CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` - - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` + - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` - 当前线上示例:`STRATEGY_PROFILE=tech_communication_pullback_enhancement` - 建议的 secret-name 值:`longport-app-key-hk`、`longport-app-secret-hk` - **GitHub Environment: `longbridge-sg`** - Variables: `CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` - - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` + - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO` - 当前线上示例:`STRATEGY_PROFILE=soxl_soxx_trend_income` - 建议的 secret-name 值:`longport-app-key-sg`、`longport-app-secret-sg` diff --git a/application/execution_service.py b/application/execution_service.py index ee5ee0d..60af8e8 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -6,6 +6,11 @@ from collections.abc import Mapping from dataclasses import dataclass +from quant_platform_kit.common.quantity import ( + floor_to_quantity_step, + format_quantity, + normalize_order_quantity, +) from quant_platform_kit.common.models import OrderIntent @@ -67,6 +72,14 @@ def record_note_log(note_logs, *, translator, with_prefix, kind, **kwargs): print(with_prefix(message), flush=True) +def _is_whole_share_step(quantity_step) -> bool: + return float(quantity_step or 1.0) >= 1.0 + + +def _floor_order_quantity(quantity, *, quantity_step): + return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step)) + + def safe_quote_last_price(symbol, *, market_data_port, notify_issue): try: return float(market_data_port.get_quote(symbol).last_price) @@ -119,6 +132,8 @@ def execute_rebalance_cycle( dry_run_only=False, post_sell_refresh_attempts=1, post_sell_refresh_interval_sec=0.0, + quantity_step=1.0, + min_order_notional=0.0, sleeper=_noop_sleep, ) -> ExecutionCycleResult: logs: list[str] = [] @@ -140,6 +155,8 @@ def execute_rebalance_cycle( cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency")) investable_cash = float(execution["investable_cash"]) current_min_trade = float(execution["current_min_trade"]) + order_quantity_step = float(quantity_step or 1.0) + minimum_order_notional = max(0.0, float(min_order_notional or 0.0)) def append_order_id_suffix(log_message, order_id): order_id_text = str(order_id or "").strip() @@ -224,18 +241,22 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) if price is None: continue - quantity = min( - int(abs(diff) // price), - sellable_quantities[symbol], + quantity = _floor_order_quantity( + min( + floor_to_quantity_step(abs(diff) / price, order_quantity_step), + float(sellable_quantities[symbol]), + ), + quantity_step=order_quantity_step, ) if quantity > 0: + quantity_text = format_quantity(quantity) if symbol in limit_order_symbols: limit_price = round(price * limit_sell_discount, 2) if dry_run_only: submitted = record_dry_run( f"{symbol}.US", "sell", - quantity, + quantity_text, limit_price, order_type="limit", ) @@ -245,7 +266,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): "limit", "sell", quantity, - translator("limit_sell", symbol=symbol, qty=quantity, price=limit_price), + translator("limit_sell", symbol=symbol, qty=quantity_text, price=limit_price), submitted_price=limit_price, ) else: @@ -253,7 +274,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): submitted = record_dry_run( f"{symbol}.US", "sell", - quantity, + quantity_text, round(price, 2), order_type="market", ) @@ -263,7 +284,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): "market", "sell", quantity, - translator("market_sell", symbol=symbol, qty=quantity, price=round(price, 2)), + translator("market_sell", symbol=symbol, qty=quantity_text, price=round(price, 2)), ) if submitted: @@ -349,12 +370,21 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): if price is None: continue can_buy_value = min(diff, investable_cash) - if can_buy_value > price: + if ( + _is_whole_share_step(order_quantity_step) + and can_buy_value > price + ) or ( + not _is_whole_share_step(order_quantity_step) + and can_buy_value >= minimum_order_notional + ): is_limit_order = symbol in limit_order_symbols order_kind = "limit" if is_limit_order else "market" ref_price = round(price * limit_buy_premium, 2) if is_limit_order else round(price, 2) budget_price = ref_price if is_limit_order else price - budget_quantity = int(can_buy_value // budget_price) + budget_quantity = floor_to_quantity_step( + can_buy_value / budget_price, + order_quantity_step, + ) cash_limit_quantity = estimate_cash_buy_quantity_safe( trade_context, f"{symbol}.US", @@ -365,8 +395,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) if cash_limit_quantity is None: continue - - quantity = min(budget_quantity, cash_limit_quantity) + effective_cash_limit_quantity = float(cash_limit_quantity) + if ( + not _is_whole_share_step(order_quantity_step) + and effective_cash_limit_quantity <= 0 + ): + effective_cash_limit_quantity = budget_quantity + + quantity = _floor_order_quantity( + min(budget_quantity, effective_cash_limit_quantity), + quantity_step=order_quantity_step, + ) cost_estimate = 0.0 if quantity <= 0: record_note_log( @@ -376,16 +415,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): kind="buy_deferred_cash_limit", symbol=f"{symbol}.US", diff=f"{diff:.2f}", - budget_qty=budget_quantity, + budget_qty=format_quantity(budget_quantity), ) continue + quantity_text = format_quantity(quantity) if is_limit_order: if dry_run_only: submitted = record_dry_run( f"{symbol}.US", "buy", - quantity, + quantity_text, ref_price, order_type="limit", ) @@ -395,7 +435,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): "limit", "buy", quantity, - translator("limit_buy", symbol=symbol, qty=quantity, price=ref_price), + translator("limit_buy", symbol=symbol, qty=quantity_text, price=ref_price), submitted_price=ref_price, ) cost_estimate = quantity * budget_price @@ -404,7 +444,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): submitted = record_dry_run( f"{symbol}.US", "buy", - quantity, + quantity_text, round(price, 2), order_type="market", ) @@ -414,14 +454,14 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): "market", "buy", quantity, - translator("market_buy", symbol=symbol, qty=quantity, price=round(price, 2)), + translator("market_buy", symbol=symbol, qty=quantity_text, price=round(price, 2)), ) cost_estimate = quantity * budget_price if submitted: investable_cash = max(0, investable_cash - cost_estimate) action_done = True - else: + elif _is_whole_share_step(order_quantity_step): record_note_log( note_logs, translator=translator, @@ -432,6 +472,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): investable=f"{investable_cash:.2f}", price=f"{price:.2f}", ) + else: + record_note_log( + note_logs, + translator=translator, + with_prefix=with_prefix, + kind="buy_deferred_min_notional", + symbol=f"{symbol}.US", + diff=f"{diff:.2f}", + investable=f"{investable_cash:.2f}", + min_notional=f"{minimum_order_notional:.2f}", + ) return ExecutionCycleResult( plan=dict(plan or {}), diff --git a/application/rebalance_service.py b/application/rebalance_service.py index d25188b..9caf59f 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -213,6 +213,8 @@ def fetch_replanned_state(): dry_run_only=config.dry_run_only, post_sell_refresh_attempts=config.post_sell_refresh_attempts, post_sell_refresh_interval_sec=config.post_sell_refresh_interval_sec, + quantity_step=config.quantity_step, + min_order_notional=config.min_order_notional, sleeper=config.sleeper or _noop_sleep, ) execution = execution_result.execution diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index 256fc07..3c2307e 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -194,7 +194,7 @@ def build_execution_port(self, trade_context) -> ExecutionPort: str(order_intent.symbol), order_kind=str(order_intent.order_type), side=str(order_intent.side), - quantity=int(order_intent.quantity), + quantity=float(order_intent.quantity), submitted_price=order_intent.limit_price, ) ) diff --git a/application/runtime_composer.py b/application/runtime_composer.py index 641f579..91f29b8 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -35,10 +35,12 @@ class LongBridgeRuntimeComposer: limit_buy_premium: float order_poll_interval_sec: int order_poll_max_attempts: int + quantity_step: float + min_order_notional: float dry_run_only: bool = False broker_adapters: Any = None strategy_adapters: Any = None - estimate_max_purchase_quantity_fn: Callable[..., int] | None = None + estimate_max_purchase_quantity_fn: Callable[..., float] | None = None fetch_order_status_fn: Callable[..., Any] | None = None fetch_token_from_secret_fn: Callable[..., str] | None = None refresh_token_if_needed_fn: Callable[..., str] | None = None @@ -168,6 +170,8 @@ def build_rebalance_config(self) -> LongBridgeRebalanceConfig: dry_run_only=self.dry_run_only, post_sell_refresh_attempts=self.order_poll_max_attempts, post_sell_refresh_interval_sec=self.order_poll_interval_sec, + quantity_step=self.quantity_step, + min_order_notional=self.min_order_notional, sleeper=self.sleeper, ) @@ -194,10 +198,12 @@ def build_runtime_composer( limit_buy_premium: float, order_poll_interval_sec: int, order_poll_max_attempts: int, + quantity_step: float, + min_order_notional: float, dry_run_only: bool, broker_adapters: Any, strategy_adapters: Any, - estimate_max_purchase_quantity_fn: Callable[..., int], + estimate_max_purchase_quantity_fn: Callable[..., float], fetch_order_status_fn: Callable[..., Any], fetch_token_from_secret_fn: Callable[..., str], refresh_token_if_needed_fn: Callable[..., str], @@ -233,6 +239,8 @@ def build_runtime_composer( limit_buy_premium=float(limit_buy_premium), order_poll_interval_sec=int(order_poll_interval_sec), order_poll_max_attempts=int(order_poll_max_attempts), + quantity_step=float(quantity_step), + min_order_notional=float(min_order_notional), dry_run_only=bool(dry_run_only), broker_adapters=broker_adapters, strategy_adapters=strategy_adapters, diff --git a/application/runtime_dependencies.py b/application/runtime_dependencies.py index 0dd4604..d677f28 100644 --- a/application/runtime_dependencies.py +++ b/application/runtime_dependencies.py @@ -20,6 +20,8 @@ class LongBridgeRebalanceConfig: dry_run_only: bool = False post_sell_refresh_attempts: int = 1 post_sell_refresh_interval_sec: float = 0.0 + quantity_step: float = 1.0 + min_order_notional: float = 0.0 sleeper: Callable[[float], None] | None = None @@ -28,7 +30,7 @@ class LongBridgeRebalanceRuntime: bootstrap: Callable[[], tuple[Any, Any, Any]] resolve_rebalance_plan: Callable[..., dict[str, Any]] market_data_port_factory: Callable[[Any], MarketDataPort] - estimate_max_purchase_quantity: Callable[..., int] + estimate_max_purchase_quantity: Callable[..., float] notifications: NotificationPort notify_issue: Callable[[str, str], None] portfolio_port_factory: Callable[[Any, Any], PortfolioPort] diff --git a/application/runtime_notification_adapters.py b/application/runtime_notification_adapters.py index a33deaf..f617475 100644 --- a/application/runtime_notification_adapters.py +++ b/application/runtime_notification_adapters.py @@ -13,6 +13,7 @@ publish_order_lifecycle_event, ) from notifications.telegram import build_issue_notifier +from quant_platform_kit.common.quantity import format_quantity from quant_platform_kit.common.port_adapters import CallableNotificationPort from quant_platform_kit.common.ports import NotificationPort @@ -69,7 +70,7 @@ def post_submit_order(trade_context, order_intent, report) -> None: trade_context, str(order_intent.symbol), "Buy" if str(order_intent.side).lower() == "buy" else "Sell", - int(order_intent.quantity), + format_quantity(order_intent.quantity), report.broker_order_id or "", fetch_order_status=fetch_order_status, order_poll_interval_sec=order_poll_interval_sec, diff --git a/main.py b/main.py index 8b6008d..0992bde 100644 --- a/main.py +++ b/main.py @@ -99,6 +99,12 @@ def t(key, **kwargs): STRATEGY_PROFILE, fallback_name=STRATEGY_DISPLAY_NAME, ) + + +def log_position_snapshot(message): + print(f"[{ACCOUNT_REGION}] {message}", flush=True) + + BROKER_ADAPTERS = build_runtime_broker_adapters( strategy_symbols=tuple(MANAGED_SYMBOLS), account_hash=ACCOUNT_PREFIX or ACCOUNT_REGION or "longbridge", @@ -107,6 +113,11 @@ def t(key, **kwargs): quote_context, trade_context, list(MANAGED_SYMBOLS), + position_log_fn=( + log_position_snapshot + if getattr(RUNTIME_SETTINGS, "debug_position_snapshot", False) + else None + ), ), submit_order_fn=submit_order, ) @@ -147,6 +158,8 @@ def build_composer(): limit_buy_premium=LIMIT_BUY_PREMIUM, order_poll_interval_sec=ORDER_POLL_INTERVAL_SEC, order_poll_max_attempts=ORDER_POLL_MAX_ATTEMPTS, + quantity_step=getattr(RUNTIME_SETTINGS, "quantity_step", 1.0), + min_order_notional=getattr(RUNTIME_SETTINGS, "min_order_notional", 0.0), dry_run_only=RUNTIME_SETTINGS.dry_run_only, broker_adapters=BROKER_ADAPTERS, strategy_adapters=STRATEGY_ADAPTERS, diff --git a/notifications/order_alerts.py b/notifications/order_alerts.py index 6244f13..f935314 100644 --- a/notifications/order_alerts.py +++ b/notifications/order_alerts.py @@ -13,7 +13,7 @@ class OrderLifecycleEvent: symbol: str side_text: str - quantity: int | str + quantity: float | int | str order_id: str status: str executed_qty: str = "0" diff --git a/notifications/telegram.py b/notifications/telegram.py index 7cec45c..f1cdf5f 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -62,6 +62,7 @@ "buy_deferred_no_investable_cash": "账户现金 ${available} 低于策略保留阈值,可投资现金为 ${investable},本轮不发起买单", "buy_deferred_non_usd_cash": "检测到非 USD 现金({currencies}),但美股策略可用 USD 现金为 ${available}、可投资现金为 ${investable};请先换汇或入金 USD 后再买入", "buy_deferred_small_cash": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 不足买入 1 股(价格 ${price})", + "buy_deferred_min_notional": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 低于最小下单金额 ${min_notional}", "buy_deferred_cash_limit": "{symbol} 目标差额 ${diff},预算可买 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用", "limit_buy": "📈 [限价买入] {symbol}: {qty}股 @ ${price}", "market_buy": "📈 [市价买入] {symbol}: {qty}股 @ ${price}", @@ -141,6 +142,7 @@ "buy_deferred_no_investable_cash": "Account cash ${available} is below the strategy reserve threshold, investable cash is ${investable}; no buy order this cycle", "buy_deferred_non_usd_cash": "Non-USD cash is present ({currencies}), but this US-equity strategy has USD cash ${available} and investable cash ${investable}; convert or deposit USD before buying", "buy_deferred_small_cash": "{symbol} target gap ${diff}, but investable cash ${investable} is not enough for 1 share at ${price}", + "buy_deferred_min_notional": "{symbol} target gap ${diff}, but investable cash ${investable} is below the minimum order notional ${min_notional}", "buy_deferred_cash_limit": "{symbol} target gap ${diff}, budget supports {budget_qty} shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds", "limit_buy": "📈 [Limit buy] {symbol}: {qty} shares @ ${price}", "market_buy": "📈 [Market buy] {symbol}: {qty} shares @ ${price}", diff --git a/runtime_config_support.py b/runtime_config_support.py index 74e6d32..84eb08c 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -33,6 +33,9 @@ class PlatformRuntimeSettings: tg_token: str | None tg_chat_id: str | None dry_run_only: bool + quantity_step: float = 1.0 + min_order_notional: float = 0.0 + debug_position_snapshot: bool = False income_threshold_usd: float | None = None qqqi_income_ratio: float | None = None feature_snapshot_path: str | None = None @@ -99,6 +102,13 @@ def load_platform_runtime_settings( tg_token=os.getenv("TELEGRAM_TOKEN"), tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"), dry_run_only=resolve_bool_value(os.getenv("LONGBRIDGE_DRY_RUN_ONLY")), + quantity_step=_quantity_step_env( + step_env="LONGBRIDGE_ORDER_QUANTITY_STEP", + fractional_env="LONGBRIDGE_FRACTIONAL_SHARES_ENABLED", + fractional_default=True, + ), + min_order_notional=_float_env("LONGBRIDGE_MIN_ORDER_NOTIONAL_USD", default=1.0), + debug_position_snapshot=resolve_bool_value(os.getenv("LONGBRIDGE_DEBUG_POSITION_SNAPSHOT")), income_threshold_usd=_optional_float_env("INCOME_THRESHOLD_USD"), qqqi_income_ratio=_qqqi_income_ratio_env(), feature_snapshot_path=runtime_paths.feature_snapshot_path, @@ -124,9 +134,33 @@ def _optional_float_env(name: str) -> float | None: return float(raw_value) +def _float_env(name: str, *, default: float) -> float: + raw_value = os.getenv(name) + if raw_value is None or raw_value.strip() == "": + return float(default) + return float(raw_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 _qqqi_income_ratio_env() -> float | None: value = _optional_float_env("QQQI_INCOME_RATIO") if value is not None and not (0.0 <= value <= 1.0): raise ValueError(f"QQQI_INCOME_RATIO must be in [0,1], got {value}") return value - diff --git a/strategy_runtime.py b/strategy_runtime.py index aa91e2d..a99e536 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -154,6 +154,9 @@ def _default_runtime_settings(profile: str, display_name: str) -> PlatformRuntim tg_token=None, tg_chat_id=None, dry_run_only=False, + quantity_step=1.0, + min_order_notional=0.0, + debug_position_snapshot=False, feature_snapshot_path=None, feature_snapshot_manifest_path=None, strategy_config_path=None, diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index baa9acf..1059b09 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -400,6 +400,8 @@ def _run_strategy( dry_run_only=False, strategy_display_name="SOXL/SOXX 半导体趋势收益", post_sell_refresh_attempts=1, + quantity_step=1.0, + min_order_notional=0.0, ): sent_messages = [] observed_snapshots = [] @@ -471,6 +473,8 @@ def fake_resolve_rebalance_plan(*, indicators, snapshot): dry_run_only=dry_run_only, post_sell_refresh_attempts=post_sell_refresh_attempts, post_sell_refresh_interval_sec=0.0, + quantity_step=quantity_step, + min_order_notional=min_order_notional, sleeper=observed_sleeps.append, ), ) @@ -543,6 +547,73 @@ def test_buy_skip_without_orders_is_sent_in_single_heartbeat_message(self): self.assertIn("可投资现金", sent_messages[0]) self.assertIn("SOXX.US", sent_messages[0]) + def test_fractional_quantity_step_allows_small_soxx_target_buy(self): + plan = _build_plan( + strategy_symbols=("SOXL", "SOXX", "BOXX"), + risk_symbols=("SOXL", "SOXX"), + safe_haven_symbols=("BOXX",), + targets={"SOXL": 0.0, "SOXX": 163.14, "BOXX": 924.46}, + market_values={"SOXL": 0.0, "SOXX": 0.0, "BOXX": 699.54}, + sellable_quantities={"SOXL": 0, "SOXX": 0, "BOXX": 6}, + quantities={"SOXL": 0, "SOXX": 0, "BOXX": 6}, + current_min_trade=100.0, + trade_threshold_value=100.0, + investable_cash=891.03, + market_status="🧯 过热降档(SOXX)", + deploy_ratio_text="15.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="SOXX 目标仓位 15.0%", + available_cash=923.66, + total_strategy_equity=1087.60, + portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)), + ) + + sent_messages, _, _ = self._run_strategy( + plan, + prices={"SOXX.US": 504.60, "SOXL.US": 162.93, "BOXX.US": 116.59}, + estimate_max_purchase_quantity_value=10, + quantity_step=0.000001, + min_order_notional=1.0, + ) + + self.assertEqual(len(sent_messages), 1) + self.assertIn("限价买入] SOXX: 0.321699股", sent_messages[0]) + self.assertNotIn("不足买入 1 股", sent_messages[0]) + + def test_fractional_buy_uses_budget_when_broker_estimate_is_whole_share_zero(self): + plan = _build_plan( + strategy_symbols=("SOXX",), + risk_symbols=("SOXX",), + targets={"SOXX": 163.14}, + market_values={"SOXX": 0.0}, + sellable_quantities={"SOXX": 0}, + quantities={"SOXX": 0}, + current_min_trade=100.0, + trade_threshold_value=100.0, + investable_cash=891.03, + market_status="🧯 过热降档(SOXX)", + deploy_ratio_text="15.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="SOXX 目标仓位 15.0%", + available_cash=923.66, + total_strategy_equity=1087.60, + portfolio_rows=(("SOXX",),), + ) + + sent_messages, _, _ = self._run_strategy( + plan, + prices={"SOXX.US": 504.60}, + estimate_max_purchase_quantity_value=0, + quantity_step=0.000001, + min_order_notional=1.0, + ) + + self.assertEqual(len(sent_messages), 1) + self.assertIn("限价买入] SOXX: 0.321699股", sent_messages[0]) + self.assertNotIn("券商估算可买数量为 0", sent_messages[0]) + def test_zero_investable_cash_reports_buying_power_without_trade_note(self): plan = _build_plan( strategy_symbols=("BOXX",), diff --git a/tests/test_runtime_composer.py b/tests/test_runtime_composer.py index 3952f44..b6323fc 100644 --- a/tests/test_runtime_composer.py +++ b/tests/test_runtime_composer.py @@ -50,6 +50,8 @@ def fake_bootstrap_builder(**kwargs): limit_buy_premium=1.005, order_poll_interval_sec=1, order_poll_max_attempts=8, + quantity_step=0.000001, + min_order_notional=1.0, dry_run_only=True, broker_adapters=SimpleNamespace( build_market_data_port="market-data-port-factory", @@ -111,5 +113,7 @@ def fake_bootstrap_builder(**kwargs): assert runtime.post_submit_order == "post-submit-order" assert config.limit_sell_discount == 0.995 assert config.limit_buy_premium == 1.005 + assert config.quantity_step == 0.000001 + assert config.min_order_notional == 1.0 assert config.strategy_display_name == "SOXL/SOXX 半导体趋势收益" assert config.dry_run_only is True diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index d1cab15..80f0c52 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -52,6 +52,9 @@ def test_load_platform_runtime_settings_uses_defaults_with_explicit_strategy_pro self.assertIsNone(settings.tg_token) self.assertIsNone(settings.tg_chat_id) self.assertFalse(settings.dry_run_only) + self.assertEqual(settings.quantity_step, 0.000001) + self.assertEqual(settings.min_order_notional, 1.0) + self.assertFalse(settings.debug_position_snapshot) self.assertIsNone(settings.income_threshold_usd) self.assertIsNone(settings.qqqi_income_ratio) self.assertIsNone(settings.feature_snapshot_path) @@ -102,6 +105,34 @@ def test_dry_run_only_is_loaded_from_env(self): self.assertTrue(settings.dry_run_only) + def test_order_quantity_step_can_be_forced_to_whole_shares(self): + with patch.dict( + os.environ, + { + "STRATEGY_PROFILE": SAMPLE_STRATEGY_PROFILE, + "LONGBRIDGE_FRACTIONAL_SHARES_ENABLED": "false", + "LONGBRIDGE_MIN_ORDER_NOTIONAL_USD": "25", + }, + clear=True, + ): + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + self.assertEqual(settings.quantity_step, 1.0) + self.assertEqual(settings.min_order_notional, 25.0) + + def test_debug_position_snapshot_is_loaded_from_env(self): + with patch.dict( + os.environ, + { + "STRATEGY_PROFILE": SAMPLE_STRATEGY_PROFILE, + "LONGBRIDGE_DEBUG_POSITION_SNAPSHOT": "true", + }, + clear=True, + ): + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + self.assertTrue(settings.debug_position_snapshot) + def test_income_layer_overrides_are_loaded_from_env(self): with patch.dict( os.environ,