Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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 原始持仓数量和可卖数量,便于排查。 |
Expand Down
81 changes: 51 additions & 30 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment on lines +388 to +389

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve suffixing for class-share tickers

When a strategy emits an unsuffixed US class-share ticker such as BRK.B or BF.B, this new guard treats the class-share dot as if a market suffix is already present and sends BRK.B to quotes/order previews instead of the prior BRK.B.US. That regresses any profile/universe containing dotted tickers because LongBridge execution still needs the market suffix; this should check for the configured suffix specifically rather than any period.

Useful? React with 👍 / 👎.

suffix = str(symbol_suffix or "").strip().upper()
return f"{normalized}{suffix}" if suffix else normalized


def _sell_order_quantity(
*,
current_value,
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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"])
Expand All @@ -576,13 +593,15 @@ 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,
allocation=allocation,
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"])
Expand Down Expand Up @@ -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,
)
Expand All @@ -693,15 +712,15 @@ 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,
order_type="limit",
)
else:
submitted = submit_order_via_port(
f"{symbol}.US",
market_symbol(symbol),
"limit",
"sell",
quantity,
Expand All @@ -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),
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -764,15 +783,15 @@ 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,
)
if sweep_price is not None and sweep_price > 0.0:
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,
)
Expand All @@ -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),
Expand All @@ -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,
Expand Down Expand Up @@ -864,13 +883,15 @@ 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,
allocation=allocation,
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(
Expand Down Expand Up @@ -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,
)
Expand All @@ -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,
Expand All @@ -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),
)
Expand All @@ -958,15 +979,15 @@ 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,
order_type="limit",
)
else:
submitted = submit_order_via_port(
f"{symbol}.US",
market_symbol(symbol),
"limit",
"buy",
quantity,
Expand All @@ -977,15 +998,15 @@ 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),
order_type="market",
)
else:
submitted = submit_order_via_port(
f"{symbol}.US",
market_symbol(symbol),
"market",
"buy",
quantity,
Expand All @@ -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}",
Expand All @@ -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,
)
Expand All @@ -1050,15 +1071,15 @@ 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),
order_type="market",
)
else:
submitted = submit_order_via_port(
f"{cash_sweep_symbol}.US",
market_symbol(cash_sweep_symbol),
"market",
"buy",
quantity,
Expand All @@ -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}",
)
Expand Down
1 change: 1 addition & 0 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions application/runtime_composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions application/runtime_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/hk_equity_runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH=gs://<bucket>/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` | 账户现金、报价和通知口径。 |

最小港股配置:
Expand Down Expand Up @@ -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 更新/部署命令才会改变服务配置。

Expand Down
Loading