diff --git a/README.md b/README.md index 7ed96ef..d55378c 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ Telegram notifications include structured execution and heartbeat messages, with | `NOTIFY_LANG` | No | Notification language: `en` (English, default) or `zh` (Chinese) | | `GOOGLE_CLOUD_PROJECT` | No | GCP project ID (defaults to ADC project when unset) | +Quantity sizing is resolved at runtime: `LONGBRIDGE_ORDER_QUANTITY_STEP` wins when set; otherwise `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED=true` uses a `0.000001` step and `false` uses whole shares. When a target value is zero, sell sizing uses the sellable position quantity instead of re-deriving shares from current price, so liquidation targets do not leave a residual share because of quote drift. + Secret Manager must contain the secret named by `LONGPORT_SECRET_NAME` (default: `longport_token_hk`), where the **latest version = active access token**. The app refreshes it when expiry is within 30 days. Recommended runtime secrets in the `longbridgequant` project: @@ -223,6 +225,8 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换 | `NOTIFY_LANG` | 否 | 通知语言: `en`(英文,默认)或 `zh`(中文) | | `GOOGLE_CLOUD_PROJECT` | 否 | GCP 项目 ID(未设置时使用 ADC 默认项目) | +下单数量在运行时解析:显式设置 `LONGBRIDGE_ORDER_QUANTITY_STEP` 时优先使用该步进;否则 `LONGBRIDGE_FRACTIONAL_SHARES_ENABLED=true` 使用 `0.000001` 步进,`false` 使用整数股。目标市值为 0 时,卖出数量直接按可卖持仓计算,不再用当前报价反推股数,避免因报价漂移留下 1 股残仓。 + Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `longport_token_hk`),**最新版本 = 当前有效的 access token**。Token 到期前 30 天会自动刷新。 建议在 `longbridgequant` 项目里维护这些运行时 secret: diff --git a/application/execution_service.py b/application/execution_service.py index 60af8e8..76ad7e1 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -80,6 +80,31 @@ def _floor_order_quantity(quantity, *, quantity_step): return normalize_order_quantity(floor_to_quantity_step(quantity, quantity_step)) +def _sell_order_quantity( + *, + current_value, + target_value, + price, + sellable_quantity, + quantity_step, +): + sellable = max(0.0, float(sellable_quantity or 0.0)) + if sellable <= 0.0: + return 0 + + target = max(0.0, float(target_value or 0.0)) + if target <= 0.0: + return _floor_order_quantity(sellable, quantity_step=quantity_step) + + sell_value = max(0.0, float(current_value or 0.0) - target) + if sell_value <= 0.0 or float(price or 0.0) <= 0.0: + return 0 + return _floor_order_quantity( + min(sell_value / float(price), sellable), + quantity_step=quantity_step, + ) + + def safe_quote_last_price(symbol, *, market_data_port, notify_issue): try: return float(market_data_port.get_quote(symbol).last_price) @@ -241,11 +266,11 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) if price is None: continue - quantity = _floor_order_quantity( - min( - floor_to_quantity_step(abs(diff) / price, order_quantity_step), - float(sellable_quantities[symbol]), - ), + quantity = _sell_order_quantity( + current_value=market_values[symbol], + target_value=target_values[symbol], + price=price, + sellable_quantity=sellable_quantities[symbol], quantity_step=order_quantity_step, ) if quantity > 0: diff --git a/requirements.txt b/requirements.txt index c61f8d2..6c584fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@e8ef5e79642edac26465fe88c893ef01c8c51a14 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@12047c085a090b2977cc47c297515ba515f302e4 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@08ed04ae9796f54a2218ffb700f97e0e33bf312f +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c9ec484c9a12cdffedf7d87c8906b93b21f50b1c pandas requests pytz diff --git a/runtime_config_support.py b/runtime_config_support.py index 84eb08c..2e07de1 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -7,6 +7,9 @@ from quant_platform_kit.common.runtime_config import ( resolve_bool_value, + resolve_float_env, + resolve_optional_float_env, + resolve_quantity_step_env, resolve_strategy_runtime_path_settings, ) from strategy_registry import ( @@ -102,14 +105,19 @@ 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( + quantity_step=resolve_quantity_step_env( + os.environ, 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), + min_order_notional=resolve_float_env( + os.environ, + "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"), + income_threshold_usd=resolve_optional_float_env(os.environ, "INCOME_THRESHOLD_USD"), qqqi_income_ratio=_qqqi_income_ratio_env(), feature_snapshot_path=runtime_paths.feature_snapshot_path, feature_snapshot_manifest_path=runtime_paths.feature_snapshot_manifest_path, @@ -127,40 +135,8 @@ def _normalize_region(raw_value: str | None) -> str | None: return value.upper() -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: - 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") + value = resolve_optional_float_env(os.environ, "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/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 1059b09..3580a1b 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -581,6 +581,36 @@ def test_fractional_quantity_step_allows_small_soxx_target_buy(self): self.assertIn("限价买入] SOXX: 0.321699股", sent_messages[0]) self.assertNotIn("不足买入 1 股", sent_messages[0]) + def test_zero_target_sell_uses_sellable_quantity_not_price_derived_floor(self): + plan = _build_plan( + strategy_symbols=("SOXL",), + risk_symbols=("SOXL",), + targets={"SOXL": 0.0}, + market_values={"SOXL": 327.88}, + sellable_quantities={"SOXL": 2}, + quantities={"SOXL": 2}, + current_min_trade=100.0, + trade_threshold_value=100.0, + investable_cash=891.03, + market_status="🧯 过热降档(SOXX)", + deploy_ratio_text="0.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="SOXL 目标仓位 0.0%", + available_cash=923.66, + total_strategy_equity=1087.60, + portfolio_rows=(("SOXL",),), + ) + + sent_messages, _, _ = self._run_strategy( + plan, + prices={"SOXL.US": 165.85}, + quantity_step=1.0, + ) + + self.assertEqual(len(sent_messages), 1) + self.assertIn("限价卖出] SOXL: 2股", sent_messages[0]) + def test_fractional_buy_uses_budget_when_broker_estimate_is_whole_share_zero(self): plan = _build_plan( strategy_symbols=("SOXX",),