diff --git a/README.md b/README.md index a1eb03c..42b5a47 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Telegram notifications include structured execution and heartbeat messages, with | `LONGBRIDGE_MARKET` | No | Market scope. Defaults to `HK` when `ACCOUNT_REGION=HK`, otherwise `US`. | | `LONGBRIDGE_MARKET_CALENDAR` | No | Market calendar for market-hours checks. Defaults to `XHKG` for HK and `NYSE` for US. | | `LONGBRIDGE_MARKET_TIMEZONE` | No | Market timezone. Defaults to `Asia/Hong_Kong` for HK and `America/New_York` for US. | -| `LONGBRIDGE_SYMBOL_SUFFIX` | No | Market-data symbol suffix. Defaults to `.HK` for HK and `.US` for US. | +| `LONGBRIDGE_SYMBOL_SUFFIX` | No | Market-data and order symbol suffix. Defaults to `.HK` for HK and `.US` for US. | | `LONGBRIDGE_TRADING_CURRENCY` | No | Trading-currency cash/reporting scope. Defaults to `HKD` for HK and `USD` for US. | | `LONGBRIDGE_DRY_RUN_ONLY` | No | Set to `true` to keep the selected deployment in dry-run mode. | | `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | No | Set to `true` to log raw LongBridge position quantity and available quantity for troubleshooting. | @@ -278,7 +278,7 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换 | `LONGBRIDGE_MARKET` | 否 | 市场范围。`ACCOUNT_REGION=HK` 时默认 `HK`,其他情况默认 `US`。 | | `LONGBRIDGE_MARKET_CALENDAR` | 否 | 市场开闭市检查用的日历。港股默认 `XHKG`,美股默认 `NYSE`。 | | `LONGBRIDGE_MARKET_TIMEZONE` | 否 | 市场时区。港股默认 `Asia/Hong_Kong`,美股默认 `America/New_York`。 | -| `LONGBRIDGE_SYMBOL_SUFFIX` | 否 | 行情标的后缀。港股默认 `.HK`,美股默认 `.US`。 | +| `LONGBRIDGE_SYMBOL_SUFFIX` | 否 | 行情和订单标的后缀。港股默认 `.HK`,美股默认 `.US`。 | | `LONGBRIDGE_TRADING_CURRENCY` | 否 | 交易现金和报表口径。港股默认 `HKD`,美股默认 `USD`。 | | `LONGBRIDGE_DRY_RUN_ONLY` | 否 | 设为 `true` 时,该部署保持 dry-run。 | | `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | 否 | 设为 `true` 时输出 LongBridge 原始持仓数量和可卖数量,便于排查。 | diff --git a/application/execution_service.py b/application/execution_service.py index 9a1f7fd..62c8a0d 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -356,10 +356,12 @@ def record_small_account_cash_substitution_notes( translator, with_prefix, seen_keys, + symbol_suffix=".US", ): for message in format_small_account_cash_substitution_notes( allocation.get("small_account_whole_share_cash_notes") or (), translator=translator, + symbol_suffix=symbol_suffix, ): if message in seen_keys: continue @@ -379,6 +381,16 @@ def _normalize_trade_quantity(quantity): return _floor_whole_share_quantity(raw_quantity) +def _market_symbol(symbol, *, symbol_suffix=".US"): + normalized = str(symbol or "").strip().upper() + if not normalized: + return normalized + if "." in normalized: + return normalized + suffix = str(symbol_suffix or "").strip().upper() + return f"{normalized}{suffix}" if suffix else normalized + + def _sell_order_quantity( *, current_value, @@ -417,6 +429,7 @@ def _apply_small_account_whole_share_compatibility( strategy_assets, market_data_port, notify_issue, + symbol_suffix=".US", ) -> tuple[dict, dict]: target_values = dict(allocation.get("targets") or {}) candidate_symbols = tuple( @@ -439,7 +452,7 @@ def _apply_small_account_whole_share_compatibility( quote_prices = {} for symbol in candidate_symbols: try: - price = float(market_data_port.get_quote(f"{symbol}.US").last_price) + price = float(market_data_port.get_quote(_market_symbol(symbol, symbol_suffix=symbol_suffix)).last_price) except Exception: continue if price > 0.0: @@ -543,6 +556,7 @@ def execute_rebalance_cycle( limit_sell_discount, limit_buy_premium, dry_run_only=False, + symbol_suffix=".US", post_sell_refresh_attempts=1, post_sell_refresh_interval_sec=0.0, sleeper=_noop_sleep, @@ -559,6 +573,9 @@ def execute_rebalance_cycle( allocation.get("risk_symbols", ()) + allocation.get("income_symbols", ()) ) + def market_symbol(symbol): + return _market_symbol(symbol, symbol_suffix=symbol_suffix) + strategy_assets = tuple(allocation["strategy_symbols"]) market_values = dict(portfolio["market_values"]) quantities = dict(portfolio["quantities"]) @@ -576,6 +593,7 @@ def execute_rebalance_cycle( strategy_assets=strategy_assets, market_data_port=market_data_port, notify_issue=notify_issue, + symbol_suffix=symbol_suffix, ) record_small_account_cash_substitution_notes( note_logs, @@ -583,6 +601,7 @@ def execute_rebalance_cycle( translator=translator, with_prefix=with_prefix, seen_keys=small_account_cash_note_keys, + symbol_suffix=symbol_suffix, ) target_values = dict(allocation["targets"]) available_cash = float(portfolio["liquid_cash"]) @@ -675,7 +694,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): diff = target_values[symbol] - market_values[symbol] if diff < -threshold_value and abs(diff) > current_min_trade: price = safe_quote_last_price( - f"{symbol}.US", + market_symbol(symbol), market_data_port=market_data_port, notify_issue=notify_issue, ) @@ -693,7 +712,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): limit_price = round(price * limit_sell_discount, 2) if dry_run_only: submitted = record_dry_run( - f"{symbol}.US", + market_symbol(symbol), "sell", quantity_text, limit_price, @@ -701,7 +720,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) else: submitted = submit_order_via_port( - f"{symbol}.US", + market_symbol(symbol), "limit", "sell", quantity, @@ -711,7 +730,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): else: if dry_run_only: submitted = record_dry_run( - f"{symbol}.US", + market_symbol(symbol), "sell", quantity_text, round(price, 2), @@ -721,7 +740,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): dry_run_sale_proceeds += float(quantity) * round(price, 2) else: submitted = submit_order_via_port( - f"{symbol}.US", + market_symbol(symbol), "market", "sell", quantity, @@ -738,13 +757,13 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): skip_logs, translator=translator, with_prefix=with_prefix, - kind="sell_skipped", - detail=( - f"Symbol: {symbol}.US Diff: ${abs(diff):.2f} " - f"Held: {quantities[symbol]} Sellable: {sellable_quantities[symbol]} " - f"(no sellable)" - ), - ) + kind="sell_skipped", + detail=( + f"Symbol: {market_symbol(symbol)} Diff: ${abs(diff):.2f} " + f"Held: {quantities[symbol]} Sellable: {sellable_quantities[symbol]} " + f"(no sellable)" + ), + ) buy_candidates = [ symbol @@ -764,7 +783,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): and sellable_quantities.get(cash_sweep_symbol, 0.0) > 0.0 ): sweep_price = safe_quote_last_price( - f"{cash_sweep_symbol}.US", + market_symbol(cash_sweep_symbol), market_data_port=market_data_port, notify_issue=notify_issue, ) @@ -772,7 +791,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): funding_needs = [] for buy_symbol in funding_buy_candidates: buy_price = safe_quote_last_price( - f"{buy_symbol}.US", + market_symbol(buy_symbol), market_data_port=market_data_port, notify_issue=notify_issue, ) @@ -796,7 +815,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): quantity_text = format_quantity(sweep_quantity) if dry_run_only: submitted = record_dry_run( - f"{cash_sweep_symbol}.US", + market_symbol(cash_sweep_symbol), "sell", quantity_text, round(sweep_price, 2), @@ -806,7 +825,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): dry_run_sale_proceeds += float(sweep_quantity) * round(sweep_price, 2) else: submitted = submit_order_via_port( - f"{cash_sweep_symbol}.US", + market_symbol(cash_sweep_symbol), "market", "sell", sweep_quantity, @@ -864,6 +883,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): strategy_assets=tuple(allocation["strategy_symbols"]), market_data_port=market_data_port, notify_issue=notify_issue, + symbol_suffix=symbol_suffix, ) record_small_account_cash_substitution_notes( note_logs, @@ -871,6 +891,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): translator=translator, with_prefix=with_prefix, seen_keys=small_account_cash_note_keys, + symbol_suffix=symbol_suffix, ) threshold_value = float(execution["trade_threshold_value"]) limit_order_symbols = set( @@ -913,7 +934,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): for symbol in buy_candidates: diff = target_values[symbol] - market_values[symbol] price = safe_quote_last_price( - f"{symbol}.US", + market_symbol(symbol), market_data_port=market_data_port, notify_issue=notify_issue, ) @@ -926,7 +947,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): limit_ref_price = round(price * limit_buy_premium, 2) if is_limit_order else round(price, 2) limit_candidate = _estimate_buy_quantity_candidate( trade_context, - f"{symbol}.US", + market_symbol(symbol), limit_order_kind, limit_ref_price, can_buy_value=can_buy_value, @@ -948,7 +969,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): translator=translator, with_prefix=with_prefix, kind="buy_deferred_cash_limit", - symbol=f"{symbol}.US", + symbol=market_symbol(symbol), diff=f"{diff:.2f}", budget_qty=format_quantity(limit_budget_quantity), ) @@ -958,7 +979,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): if order_kind == "limit": if dry_run_only: submitted = record_dry_run( - f"{symbol}.US", + market_symbol(symbol), "buy", quantity_text, ref_price, @@ -966,7 +987,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) else: submitted = submit_order_via_port( - f"{symbol}.US", + market_symbol(symbol), "limit", "buy", quantity, @@ -977,7 +998,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): else: if dry_run_only: submitted = record_dry_run( - f"{symbol}.US", + market_symbol(symbol), "buy", quantity_text, round(price, 2), @@ -985,7 +1006,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) else: submitted = submit_order_via_port( - f"{symbol}.US", + market_symbol(symbol), "market", "buy", quantity, @@ -1000,14 +1021,14 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): if diff <= investable_cash: note_kind = "buy_deferred_small_target_gap" note_kwargs = { - "symbol": f"{symbol}.US", + "symbol": market_symbol(symbol), "diff": f"{diff:.2f}", "price": f"{price:.2f}", } else: note_kind = "buy_deferred_small_cash" note_kwargs = { - "symbol": f"{symbol}.US", + "symbol": market_symbol(symbol), "diff": f"{diff:.2f}", "investable": f"{investable_cash:.2f}", "price": f"{price:.2f}", @@ -1033,7 +1054,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) ): cash_sweep_price = safe_quote_last_price( - f"{cash_sweep_symbol}.US", + market_symbol(cash_sweep_symbol), market_data_port=market_data_port, notify_issue=notify_issue, ) @@ -1050,7 +1071,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): quantity_text = format_quantity(quantity) if dry_run_only: submitted = record_dry_run( - f"{cash_sweep_symbol}.US", + market_symbol(cash_sweep_symbol), "buy", quantity_text, round(cash_sweep_price, 2), @@ -1058,7 +1079,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): ) else: submitted = submit_order_via_port( - f"{cash_sweep_symbol}.US", + market_symbol(cash_sweep_symbol), "market", "buy", quantity, @@ -1072,7 +1093,7 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): if submitted: rebuy_message = translator( "cash_sweep_rebuy", - symbol=f"{cash_sweep_symbol}.US", + symbol=market_symbol(cash_sweep_symbol), qty=quantity_text, price=f"{cash_sweep_price:.2f}", ) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 9646f55..80d9e15 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -217,6 +217,7 @@ def fetch_replanned_state(): limit_sell_discount=config.limit_sell_discount, limit_buy_premium=config.limit_buy_premium, dry_run_only=config.dry_run_only, + symbol_suffix=config.symbol_suffix, post_sell_refresh_attempts=config.post_sell_refresh_attempts, post_sell_refresh_interval_sec=config.post_sell_refresh_interval_sec, sleeper=config.sleeper or _noop_sleep, diff --git a/application/runtime_composer.py b/application/runtime_composer.py index 3108ac5..e1d8a79 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -196,6 +196,7 @@ def build_rebalance_config(self, *, strategy_plugin_signals=()) -> LongBridgeReb strategy_profile=self.strategy_profile, strategy_display_name=self.strategy_display_name_localized, dry_run_only=self.dry_run_only, + symbol_suffix=self.symbol_suffix or ".US", post_sell_refresh_attempts=self.order_poll_max_attempts, post_sell_refresh_interval_sec=self.order_poll_interval_sec, safe_haven_cash_substitute_threshold_usd=self.safe_haven_cash_substitute_threshold_usd, diff --git a/application/runtime_dependencies.py b/application/runtime_dependencies.py index efc262a..e64c93c 100644 --- a/application/runtime_dependencies.py +++ b/application/runtime_dependencies.py @@ -19,6 +19,7 @@ class LongBridgeRebalanceConfig: strategy_profile: str = "" strategy_display_name: str = "" dry_run_only: bool = False + symbol_suffix: str = ".US" post_sell_refresh_attempts: int = 1 post_sell_refresh_interval_sec: float = 0.0 safe_haven_cash_substitute_threshold_usd: float = 1000.0 diff --git a/docs/hk_equity_runtime.md b/docs/hk_equity_runtime.md index 58c8a3d..b5bce98 100644 --- a/docs/hk_equity_runtime.md +++ b/docs/hk_equity_runtime.md @@ -50,7 +50,7 @@ LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH=gs:///hk_blue_chip_leader_rota | `LONGBRIDGE_MARKET` | 从 `ACCOUNT_REGION` 推导,默认 `US` | `HK` | 显式指定市场;优先级高于 `ACCOUNT_REGION`。 | | `LONGBRIDGE_MARKET_CALENDAR` | `NYSE` / 港股为 `XHKG` | `XHKG` | 市场开闭市判断使用的 calendar 名称。 | | `LONGBRIDGE_MARKET_TIMEZONE` | `America/New_York` / 港股为 `Asia/Hong_Kong` | `Asia/Hong_Kong` | 用于生成交易日日期。 | -| `LONGBRIDGE_SYMBOL_SUFFIX` | `.US` / 港股为 `.HK` | `.HK` | 平台行情符号后缀。 | +| `LONGBRIDGE_SYMBOL_SUFFIX` | `.US` / 港股为 `.HK` | `.HK` | 平台行情和订单符号后缀。 | | `LONGBRIDGE_TRADING_CURRENCY` | `USD` / 港股为 `HKD` | `HKD` | 账户现金、报价和通知口径。 | 最小港股配置: @@ -86,7 +86,7 @@ python scripts/print_strategy_switch_env_plan.py \ - `ACCOUNT_REGION=HK`、`ACCOUNT_PREFIX=HK`、`LONGBRIDGE_DRY_RUN_ONLY=true`。 - `LONGBRIDGE_MARKET=HK` / `XHKG` / `Asia/Hong_Kong` / `.HK` / `HKD`。 - `remove_if_present`:清理 snapshot/config 相关环境变量,因为该 profile 直接使用 `market_history`。 -- `dry_run_plan`:检查 HK 行情权限、`.HK` / HKD 映射、整数股和 lot-size、HKD 现金口径、通知和 runtime report。 +- `dry_run_plan`:检查 HK 行情权限、`.HK` / HKD 映射、整数股和 lot-size、HKD 现金口径、dry-run 订单预览、通知和 runtime report。 合并代码或打印计划不会触发生产部署;只有单独执行 Cloud Run env 更新/部署命令才会改变服务配置。 diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index e880a7e..17d22b2 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -7,10 +7,10 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) PLATFORM_KIT_SRC = ROOT.parent / "QuantPlatformKit" / "src" -if str(PLATFORM_KIT_SRC) not in sys.path: +if (PLATFORM_KIT_SRC / "quant_platform_kit" / "common" / "quantity.py").exists() and str(PLATFORM_KIT_SRC) not in sys.path: sys.path.insert(0, str(PLATFORM_KIT_SRC)) US_EQUITY_STRATEGIES_SRC = ROOT.parent / "UsEquityStrategies" / "src" -if str(US_EQUITY_STRATEGIES_SRC) not in sys.path: +if (US_EQUITY_STRATEGIES_SRC / "us_equity_strategies").exists() and str(US_EQUITY_STRATEGIES_SRC) not in sys.path: sys.path.insert(0, str(US_EQUITY_STRATEGIES_SRC)) import types @@ -306,6 +306,81 @@ def test_small_account_whole_share_layer_sells_unbuyable_soxx_sleeve(self): ("SOXL.US", "buy", 2), ]) + def test_execute_rebalance_cycle_hk_dry_run_uses_hk_suffix_without_broker_submit(self): + submitted_orders = [] + quoted_symbols = [] + estimated_symbols = [] + prices = {"02800.HK": 30.0, "03033.HK": 20.0} + plan = _build_plan( + strategy_profile="hk_listed_global_etf_rotation", + strategy_symbols=("02800", "03033"), + risk_symbols=("02800", "03033"), + targets={"02800": 90000.0, "03033": 70000.0}, + market_values={"02800": 0.0, "03033": 0.0}, + sellable_quantities={"02800": 0, "03033": 0}, + quantities={"02800": 0, "03033": 0}, + current_min_trade=100.0, + trade_threshold_value=100.0, + investable_cash=196000.0, + market_status="Risk on", + deploy_ratio_text="80.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="HK ETF rotation dry-run", + available_cash=200000.0, + total_strategy_equity=200000.0, + portfolio_rows=(("02800", "03033"),), + cash_by_currency={"HKD": 200000.0}, + ) + + def quote_loader(symbol): + quoted_symbols.append(symbol) + return QuoteSnapshot( + symbol=symbol, + as_of="2026-06-01", + last_price=prices[symbol], + ) + + def estimate_max_purchase_quantity(_trade_context, symbol, **_kwargs): + estimated_symbols.append(symbol) + return 10000 + + result = execute_rebalance_cycle( + trade_context=object(), + plan=plan, + portfolio=plan["portfolio"], + execution=plan["execution"], + allocation=plan["allocation"], + fetch_replanned_state=lambda: ( + plan, + plan["portfolio"], + plan["execution"], + plan["allocation"], + ), + market_data_port=CallableMarketDataPort(quote_loader=quote_loader), + estimate_max_purchase_quantity=estimate_max_purchase_quantity, + execution_port=CallableExecutionPort(lambda order_intent: submitted_orders.append(order_intent)), + notify_issue=lambda _title, _detail: None, + translator=build_translator("zh"), + with_prefix=lambda message: message, + limit_sell_discount=1.0, + limit_buy_premium=1.0, + dry_run_only=True, + symbol_suffix=".HK", + safe_haven_cash_substitute_threshold_usd=1000.0, + ) + + joined_logs = "\n".join(result.logs + result.skip_logs + result.note_logs) + self.assertTrue(result.action_done) + self.assertEqual(submitted_orders, []) + self.assertTrue(quoted_symbols) + self.assertTrue(estimated_symbols) + self.assertTrue(all(symbol.endswith(".HK") for symbol in quoted_symbols)) + self.assertTrue(all(symbol.endswith(".HK") for symbol in estimated_symbols)) + self.assertIn("02800.HK", joined_logs) + self.assertIn("03033.HK", joined_logs) + self.assertNotIn(".US", joined_logs) + def test_run_strategy_prefers_portfolio_port_runtime_path(self): sent_messages = [] observed = {}