diff --git a/.gitignore b/.gitignore index 681da60..cc8a52d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ __pycache__/ *.py[cod] +.venv/ backtest_output/ backtest_output_*/ diff --git a/README.md b/README.md index 27aa1df..d9f8528 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Quant system on LongPort OpenAPI and Google Cloud Run. This repository uses `QuantPlatformKit` for LongPort token handling, context bootstrap, account snapshot access, market data, and order submission. Cloud Run deploys this repository directly. -The LongBridge runtime can execute all nine live `us_equity` profiles from `UsEquityStrategies`; `LongBridgePlatform` keeps the LongPort runtime, token refresh, execution, and notification flow. +The LongBridge runtime can execute the six current `runtime_enabled` `us_equity` profiles from `UsEquityStrategies`; `LongBridgePlatform` keeps the LongPort runtime, token refresh, execution, and notification flow. Full strategy documentation now lives in [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies). The sections below focus on LongBridge runtime behavior, profile enablement, deployment, and credentials. This runtime matrix is the authoritative enablement source for LongBridge. `UsEquityStrategies` carries strategy-layer logic, cadence, compatibility, and metadata. @@ -33,13 +33,13 @@ Platform execution no longer depends on `strategy/allocation.py` or hard-coded s | --- | --- | --- | --- | --- | --- | | `global_etf_rotation` | Global ETF Rotation | Yes | Yes | `us_equity` | enabled weight-mode rotation line | | `russell_1000_multi_factor_defensive` | Russell 1000 Multi-Factor | Yes | Yes | `us_equity` | enabled feature-snapshot stock baseline | -| `mega_cap_leader_rotation_aggressive` | Mega Cap Leader Rotation Aggressive | Yes | Yes | `us_equity` | selectable aggressive monthly feature-snapshot leader rotation | -| `mega_cap_leader_rotation_dynamic_top20` | Mega Cap Leader Rotation Dynamic Top20 | Yes | Yes | `us_equity` | selectable monthly feature-snapshot leader rotation | | `mega_cap_leader_rotation_top50_balanced` | Mega Cap Leader Rotation Top50 Balanced | Yes | Yes | `us_equity` | selectable balanced Top50 monthly leader rotation | -| `dynamic_mega_leveraged_pullback` | Dynamic Mega Leveraged Pullback | Yes | Yes | `us_equity` | selectable 2x mega-cap pullback line | | `soxl_soxx_trend_income` | SOXL/SOXX Semiconductor Trend Income | Yes | Yes | `us_equity` | current SG deployment | | `tqqq_growth_income` | TQQQ Growth Income | Yes | Yes | `us_equity` | selectable growth line | | `tech_communication_pullback_enhancement` | Tech/Communication Pullback Enhancement | Yes | Yes | `us_equity` | current HK feature-snapshot line | +| `mega_cap_leader_rotation_aggressive` | Mega Cap Leader Rotation Aggressive | Yes | No | `us_equity` | research-only archive | +| `mega_cap_leader_rotation_dynamic_top20` | Mega Cap Leader Rotation Dynamic Top20 | Yes | No | `us_equity` | research-only archive | +| `dynamic_mega_leveraged_pullback` | Dynamic Mega Leveraged Pullback | Yes | No | `us_equity` | research-only archive | Check the current matrix locally: @@ -65,7 +65,7 @@ Telegram notifications include structured execution and heartbeat messages, with | `LONGPORT_APP_SECRET` | Yes | LongPort OpenAPI app secret (for token refresh); recommended to inject from the region-specific Secret Manager secret for this deployment, such as `longport-app-secret-hk` / `longport-app-secret-sg` | | `LONGPORT_SECRET_NAME` | No | Secret Manager secret name for LongPort token (default: `longport_token_hk`) | | `ACCOUNT_PREFIX` | No | Alert/log prefix for account/environment (default: `DEFAULT`) | -| `STRATEGY_PROFILE` | Yes | Strategy profile selector. Set explicitly per deployment; enabled values include `dynamic_mega_leveraged_pullback`, `global_etf_rotation`, `mega_cap_leader_rotation_aggressive`, `mega_cap_leader_rotation_dynamic_top20`, `mega_cap_leader_rotation_top50_balanced`, `russell_1000_multi_factor_defensive`, `soxl_soxx_trend_income`, `tech_communication_pullback_enhancement`, and `tqqq_growth_income` | +| `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. | | `INCOME_THRESHOLD_USD` | No | Optional override for the `tqqq_growth_income` income-layer threshold. Leave unset to use the strategy package default. | @@ -159,7 +159,7 @@ IAM: the Cloud Run service account needs **Secret Manager Admin** (or Secret Acc 基于 LongPort OpenAPI 和 Google Cloud Run 的量化交易系统。 这个仓库通过 `QuantPlatformKit` 复用 LongPort token 处理、上下文初始化、账户快照、行情读取和下单逻辑。Cloud Run 直接部署这个仓库。 -`LongBridgePlatform` 现在可直接执行 `UsEquityStrategies` 里的全部 9 条 live `us_equity` 策略:`dynamic_mega_leveraged_pullback`、`global_etf_rotation`、`mega_cap_leader_rotation_aggressive`、`mega_cap_leader_rotation_dynamic_top20`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tqqq_growth_income` 和 `tech_communication_pullback_enhancement`;仓库本身继续保留 LongPort 运行时、token 刷新、执行和通知流程。 +`LongBridgePlatform` 现在可直接执行 `UsEquityStrategies` 里的 6 条 `runtime_enabled` `us_equity` 策略:`global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tqqq_growth_income` 和 `tech_communication_pullback_enhancement`;`dynamic_mega_leveraged_pullback`、`mega_cap_leader_rotation_aggressive`、`mega_cap_leader_rotation_dynamic_top20` 保留为 research-only 存档,不进入 LongBridge rollout。仓库本身继续保留 LongPort 运行时、token 刷新、执行和通知流程。 完整策略说明现在放在 [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies)。下面这些章节只保留 LongBridge 运行时、profile 启用状态、部署和凭据说明。 @@ -180,13 +180,13 @@ IAM: the Cloud Run service account needs **Secret Manager Admin** (or Secret Acc | --- | --- | --- | --- | --- | --- | | `global_etf_rotation` | Global ETF Rotation | Yes | Yes | `us_equity` | 已启用的 weight-mode 轮动线 | | `russell_1000_multi_factor_defensive` | Russell 1000 Multi-Factor | Yes | Yes | `us_equity` | 已启用的 feature-snapshot 个股基线 | -| `mega_cap_leader_rotation_aggressive` | Mega Cap Leader Rotation Aggressive | Yes | Yes | `us_equity` | 可选的激进月度 feature-snapshot 龙头轮动线 | -| `mega_cap_leader_rotation_dynamic_top20` | Mega Cap Leader Rotation Dynamic Top20 | Yes | Yes | `us_equity` | 可选的月度 feature-snapshot 龙头轮动线 | | `mega_cap_leader_rotation_top50_balanced` | Mega Cap Leader Rotation Top50 Balanced | Yes | Yes | `us_equity` | 可选的 Top50 平衡月度龙头轮动线 | -| `dynamic_mega_leveraged_pullback` | Dynamic Mega Leveraged Pullback | Yes | Yes | `us_equity` | 可选的 2x 龙头回调线 | | `soxl_soxx_trend_income` | SOXL/SOXX 半导体趋势收益 | Yes | Yes | `us_equity` | 当前 SG 部署线路 | | `tqqq_growth_income` | TQQQ 增长收益 | Yes | Yes | `us_equity` | 可选增长线路 | | `tech_communication_pullback_enhancement` | 科技通信回调增强 | Yes | Yes | `us_equity` | 当前 HK feature-snapshot 线路 | +| `mega_cap_leader_rotation_aggressive` | Mega Cap Leader Rotation Aggressive | Yes | No | `us_equity` | research-only 存档 | +| `mega_cap_leader_rotation_dynamic_top20` | Mega Cap Leader Rotation Dynamic Top20 | Yes | No | `us_equity` | research-only 存档 | +| `dynamic_mega_leveraged_pullback` | Dynamic Mega Leveraged Pullback | Yes | No | `us_equity` | research-only 存档 | 本地可直接查看当前矩阵: @@ -212,7 +212,7 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换 | `LONGPORT_APP_SECRET` | 是 | LongPort OpenAPI 应用密钥(用于刷新 Token);建议从当前部署对应区域的 Secret Manager 密钥注入,例如 `longport-app-secret-hk` / `longport-app-secret-sg` | | `LONGPORT_SECRET_NAME` | 否 | Secret Manager 中的密钥名称(默认: `longport_token_hk`) | | `ACCOUNT_PREFIX` | 否 | 通知/日志前缀,区分账户环境(默认: `DEFAULT`) | -| `STRATEGY_PROFILE` | 是 | 策略档位选择。每个部署都要显式设置;已启用值包括 `dynamic_mega_leveraged_pullback`、`global_etf_rotation`、`mega_cap_leader_rotation_aggressive`、`mega_cap_leader_rotation_dynamic_top20`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tech_communication_pullback_enhancement` 和 `tqqq_growth_income` | +| `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。 | | `INCOME_THRESHOLD_USD` | 否 | 可选的 `tqqq_growth_income` 收入层启动阈值覆盖。不填时使用策略包默认值。 | diff --git a/application/execution_service.py b/application/execution_service.py new file mode 100644 index 0000000..ee5ee0d --- /dev/null +++ b/application/execution_service.py @@ -0,0 +1,445 @@ +"""Order execution helpers for LongBridgePlatform.""" + +from __future__ import annotations + +import traceback +from collections.abc import Mapping +from dataclasses import dataclass + +from quant_platform_kit.common.models import OrderIntent + + +@dataclass(frozen=True) +class ExecutionCycleResult: + plan: dict + portfolio: dict + execution: dict + allocation: dict + logs: tuple[str, ...] + skip_logs: tuple[str, ...] + note_logs: tuple[str, ...] + action_done: bool + + +def _noop_sleep(_seconds): + return None + + +def _normalize_cash_by_currency(raw_cash) -> dict[str, float]: + if not isinstance(raw_cash, Mapping): + return {} + cash_by_currency: dict[str, float] = {} + for currency, amount in raw_cash.items(): + normalized_currency = str(currency or "").strip().upper() + if not normalized_currency: + continue + cash_by_currency[normalized_currency] = float(amount) + return cash_by_currency + + +def _format_cash_by_currency(cash_by_currency: Mapping[str, float]) -> str: + parts = [] + for currency in sorted(cash_by_currency, key=lambda value: (value != "USD", value)): + amount = float(cash_by_currency[currency]) + if amount == 0.0: + continue + parts.append(f"{currency} {amount:,.2f}") + return ", ".join(parts) + + +def _has_positive_non_usd_cash(cash_by_currency: Mapping[str, float]) -> bool: + return any( + currency != "USD" and float(amount) > 0.0 + for currency, amount in cash_by_currency.items() + ) + + +def record_skip_log(skip_logs, *, translator, with_prefix, kind, detail): + message = translator(kind, detail=detail) + skip_logs.append(message) + print(with_prefix(message), flush=True) + + +def record_note_log(note_logs, *, translator, with_prefix, kind, **kwargs): + detail = translator(kind, **kwargs) + message = translator("buy_deferred", detail=detail) + note_logs.append(message) + print(with_prefix(message), flush=True) + + +def safe_quote_last_price(symbol, *, market_data_port, notify_issue): + try: + return float(market_data_port.get_quote(symbol).last_price) + except Exception as exc: + notify_issue("Quote failed", f"Symbol: {symbol}\n{exc}") + return None + + +def estimate_cash_buy_quantity_safe( + trade_context, + symbol, + order_kind, + ref_price, + *, + estimate_max_purchase_quantity, + notify_issue, +): + try: + return estimate_max_purchase_quantity( + trade_context, + symbol, + order_kind=order_kind, + ref_price=ref_price, + ) + except Exception: + notify_issue( + "Estimate max buy failed", + f"Symbol: {symbol}\nOrderKind: {order_kind}\n{traceback.format_exc()}", + ) + return None + + +def execute_rebalance_cycle( + *, + trade_context, + plan, + portfolio, + execution, + allocation, + fetch_replanned_state, + market_data_port, + estimate_max_purchase_quantity, + execution_port, + post_submit_order=None, + notify_issue, + translator, + with_prefix, + limit_sell_discount, + limit_buy_premium, + dry_run_only=False, + post_sell_refresh_attempts=1, + post_sell_refresh_interval_sec=0.0, + sleeper=_noop_sleep, +) -> ExecutionCycleResult: + logs: list[str] = [] + skip_logs: list[str] = [] + note_logs: list[str] = [] + action_done = False + sell_submitted = False + threshold_value = float(execution["trade_threshold_value"]) + limit_order_symbols = set( + allocation.get("risk_symbols", ()) + allocation.get("income_symbols", ()) + ) + + strategy_assets = tuple(allocation["strategy_symbols"]) + market_values = dict(portfolio["market_values"]) + quantities = dict(portfolio["quantities"]) + sellable_quantities = dict(portfolio["sellable_quantities"]) + target_values = dict(allocation["targets"]) + available_cash = float(portfolio["liquid_cash"]) + 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"]) + + def append_order_id_suffix(log_message, order_id): + order_id_text = str(order_id or "").strip() + if not order_id_text: + return log_message + suffix = str(translator("order_id_suffix", order_id=order_id_text)).strip() + if not suffix or suffix == "order_id_suffix": + suffix = f"[order_id={order_id_text}]" + return f"{log_message} {suffix}" + + def submit_order_via_port(symbol, order_type, side, quantity, log_message, *, submitted_price=None): + order_intent = OrderIntent( + symbol=symbol, + side=side, + quantity=quantity, + order_type=order_type, + limit_price=float(submitted_price) if submitted_price is not None else None, + ) + side_text = "Buy" if side == "buy" else "Sell" + try: + report = execution_port.submit_order(order_intent) + except Exception: + notify_issue( + "Order submit failed", + ( + f"Symbol: {symbol} Side: {side_text} Qty: {quantity} " + f"Type: {order_type} Price: {submitted_price if submitted_price is not None else 'MO'}\n" + f"{traceback.format_exc()}" + ), + ) + return False + + status = str(report.status or "").strip().lower() + if status not in {"submitted", "accepted"}: + detail = report.raw_payload.get("detail", report.status) if isinstance(report.raw_payload, Mapping) else report.status + notify_issue( + "Order submit failed", + ( + f"Symbol: {symbol} Side: {side_text} Qty: {quantity} " + f"Type: {order_type} Price: {submitted_price if submitted_price is not None else 'MO'}\n" + f"Status: {detail}" + ), + ) + return False + + log_with_order_id = append_order_id_suffix(log_message, report.broker_order_id) + print(with_prefix(f"OK {log_with_order_id}"), flush=True) + logs.append(log_with_order_id) + if post_submit_order is not None: + try: + post_submit_order(trade_context, order_intent, report) + except Exception: + notify_issue( + "Order post-submit hook failed", + f"Symbol: {symbol} Side: {side_text} Qty: {quantity}\n{traceback.format_exc()}", + ) + return True + + def record_dry_run(symbol, side, quantity, price, *, order_type): + price_text = f"${price:.2f}" if price is not None else translator("order_type_market") + side_key = "side_buy" if str(side).lower() == "buy" else "side_sell" + order_type_key = "order_type_limit" if order_type == "limit" else "order_type_market" + message = translator( + "dry_run_order", + side=translator(side_key), + symbol=symbol, + qty=quantity, + price=price_text, + order_type=translator(order_type_key), + ) + logs.append(message) + print(with_prefix(message), flush=True) + return True + + for symbol in strategy_assets: + 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_data_port=market_data_port, + notify_issue=notify_issue, + ) + if price is None: + continue + quantity = min( + int(abs(diff) // price), + sellable_quantities[symbol], + ) + if quantity > 0: + 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, + limit_price, + order_type="limit", + ) + else: + submitted = submit_order_via_port( + f"{symbol}.US", + "limit", + "sell", + quantity, + translator("limit_sell", symbol=symbol, qty=quantity, price=limit_price), + submitted_price=limit_price, + ) + else: + if dry_run_only: + submitted = record_dry_run( + f"{symbol}.US", + "sell", + quantity, + round(price, 2), + order_type="market", + ) + else: + submitted = submit_order_via_port( + f"{symbol}.US", + "market", + "sell", + quantity, + translator("market_sell", symbol=symbol, qty=quantity, price=round(price, 2)), + ) + + if submitted: + action_done = True + sell_submitted = True + elif sellable_quantities[symbol] <= 0 and quantities[symbol] > 0: + record_skip_log( + 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)" + ), + ) + + if sell_submitted: + previous_investable_cash = investable_cash + refresh_attempts = max(1, int(post_sell_refresh_attempts or 1)) + refresh_interval = max(0.0, float(post_sell_refresh_interval_sec or 0.0)) + best_refreshed_state = None + best_investable_cash = previous_investable_cash + for attempt in range(refresh_attempts): + if attempt > 0: + sleeper(refresh_interval) + refreshed_state = fetch_replanned_state() + refreshed_execution = refreshed_state[2] + refreshed_investable_cash = float(refreshed_execution["investable_cash"]) + if best_refreshed_state is None or refreshed_investable_cash > best_investable_cash: + best_refreshed_state = refreshed_state + best_investable_cash = refreshed_investable_cash + if refreshed_investable_cash > previous_investable_cash: + best_refreshed_state = refreshed_state + break + plan, portfolio, execution, allocation = best_refreshed_state + threshold_value = float(execution["trade_threshold_value"]) + limit_order_symbols = set( + allocation.get("risk_symbols", ()) + allocation.get("income_symbols", ()) + ) + strategy_assets = tuple(allocation["strategy_symbols"]) + market_values = dict(portfolio["market_values"]) + quantities = dict(portfolio["quantities"]) + sellable_quantities = dict(portfolio["sellable_quantities"]) + target_values = dict(allocation["targets"]) + available_cash = float(portfolio["liquid_cash"]) + 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"]) + + if ( + available_cash <= 0.0 + and investable_cash <= 0.0 + and _has_positive_non_usd_cash(cash_by_currency) + ): + record_note_log( + note_logs, + translator=translator, + with_prefix=with_prefix, + kind="buy_deferred_non_usd_cash", + available=f"{available_cash:.2f}", + investable=f"{investable_cash:.2f}", + currencies=_format_cash_by_currency(cash_by_currency), + ) + + buy_candidates = [ + symbol + for symbol in strategy_assets + if (target_values[symbol] - market_values[symbol]) > threshold_value + and abs(target_values[symbol] - market_values[symbol]) > current_min_trade + ] + if buy_candidates and investable_cash <= 0: + buy_candidates = [] + + for symbol in buy_candidates: + diff = target_values[symbol] - market_values[symbol] + price = safe_quote_last_price( + f"{symbol}.US", + market_data_port=market_data_port, + notify_issue=notify_issue, + ) + if price is None: + continue + can_buy_value = min(diff, investable_cash) + if can_buy_value > price: + 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) + cash_limit_quantity = estimate_cash_buy_quantity_safe( + trade_context, + f"{symbol}.US", + order_kind, + ref_price, + estimate_max_purchase_quantity=estimate_max_purchase_quantity, + notify_issue=notify_issue, + ) + if cash_limit_quantity is None: + continue + + quantity = min(budget_quantity, cash_limit_quantity) + cost_estimate = 0.0 + if quantity <= 0: + record_note_log( + note_logs, + translator=translator, + with_prefix=with_prefix, + kind="buy_deferred_cash_limit", + symbol=f"{symbol}.US", + diff=f"{diff:.2f}", + budget_qty=budget_quantity, + ) + continue + + if is_limit_order: + if dry_run_only: + submitted = record_dry_run( + f"{symbol}.US", + "buy", + quantity, + ref_price, + order_type="limit", + ) + else: + submitted = submit_order_via_port( + f"{symbol}.US", + "limit", + "buy", + quantity, + translator("limit_buy", symbol=symbol, qty=quantity, price=ref_price), + submitted_price=ref_price, + ) + cost_estimate = quantity * budget_price + else: + if dry_run_only: + submitted = record_dry_run( + f"{symbol}.US", + "buy", + quantity, + round(price, 2), + order_type="market", + ) + else: + submitted = submit_order_via_port( + f"{symbol}.US", + "market", + "buy", + quantity, + translator("market_buy", symbol=symbol, qty=quantity, price=round(price, 2)), + ) + cost_estimate = quantity * budget_price + + if submitted: + investable_cash = max(0, investable_cash - cost_estimate) + action_done = True + else: + record_note_log( + note_logs, + translator=translator, + with_prefix=with_prefix, + kind="buy_deferred_small_cash", + symbol=f"{symbol}.US", + diff=f"{diff:.2f}", + investable=f"{investable_cash:.2f}", + price=f"{price:.2f}", + ) + + return ExecutionCycleResult( + plan=dict(plan or {}), + portfolio=dict(portfolio or {}), + execution=dict(execution or {}), + allocation=dict(allocation or {}), + logs=tuple(logs), + skip_logs=tuple(skip_logs), + note_logs=tuple(note_logs), + action_done=action_done, + ) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 3056cbc..f9f647d 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -2,13 +2,13 @@ from __future__ import annotations -import os import re -import traceback -from collections.abc import Mapping from datetime import datetime -from notifications.events import NotificationPublisher, RenderedNotification +from application.execution_service import execute_rebalance_cycle +from application.runtime_dependencies import LongBridgeRebalanceConfig, LongBridgeRebalanceRuntime +from notifications.events import NotificationPublisher +from notifications import renderers as notification_renderers _ZH_REASON_REPLACEMENTS = ( ("feature snapshot guard blocked execution", "特征快照校验阻止执行"), @@ -101,11 +101,6 @@ def _plan_allocation(plan): def _noop_sleep(_seconds): return None - -def _has_text(value): - return bool(str(value or "").strip()) - - def _translator_uses_zh(translator) -> bool: sample = str(translator("no_trades")) return any("\u4e00" <= ch <= "\u9fff" for ch in sample) @@ -170,35 +165,6 @@ def _build_benchmark_lines(execution, *, translator): ] -def _normalize_cash_by_currency(raw_cash) -> dict[str, float]: - if not isinstance(raw_cash, Mapping): - return {} - cash_by_currency: dict[str, float] = {} - for currency, amount in raw_cash.items(): - normalized_currency = str(currency or "").strip().upper() - if not normalized_currency: - continue - cash_by_currency[normalized_currency] = float(amount) - return cash_by_currency - - -def _format_cash_by_currency(cash_by_currency: Mapping[str, float]) -> str: - parts = [] - for currency in sorted(cash_by_currency, key=lambda value: (value != "USD", value)): - amount = float(cash_by_currency[currency]) - if amount == 0.0: - continue - parts.append(f"{currency} {amount:,.2f}") - return ", ".join(parts) - - -def _has_positive_non_usd_cash(cash_by_currency: Mapping[str, float]) -> bool: - return any( - currency != "USD" and float(amount) > 0.0 - for currency, amount in cash_by_currency.items() - ) - - def _format_dashboard_text(text) -> str: return "\n".join( line.rstrip() @@ -263,73 +229,30 @@ def _append_strategy_line(lines, *, strategy_display_name, translator): lines.append(translator("strategy_label", name=name)) +_localize_notification_text = notification_renderers._localize_notification_text +_format_dashboard_text = notification_renderers._format_dashboard_text +_append_status_lines = notification_renderers._append_status_lines -def record_skip_log(skip_logs, *, translator, with_prefix, kind, detail): - message = translator(kind, detail=detail) - skip_logs.append(message) - print(with_prefix(message), flush=True) - - -def record_note_log(note_logs, *, translator, with_prefix, kind, **kwargs): - detail = translator(kind, **kwargs) - message = translator("buy_deferred", detail=detail) - note_logs.append(message) - print(with_prefix(message), flush=True) def run_strategy( *, - project_id, - secret_name, - token_refresh_threshold_days, - limit_sell_discount, - limit_buy_premium, - separator, - translator, - with_prefix, - send_tg_message, - notify_issue, - fetch_token_from_secret, - refresh_token_if_needed, - build_contexts, - calculate_strategy_indicators, - fetch_strategy_account_state, - resolve_rebalance_plan, - fetch_last_price, - estimate_max_purchase_quantity, - submit_order_with_alert, - dry_run_only=False, - strategy_display_name="", - post_sell_refresh_attempts=1, - post_sell_refresh_interval_sec=0.0, - sleeper=_noop_sleep, + runtime: LongBridgeRebalanceRuntime, + config: LongBridgeRebalanceConfig, ): - print(with_prefix(f"[{datetime.now()}] Starting strategy..."), flush=True) + print(config.with_prefix(f"[{datetime.now()}] Starting strategy..."), flush=True) notification_publisher = NotificationPublisher( - log_message=lambda message: print(with_prefix(message), flush=True), - send_message=send_tg_message, + log_message=lambda message: print(config.with_prefix(message), flush=True), + send_message=runtime.notifications.send_text, ) + quote_context, trade_context, indicators = runtime.bootstrap() + market_data_port = runtime.market_data_port_factory(quote_context) + execution_port = runtime.execution_port_factory(trade_context) - token = refresh_token_if_needed( - fetch_token_from_secret(project_id, secret_name), - project_id=project_id, - secret_name=secret_name, - app_key=os.getenv("LONGPORT_APP_KEY"), - app_secret=os.getenv("LONGPORT_APP_SECRET"), - refresh_threshold_days=token_refresh_threshold_days, - ) - app_key = os.getenv("LONGPORT_APP_KEY", "") - app_secret = os.getenv("LONGPORT_APP_SECRET", "") - quote_context, trade_context = build_contexts(app_key, app_secret, token) - - indicators = calculate_strategy_indicators(quote_context) - if indicators is None: - raise Exception("Quote data missing or API limited; cannot compute indicators") - - def load_plan(current_account_state): - current_plan = resolve_rebalance_plan( + def load_plan(*, current_snapshot): + current_plan = runtime.resolve_rebalance_plan( indicators=indicators, - account_state=current_account_state, + snapshot=current_snapshot, ) current_portfolio = _plan_portfolio(current_plan) current_execution = _plan_execution(current_plan) @@ -339,415 +262,63 @@ def load_plan(current_account_state): return current_plan, current_portfolio, current_execution, current_allocation def fetch_replanned_state(): - current_account_state = fetch_strategy_account_state(quote_context, trade_context) - return load_plan(current_account_state) + current_snapshot = runtime.portfolio_port_factory( + quote_context, + trade_context, + ).get_portfolio_snapshot() + return load_plan(current_snapshot=current_snapshot) plan, portfolio, execution, allocation = fetch_replanned_state() - logs = [] - skip_logs = [] - note_logs = [] - action_done = False - sell_submitted = False - threshold_value = float(execution["trade_threshold_value"]) - limit_order_symbols = set( - allocation.get("risk_symbols", ()) + allocation.get("income_symbols", ()) + execution_result = execute_rebalance_cycle( + trade_context=trade_context, + plan=plan, + portfolio=portfolio, + execution=execution, + allocation=allocation, + fetch_replanned_state=fetch_replanned_state, + market_data_port=market_data_port, + estimate_max_purchase_quantity=runtime.estimate_max_purchase_quantity, + execution_port=execution_port, + post_submit_order=runtime.post_submit_order, + notify_issue=runtime.notify_issue, + translator=config.translator, + with_prefix=config.with_prefix, + limit_sell_discount=config.limit_sell_discount, + limit_buy_premium=config.limit_buy_premium, + 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, + sleeper=config.sleeper or _noop_sleep, ) - - strategy_assets = tuple(allocation["strategy_symbols"]) - market_values = dict(portfolio["market_values"]) - quantities = dict(portfolio["quantities"]) - sellable_quantities = dict(portfolio["sellable_quantities"]) - target_values = dict(allocation["targets"]) - available_cash = float(portfolio["liquid_cash"]) - 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"]) - def record_dry_run(symbol, side, quantity, price, *, order_type): - price_text = f"${price:.2f}" if price is not None else translator("order_type_market") - side_key = "side_buy" if str(side).lower() == "buy" else "side_sell" - order_type_key = "order_type_limit" if order_type == "limit" else "order_type_market" - message = translator( - "dry_run_order", - side=translator(side_key), - symbol=symbol, - qty=quantity, - price=price_text, - order_type=translator(order_type_key), - ) - logs.append(message) - print(with_prefix(message), flush=True) - return True - - for symbol in strategy_assets: - diff = target_values[symbol] - market_values[symbol] - if diff < -threshold_value and abs(diff) > current_min_trade: - price = safe_quote_last_price( - quote_context, - f"{symbol}.US", - fetch_last_price=fetch_last_price, - notify_issue=notify_issue, - ) - if price is None: - continue - quantity = min( - int(abs(diff) // price), - sellable_quantities[symbol], - ) - if quantity > 0: - 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, - limit_price, - order_type="limit", - ) - else: - submitted = submit_order_with_alert( - trade_context, - f"{symbol}.US", - "limit", - "sell", - quantity, - logs, - translator("limit_sell", symbol=symbol, qty=quantity, price=limit_price), - submitted_price=limit_price, - ) - else: - if dry_run_only: - submitted = record_dry_run( - f"{symbol}.US", - "sell", - quantity, - round(price, 2), - order_type="market", - ) - else: - submitted = submit_order_with_alert( - trade_context, - f"{symbol}.US", - "market", - "sell", - quantity, - logs, - translator("market_sell", symbol=symbol, qty=quantity, price=round(price, 2)), - ) - - if submitted: - action_done = True - sell_submitted = True - elif sellable_quantities[symbol] <= 0 and quantities[symbol] > 0: - record_skip_log( - 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)" - ), - ) - - if sell_submitted: - previous_investable_cash = investable_cash - refresh_attempts = max(1, int(post_sell_refresh_attempts or 1)) - refresh_interval = max(0.0, float(post_sell_refresh_interval_sec or 0.0)) - best_refreshed_state = None - best_investable_cash = previous_investable_cash - for attempt in range(refresh_attempts): - if attempt > 0: - sleeper(refresh_interval) - refreshed_state = fetch_replanned_state() - refreshed_execution = refreshed_state[2] - refreshed_investable_cash = float(refreshed_execution["investable_cash"]) - if best_refreshed_state is None or refreshed_investable_cash > best_investable_cash: - best_refreshed_state = refreshed_state - best_investable_cash = refreshed_investable_cash - if refreshed_investable_cash > previous_investable_cash: - best_refreshed_state = refreshed_state - break - plan, portfolio, execution, allocation = best_refreshed_state - threshold_value = float(execution["trade_threshold_value"]) - limit_order_symbols = set( - allocation.get("risk_symbols", ()) + allocation.get("income_symbols", ()) - ) - strategy_assets = tuple(allocation["strategy_symbols"]) - market_values = dict(portfolio["market_values"]) - quantities = dict(portfolio["quantities"]) - sellable_quantities = dict(portfolio["sellable_quantities"]) - target_values = dict(allocation["targets"]) - available_cash = float(portfolio["liquid_cash"]) - 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"]) - - if ( - available_cash <= 0.0 - and investable_cash <= 0.0 - and _has_positive_non_usd_cash(cash_by_currency) - ): - record_note_log( - note_logs, - translator=translator, - with_prefix=with_prefix, - kind="buy_deferred_non_usd_cash", - available=f"{available_cash:.2f}", - investable=f"{investable_cash:.2f}", - currencies=_format_cash_by_currency(cash_by_currency), - ) - buy_candidates = [ - symbol - for symbol in strategy_assets - if (target_values[symbol] - market_values[symbol]) > threshold_value - and abs(target_values[symbol] - market_values[symbol]) > current_min_trade - ] - if buy_candidates and investable_cash <= 0: - buy_candidates = [] - for symbol in buy_candidates: - diff = target_values[symbol] - market_values[symbol] - price = safe_quote_last_price( - quote_context, - f"{symbol}.US", - fetch_last_price=fetch_last_price, - notify_issue=notify_issue, - ) - if price is None: - continue - can_buy_value = min(diff, investable_cash) - if can_buy_value > price: - 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) - cash_limit_quantity = estimate_cash_buy_quantity_safe( - trade_context, - f"{symbol}.US", - order_kind, - ref_price, - estimate_max_purchase_quantity=estimate_max_purchase_quantity, - notify_issue=notify_issue, - ) - if cash_limit_quantity is None: - continue - - quantity = min(budget_quantity, cash_limit_quantity) - cost_estimate = 0.0 - if quantity <= 0: - record_note_log( - note_logs, - translator=translator, - with_prefix=with_prefix, - kind="buy_deferred_cash_limit", - symbol=f"{symbol}.US", - diff=f"{diff:.2f}", - budget_qty=budget_quantity, - ) - continue - - if is_limit_order: - if dry_run_only: - submitted = record_dry_run( - f"{symbol}.US", - "buy", - quantity, - ref_price, - order_type="limit", - ) - else: - submitted = submit_order_with_alert( - trade_context, - f"{symbol}.US", - "limit", - "buy", - quantity, - logs, - translator("limit_buy", symbol=symbol, qty=quantity, price=ref_price), - submitted_price=ref_price, - ) - cost_estimate = quantity * budget_price - else: - if dry_run_only: - submitted = record_dry_run( - f"{symbol}.US", - "buy", - quantity, - round(price, 2), - order_type="market", - ) - else: - submitted = submit_order_with_alert( - trade_context, - f"{symbol}.US", - "market", - "buy", - quantity, - logs, - translator("market_buy", symbol=symbol, qty=quantity, price=round(price, 2)), - ) - cost_estimate = quantity * budget_price - - if submitted: - investable_cash = max(0, investable_cash - cost_estimate) - action_done = True - else: - record_note_log( - note_logs, - translator=translator, - with_prefix=with_prefix, - kind="buy_deferred_small_cash", - symbol=f"{symbol}.US", - diff=f"{diff:.2f}", - investable=f"{investable_cash:.2f}", - price=f"{price:.2f}", - ) + execution = execution_result.execution + logs = list(execution_result.logs) + skip_logs = list(execution_result.skip_logs) + note_logs = list(execution_result.note_logs) + action_done = execution_result.action_done if action_done: - formatted_logs = "\n".join(f" - {log}" for log in [*logs, *skip_logs, *note_logs]) - tg_lines = [translator("rebalance_title")] - _append_strategy_line( - tg_lines, - strategy_display_name=strategy_display_name, - translator=translator, - ) - if dry_run_only: - tg_lines.append(translator("dry_run_banner")) - _append_dashboard_lines(tg_lines, execution=execution) - _append_status_lines( - tg_lines, - execution=execution, - translator=translator, - signal_key="signal", - ) - tg_lines.extend([separator, translator("order_logs_title"), formatted_logs]) - detailed_tg_message = "\n".join(tg_lines) - compact_lines = [translator("rebalance_title")] - _append_strategy_line( - compact_lines, - strategy_display_name=strategy_display_name, - translator=translator, - ) - if dry_run_only: - compact_lines.append(translator("dry_run_banner")) - _append_dashboard_lines(compact_lines, execution=execution) - _append_compact_status_lines( - compact_lines, - execution=execution, - translator=translator, - signal_key="signal", - ) - compact_lines.extend([separator, translator("order_logs_title"), formatted_logs]) - compact_tg_message = "\n".join(compact_lines) notification_publisher.publish( - RenderedNotification( - detailed_text=detailed_tg_message, - compact_text=compact_tg_message, + notification_renderers.render_rebalance_notification( + execution=execution, + logs=logs, + skip_logs=skip_logs, + note_logs=note_logs, + translator=config.translator, + separator=config.separator, + strategy_display_name=config.strategy_display_name, + dry_run_only=config.dry_run_only, ) ) else: - no_trade_lines = [ - translator("heartbeat_title"), - ] - _append_strategy_line( - no_trade_lines, - strategy_display_name=strategy_display_name, - translator=translator, - ) - if dry_run_only: - no_trade_lines.append(translator("dry_run_banner")) - _append_dashboard_lines(no_trade_lines, execution=execution) - no_trade_lines.append(separator) - _append_status_lines( - no_trade_lines, - execution=execution, - translator=translator, - signal_key="heartbeat_signal", - ) - no_trade_lines.extend( - [ - separator, - translator("no_executable_orders") if (skip_logs or note_logs) else translator("no_trades"), - ] - ) - no_trade_message = "\n".join(no_trade_lines) - if skip_logs: - no_trade_message += ( - f"\n{separator}\n" - f"{translator('skipped_actions')}\n" - + "\n".join(f" - {log}" for log in skip_logs) - ) - if note_logs: - no_trade_message += ( - f"\n{separator}\n" - f"{translator('notes_title')}\n" - + "\n".join(f" - {log}" for log in note_logs) - ) - compact_no_trade_lines = [ - translator("heartbeat_title"), - ] - _append_strategy_line( - compact_no_trade_lines, - strategy_display_name=strategy_display_name, - translator=translator, - ) - if dry_run_only: - compact_no_trade_lines.append(translator("dry_run_banner")) - _append_dashboard_lines(compact_no_trade_lines, execution=execution) - _append_compact_status_lines( - compact_no_trade_lines, - execution=execution, - translator=translator, - signal_key="heartbeat_signal", - ) - compact_no_trade_lines.append( - translator("no_executable_orders") if (skip_logs or note_logs) else translator("no_trades") - ) - if skip_logs: - compact_no_trade_lines.extend([separator, translator("skipped_actions")]) - compact_no_trade_lines.extend(f" - {log}" for log in skip_logs) - if note_logs: - compact_no_trade_lines.extend([separator, translator("notes_title")]) - compact_no_trade_lines.extend(f" - {log}" for log in note_logs) - compact_no_trade_message = "\n".join(compact_no_trade_lines) notification_publisher.publish( - RenderedNotification( - detailed_text=no_trade_message, - compact_text=compact_no_trade_message, + notification_renderers.render_heartbeat_notification( + execution=execution, + skip_logs=skip_logs, + note_logs=note_logs, + translator=config.translator, + separator=config.separator, + strategy_display_name=config.strategy_display_name, + dry_run_only=config.dry_run_only, ) ) - - -def safe_quote_last_price(quote_context, symbol, *, fetch_last_price, notify_issue): - try: - return fetch_last_price(quote_context, symbol) - except Exception as exc: - notify_issue("Quote failed", f"Symbol: {symbol}\n{exc}") - return None - - -def estimate_cash_buy_quantity_safe( - trade_context, - symbol, - order_kind, - ref_price, - *, - estimate_max_purchase_quantity, - notify_issue, -): - try: - return estimate_max_purchase_quantity( - trade_context, - symbol, - order_kind=order_kind, - ref_price=ref_price, - ) - except Exception: - notify_issue( - "Estimate max buy failed", - f"Symbol: {symbol}\nOrderKind: {order_kind}\n{traceback.format_exc()}", - ) - return None diff --git a/application/runtime_bootstrap_adapters.py b/application/runtime_bootstrap_adapters.py new file mode 100644 index 0000000..e251aba --- /dev/null +++ b/application/runtime_bootstrap_adapters.py @@ -0,0 +1,67 @@ +"""Builder helpers for LongBridge runtime bootstrap assembly.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import os +from typing import Any + + +@dataclass(frozen=True) +class LongBridgeRuntimeBootstrap: + project_id: str | None + secret_name: str + token_refresh_threshold_days: int + fetch_token_from_secret_fn: Callable[[str | None, str], str] + refresh_token_if_needed_fn: Callable[..., str] + build_contexts_fn: Callable[[str, str, str], tuple[Any, Any]] + calculate_strategy_indicators_fn: Callable[[Any], Any] + env_reader: Callable[[str, str], str | None] = os.getenv + app_key_env_name: str = "LONGPORT_APP_KEY" + app_secret_env_name: str = "LONGPORT_APP_SECRET" + + def _read_app_credentials(self) -> tuple[str, str]: + return ( + str(self.env_reader(self.app_key_env_name, "") or ""), + str(self.env_reader(self.app_secret_env_name, "") or ""), + ) + + def __call__(self) -> tuple[Any, Any, Any]: + app_key, app_secret = self._read_app_credentials() + token = self.refresh_token_if_needed_fn( + self.fetch_token_from_secret_fn(self.project_id, self.secret_name), + project_id=self.project_id, + secret_name=self.secret_name, + app_key=app_key, + app_secret=app_secret, + refresh_threshold_days=self.token_refresh_threshold_days, + ) + quote_context, trade_context = self.build_contexts_fn(app_key, app_secret, token) + indicators = self.calculate_strategy_indicators_fn(quote_context) + if indicators is None: + raise Exception("Quote data missing or API limited; cannot compute indicators") + return quote_context, trade_context, indicators + + +def build_runtime_bootstrap( + *, + project_id: str | None, + secret_name: str, + token_refresh_threshold_days: int, + fetch_token_from_secret_fn: Callable[[str | None, str], str], + refresh_token_if_needed_fn: Callable[..., str], + build_contexts_fn: Callable[[str, str, str], tuple[Any, Any]], + calculate_strategy_indicators_fn: Callable[[Any], Any], + env_reader: Callable[[str, str], str | None] = os.getenv, +) -> LongBridgeRuntimeBootstrap: + return LongBridgeRuntimeBootstrap( + project_id=project_id, + secret_name=secret_name, + token_refresh_threshold_days=int(token_refresh_threshold_days), + fetch_token_from_secret_fn=fetch_token_from_secret_fn, + refresh_token_if_needed_fn=refresh_token_if_needed_fn, + build_contexts_fn=build_contexts_fn, + calculate_strategy_indicators_fn=calculate_strategy_indicators_fn, + env_reader=env_reader, + ) diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py new file mode 100644 index 0000000..256fc07 --- /dev/null +++ b/application/runtime_broker_adapters.py @@ -0,0 +1,221 @@ +"""Builder helpers for LongBridge broker-side runtime adapters.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +import pandas as pd + +from quant_platform_kit.common.models import PricePoint, PriceSeries, QuoteSnapshot +from quant_platform_kit.common.port_adapters import ( + CallableExecutionPort, + CallableMarketDataPort, + CallablePortfolioPort, +) +from quant_platform_kit.common.ports import ExecutionPort, MarketDataPort, PortfolioPort +from quant_platform_kit.strategy_contracts import ( + build_account_state_from_portfolio_snapshot, + build_portfolio_snapshot_from_account_state, +) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass(frozen=True) +class LongBridgeBrokerAdapters: + strategy_symbols: tuple[str, ...] + account_hash: str + fetch_last_price_fn: Callable[[Any, str], float | None] + fetch_strategy_account_state_fn: Callable[[Any, Any], Mapping[str, Any]] + submit_order_fn: Callable[..., Any] + clock: Callable[[], datetime] = _utcnow + price_history_lookback: int = 260 + + def normalize_market_symbol(self, symbol: str) -> str: + value = str(symbol or "").strip().upper() + if not value: + raise ValueError("Market data symbol must be non-empty.") + if "." not in value: + return f"{value}.US" + return value + + def fetch_daily_price_history(self, quote_context, symbol: str, *, lookback: int | None = None): + from longport.openapi import AdjustType, Period + + normalized_symbol = self.normalize_market_symbol(symbol) + bars = quote_context.candlesticks( + normalized_symbol, + Period.Day, + int(lookback or self.price_history_lookback), + AdjustType.ForwardAdjust, + ) + if not bars: + return None + history = [] + for bar in bars: + close = float(bar.close) + history.append( + { + "close": close, + "high": float(getattr(bar, "high", close)), + "low": float(getattr(bar, "low", close)), + "datetime": ( + getattr(bar, "timestamp", None) + or getattr(bar, "time", None) + or getattr(bar, "datetime", None) + ), + } + ) + return history + + def _coerce_history_datetime(self, raw_value, fallback_timestamp): + if raw_value is None: + return fallback_timestamp.to_pydatetime().replace(tzinfo=timezone.utc) + if isinstance(raw_value, datetime): + return ( + raw_value.astimezone(timezone.utc) + if raw_value.tzinfo is not None + else raw_value.replace(tzinfo=timezone.utc) + ) + if isinstance(raw_value, (int, float)): + unit = "ms" if abs(raw_value) > 10_000_000_000 else "s" + return pd.to_datetime(raw_value, unit=unit, utc=True).to_pydatetime() + timestamp = pd.Timestamp(raw_value) + if timestamp.tzinfo is None: + timestamp = timestamp.tz_localize("UTC") + else: + timestamp = timestamp.tz_convert("UTC") + return timestamp.to_pydatetime() + + def build_market_data_port(self, quote_context) -> MarketDataPort: + quote_cache: dict[str, QuoteSnapshot] = {} + price_series_cache: dict[str, PriceSeries] = {} + + def load_quote(symbol: str) -> QuoteSnapshot: + normalized_symbol = self.normalize_market_symbol(symbol) + cached = quote_cache.get(normalized_symbol) + if cached is not None: + return cached + price = self.fetch_last_price_fn(quote_context, normalized_symbol) + if price is None: + raise ValueError(f"Quote unavailable for {normalized_symbol}") + snapshot = QuoteSnapshot( + symbol=normalized_symbol, + as_of=self.clock(), + last_price=float(price), + ) + quote_cache[normalized_symbol] = snapshot + return snapshot + + def load_price_series(symbol: str) -> PriceSeries: + normalized_symbol = self.normalize_market_symbol(symbol) + cached = price_series_cache.get(normalized_symbol) + if cached is not None: + return cached + history = self.fetch_daily_price_history(quote_context, normalized_symbol) + if not history: + raise ValueError(f"Price history unavailable for {normalized_symbol}") + fallback_index = pd.bdate_range(end=pd.Timestamp.now("UTC").normalize(), periods=len(history)) + points = [] + for index, bar in enumerate(history): + points.append( + PricePoint( + as_of=self._coerce_history_datetime(bar.get("datetime"), fallback_index[index]), + close=float(bar["close"]), + ) + ) + series = PriceSeries( + symbol=normalized_symbol, + currency="USD", + points=tuple(points), + ) + price_series_cache[normalized_symbol] = series + return series + + return CallableMarketDataPort( + quote_loader=load_quote, + price_series_loader=load_price_series, + ) + + def build_market_history_loader(self, market_data_port: MarketDataPort): + def load_market_history(_broker_client, symbol, *_args, **_kwargs): + series = market_data_port.get_price_series(str(symbol).strip().upper()) + if not series.points: + return pd.Series(dtype=float) + index = pd.DatetimeIndex([pd.Timestamp(point.as_of) for point in series.points]) + closes = [float(point.close) for point in series.points] + return pd.Series(closes, index=index, dtype=float) + + return load_market_history + + def build_price_history(self, market_data_port: MarketDataPort, symbol: str): + series = market_data_port.get_price_series(symbol) + return [ + { + "close": float(point.close), + "high": float(point.close), + "low": float(point.close), + } + for point in series.points + ] + + def build_portfolio_snapshot_from_account_state(self, account_state): + return build_portfolio_snapshot_from_account_state( + account_state, + strategy_symbols=self.strategy_symbols, + metadata={"account_hash": self.account_hash}, + ) + + def build_account_state_from_snapshot(self, snapshot): + return build_account_state_from_portfolio_snapshot( + snapshot, + strategy_symbols=self.strategy_symbols, + ) + + def build_managed_portfolio_snapshot(self, quote_context, trade_context): + return self.build_portfolio_snapshot_from_account_state( + self.fetch_strategy_account_state_fn(quote_context, trade_context) + ) + + def build_portfolio_port(self, quote_context, trade_context) -> PortfolioPort: + return CallablePortfolioPort( + lambda: self.build_managed_portfolio_snapshot(quote_context, trade_context) + ) + + def build_execution_port(self, trade_context) -> ExecutionPort: + return CallableExecutionPort( + lambda order_intent: self.submit_order_fn( + trade_context, + str(order_intent.symbol), + order_kind=str(order_intent.order_type), + side=str(order_intent.side), + quantity=int(order_intent.quantity), + submitted_price=order_intent.limit_price, + ) + ) + + +def build_runtime_broker_adapters( + *, + strategy_symbols: tuple[str, ...], + account_hash: str, + fetch_last_price_fn: Callable[[Any, str], float | None], + fetch_strategy_account_state_fn: Callable[[Any, Any], Mapping[str, Any]], + submit_order_fn: Callable[..., Any], + clock: Callable[[], datetime] = _utcnow, + price_history_lookback: int = 260, +) -> LongBridgeBrokerAdapters: + return LongBridgeBrokerAdapters( + strategy_symbols=tuple(strategy_symbols), + account_hash=str(account_hash), + fetch_last_price_fn=fetch_last_price_fn, + fetch_strategy_account_state_fn=fetch_strategy_account_state_fn, + submit_order_fn=submit_order_fn, + clock=clock, + price_history_lookback=int(price_history_lookback), + ) diff --git a/application/runtime_composer.py b/application/runtime_composer.py new file mode 100644 index 0000000..641f579 --- /dev/null +++ b/application/runtime_composer.py @@ -0,0 +1,253 @@ +"""Top-level runtime composer for LongBridge application wiring.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import Any + +from application.runtime_bootstrap_adapters import build_runtime_bootstrap +from application.runtime_dependencies import LongBridgeRebalanceConfig, LongBridgeRebalanceRuntime +from application.runtime_notification_adapters import build_runtime_notification_adapters +from application.runtime_reporting_adapters import build_runtime_reporting_adapters +from notifications.telegram import build_prefixer, build_sender + + +@dataclass(frozen=True) +class LongBridgeRuntimeComposer: + project_id: str | None + secret_name: str + token_refresh_threshold_days: int + account_prefix: str + account_region: str + strategy_profile: str + strategy_display_name: str + strategy_display_name_localized: str + strategy_domain: str | None + notify_lang: str + tg_token: str | None + tg_chat_id: str | None + managed_symbols: tuple[str, ...] + benchmark_symbol: str + signal_effective_after_trading_days: int | None + separator: str + limit_sell_discount: float + limit_buy_premium: float + order_poll_interval_sec: int + order_poll_max_attempts: int + dry_run_only: bool = False + broker_adapters: Any = None + strategy_adapters: Any = None + estimate_max_purchase_quantity_fn: Callable[..., int] | 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 + build_contexts_fn: Callable[..., tuple[Any, Any]] | None = None + run_id_builder: Callable[[], str] | None = None + event_logger: Callable[..., dict[str, Any]] | None = None + report_builder: Callable[..., dict[str, Any]] | None = None + report_persister: Callable[..., Any] | None = None + translator: Callable[..., str] | None = None + prefixer_builder: Callable[..., Callable[[str], str]] = build_prefixer + sender_builder: Callable[..., Callable[[str], None]] = build_sender + env_reader: Callable[[str, str], str | None] | None = None + sleeper: Callable[[float], None] | None = None + printer: Callable[..., Any] = print + notification_adapter_builder: Callable[..., Any] = build_runtime_notification_adapters + reporting_adapter_builder: Callable[..., Any] = build_runtime_reporting_adapters + bootstrap_builder: Callable[..., Any] = build_runtime_bootstrap + extra_reporting_fields: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + required = { + "broker_adapters": self.broker_adapters, + "strategy_adapters": self.strategy_adapters, + "estimate_max_purchase_quantity_fn": self.estimate_max_purchase_quantity_fn, + "fetch_order_status_fn": self.fetch_order_status_fn, + "fetch_token_from_secret_fn": self.fetch_token_from_secret_fn, + "refresh_token_if_needed_fn": self.refresh_token_if_needed_fn, + "build_contexts_fn": self.build_contexts_fn, + "run_id_builder": self.run_id_builder, + "event_logger": self.event_logger, + "report_builder": self.report_builder, + "report_persister": self.report_persister, + "translator": self.translator, + "env_reader": self.env_reader, + "sleeper": self.sleeper, + } + missing = [name for name, value in required.items() if value is None] + if missing: + raise ValueError(f"Missing runtime composer dependencies: {', '.join(missing)}") + + def with_prefix(self, message: str) -> str: + return self.prefixer_builder(self.account_prefix)(message) + + def send_tg_message(self, message: str) -> None: + sender = self.sender_builder( + self.tg_token, + self.tg_chat_id, + with_prefix_fn=self.with_prefix, + ) + sender(message) + + def build_notification_adapters(self): + return self.notification_adapter_builder( + with_prefix=self.with_prefix, + send_message=self.send_tg_message, + translator=self.translator, + fetch_order_status=self.fetch_order_status_fn, + order_poll_interval_sec=self.order_poll_interval_sec, + order_poll_max_attempts=self.order_poll_max_attempts, + sleeper=self.sleeper, + log_message=lambda message: self.printer(self.with_prefix(message), flush=True), + ) + + def build_reporting_adapters(self): + return self.reporting_adapter_builder( + platform="longbridge", + deploy_target="cloud_run", + service_name=self.env_reader("K_SERVICE", "longbridge-platform"), + strategy_profile=self.strategy_profile, + strategy_domain=self.strategy_domain, + account_scope=self.account_region, + account_region=self.account_region, + project_id=self.project_id, + extra_context_fields={ + "account_prefix": self.account_prefix, + "strategy_display_name": self.strategy_display_name, + "strategy_display_name_localized": self.strategy_display_name_localized, + **dict(self.extra_reporting_fields), + }, + managed_symbols=self.managed_symbols, + account_prefix=self.account_prefix, + benchmark_symbol=self.benchmark_symbol, + strategy_display_name=self.strategy_display_name, + strategy_display_name_localized=self.strategy_display_name_localized, + dry_run=self.dry_run_only, + signal_effective_after_trading_days=self.signal_effective_after_trading_days, + report_base_dir=self.env_reader("EXECUTION_REPORT_OUTPUT_DIR", ""), + report_gcs_prefix_uri=self.env_reader("EXECUTION_REPORT_GCS_URI", ""), + run_id_builder=self.run_id_builder, + event_logger=self.event_logger, + report_builder=self.report_builder, + report_persister=self.report_persister, + printer=lambda line: self.printer(line, flush=True), + ) + + def build_rebalance_runtime(self) -> LongBridgeRebalanceRuntime: + notification_adapters = self.build_notification_adapters() + return LongBridgeRebalanceRuntime( + bootstrap=self.bootstrap_builder( + project_id=self.project_id, + secret_name=self.secret_name, + token_refresh_threshold_days=self.token_refresh_threshold_days, + fetch_token_from_secret_fn=self.fetch_token_from_secret_fn, + refresh_token_if_needed_fn=self.refresh_token_if_needed_fn, + build_contexts_fn=self.build_contexts_fn, + calculate_strategy_indicators_fn=self.strategy_adapters.calculate_strategy_indicators, + env_reader=self.env_reader, + ), + resolve_rebalance_plan=self.strategy_adapters.resolve_rebalance_plan, + market_data_port_factory=self.broker_adapters.build_market_data_port, + estimate_max_purchase_quantity=self.estimate_max_purchase_quantity_fn, + notifications=notification_adapters.notification_port, + notify_issue=notification_adapters.notify_issue, + portfolio_port_factory=self.broker_adapters.build_portfolio_port, + execution_port_factory=self.broker_adapters.build_execution_port, + post_submit_order=notification_adapters.post_submit_order, + ) + + def build_rebalance_config(self) -> LongBridgeRebalanceConfig: + return LongBridgeRebalanceConfig( + limit_sell_discount=self.limit_sell_discount, + limit_buy_premium=self.limit_buy_premium, + separator=self.separator, + translator=self.translator, + with_prefix=self.with_prefix, + strategy_display_name=self.strategy_display_name_localized, + 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, + sleeper=self.sleeper, + ) + + +def build_runtime_composer( + *, + project_id: str | None, + secret_name: str, + token_refresh_threshold_days: int, + account_prefix: str, + account_region: str, + strategy_profile: str, + strategy_display_name: str, + strategy_display_name_localized: str, + strategy_domain: str | None, + notify_lang: str, + tg_token: str | None, + tg_chat_id: str | None, + managed_symbols: tuple[str, ...], + benchmark_symbol: str, + signal_effective_after_trading_days: int | None, + separator: str, + limit_sell_discount: float, + limit_buy_premium: float, + order_poll_interval_sec: int, + order_poll_max_attempts: int, + dry_run_only: bool, + broker_adapters: Any, + strategy_adapters: Any, + estimate_max_purchase_quantity_fn: Callable[..., int], + fetch_order_status_fn: Callable[..., Any], + fetch_token_from_secret_fn: Callable[..., str], + refresh_token_if_needed_fn: Callable[..., str], + build_contexts_fn: Callable[..., tuple[Any, Any]], + run_id_builder: Callable[[], str], + event_logger: Callable[..., dict[str, Any]], + report_builder: Callable[..., dict[str, Any]], + report_persister: Callable[..., Any], + translator: Callable[..., str], + env_reader: Callable[[str, str], str | None], + sleeper: Callable[[float], None], + printer: Callable[..., Any] = print, + extra_reporting_fields: Mapping[str, Any] | None = None, +) -> LongBridgeRuntimeComposer: + return LongBridgeRuntimeComposer( + project_id=project_id, + secret_name=secret_name, + token_refresh_threshold_days=int(token_refresh_threshold_days), + account_prefix=str(account_prefix or ""), + account_region=str(account_region or ""), + strategy_profile=str(strategy_profile), + strategy_display_name=str(strategy_display_name or ""), + strategy_display_name_localized=str(strategy_display_name_localized or ""), + strategy_domain=strategy_domain, + notify_lang=str(notify_lang or ""), + tg_token=tg_token, + tg_chat_id=tg_chat_id, + managed_symbols=tuple(managed_symbols), + benchmark_symbol=str(benchmark_symbol or ""), + signal_effective_after_trading_days=signal_effective_after_trading_days, + separator=str(separator), + limit_sell_discount=float(limit_sell_discount), + 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), + dry_run_only=bool(dry_run_only), + broker_adapters=broker_adapters, + strategy_adapters=strategy_adapters, + estimate_max_purchase_quantity_fn=estimate_max_purchase_quantity_fn, + fetch_order_status_fn=fetch_order_status_fn, + fetch_token_from_secret_fn=fetch_token_from_secret_fn, + refresh_token_if_needed_fn=refresh_token_if_needed_fn, + build_contexts_fn=build_contexts_fn, + run_id_builder=run_id_builder, + event_logger=event_logger, + report_builder=report_builder, + report_persister=report_persister, + translator=translator, + env_reader=env_reader, + sleeper=sleeper, + printer=printer, + extra_reporting_fields=dict(extra_reporting_fields or {}), + ) diff --git a/application/runtime_dependencies.py b/application/runtime_dependencies.py new file mode 100644 index 0000000..0dd4604 --- /dev/null +++ b/application/runtime_dependencies.py @@ -0,0 +1,36 @@ +"""Runtime dependency bundles for LongBridge rebalance orchestration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from quant_platform_kit.common.ports import ExecutionPort, MarketDataPort, NotificationPort, PortfolioPort + + +@dataclass(frozen=True) +class LongBridgeRebalanceConfig: + limit_sell_discount: float + limit_buy_premium: float + separator: str + translator: Callable[..., str] + with_prefix: Callable[[str], str] + strategy_display_name: str = "" + dry_run_only: bool = False + post_sell_refresh_attempts: int = 1 + post_sell_refresh_interval_sec: float = 0.0 + sleeper: Callable[[float], None] | None = None + + +@dataclass(frozen=True) +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] + notifications: NotificationPort + notify_issue: Callable[[str, str], None] + portfolio_port_factory: Callable[[Any, Any], PortfolioPort] + execution_port_factory: Callable[[Any], ExecutionPort] + post_submit_order: Callable[[Any, Any, Any], None] | None = None diff --git a/application/runtime_notification_adapters.py b/application/runtime_notification_adapters.py new file mode 100644 index 0000000..a33deaf --- /dev/null +++ b/application/runtime_notification_adapters.py @@ -0,0 +1,87 @@ +"""Builder helpers for LongBridge runtime notification adapters.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from notifications.events import NotificationPublisher, RenderedNotification +from notifications.order_alerts import ( + OrderLifecycleEvent, + monitor_submitted_order_status, + publish_order_lifecycle_event, +) +from notifications.telegram import build_issue_notifier +from quant_platform_kit.common.port_adapters import CallableNotificationPort +from quant_platform_kit.common.ports import NotificationPort + + +@dataclass(frozen=True) +class LongBridgeNotificationAdapters: + notification_port: NotificationPort + notify_issue: Callable[[str, str], None] + post_submit_order: Callable[[Any, Any, Any], None] + cycle_publisher: NotificationPublisher + + def publish_cycle_notification(self, *, detailed_text: str, compact_text: str) -> None: + self.cycle_publisher.publish( + RenderedNotification( + detailed_text=detailed_text, + compact_text=compact_text, + ) + ) + + +def build_runtime_notification_adapters( + *, + with_prefix: Callable[[str], str], + send_message: Callable[[str], None], + translator: Callable[..., str], + fetch_order_status: Callable[..., Any], + order_poll_interval_sec: int, + order_poll_max_attempts: int, + sleeper: Callable[[float], None], + log_message: Callable[[str], None] | None = None, +) -> LongBridgeNotificationAdapters: + cycle_publisher = NotificationPublisher( + log_message=log_message or (lambda message: print(with_prefix(message), flush=True)), + send_message=send_message, + ) + notify_issue = build_issue_notifier( + with_prefix_fn=with_prefix, + send_tg_message_fn=send_message, + ) + order_event_publisher = NotificationPublisher( + log_message=lambda _message: None, + send_message=send_message, + ) + + def publish_order_event(event: OrderLifecycleEvent) -> None: + publish_order_lifecycle_event( + event, + translator=translator, + publisher=order_event_publisher, + ) + + def post_submit_order(trade_context, order_intent, report) -> None: + monitor_submitted_order_status( + trade_context, + str(order_intent.symbol), + "Buy" if str(order_intent.side).lower() == "buy" else "Sell", + int(order_intent.quantity), + report.broker_order_id or "", + fetch_order_status=fetch_order_status, + order_poll_interval_sec=order_poll_interval_sec, + order_poll_max_attempts=order_poll_max_attempts, + publish_order_event=publish_order_event, + notify_issue=notify_issue, + sleeper=sleeper, + ) + + return LongBridgeNotificationAdapters( + notification_port=CallableNotificationPort(send_message), + notify_issue=notify_issue, + post_submit_order=post_submit_order, + cycle_publisher=cycle_publisher, + ) diff --git a/application/runtime_reporting_adapters.py b/application/runtime_reporting_adapters.py new file mode 100644 index 0000000..47ec332 --- /dev/null +++ b/application/runtime_reporting_adapters.py @@ -0,0 +1,166 @@ +"""Builder helpers for LongBridge runtime reporting and structured logging.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +from quant_platform_kit.strategy_contracts import build_execution_timing_metadata +from runtime_logging import RuntimeLogContext + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass(frozen=True) +class LongBridgeRuntimeReportingAdapters: + platform: str + deploy_target: str + service_name: str + strategy_profile: str + strategy_domain: str | None + account_scope: str | None + account_region: str | None + project_id: str | None + extra_context_fields: Mapping[str, Any] = field(default_factory=dict) + managed_symbols: tuple[str, ...] = () + account_prefix: str = "" + benchmark_symbol: str = "" + strategy_display_name: str = "" + strategy_display_name_localized: str = "" + dry_run: bool = False + signal_effective_after_trading_days: int | None = None + report_base_dir: str | None = None + report_gcs_prefix_uri: str | None = None + run_id_builder: Callable[[], str] | None = None + event_logger: Callable[..., dict[str, Any]] | None = None + report_builder: Callable[..., dict[str, Any]] | None = None + report_persister: Callable[..., Any] | None = None + printer: Callable[..., Any] = print + clock: Callable[[], datetime] = _utcnow + + def __post_init__(self) -> None: + if self.run_id_builder is None: + raise ValueError("run_id_builder is required") + if self.event_logger is None: + raise ValueError("event_logger is required") + if self.report_builder is None: + raise ValueError("report_builder is required") + if self.report_persister is None: + raise ValueError("report_persister is required") + + def start_run(self) -> tuple[RuntimeLogContext, dict[str, Any]]: + started_at = self.clock() + timing_summary = build_execution_timing_metadata( + signal_date=started_at, + signal_effective_after_trading_days=self.signal_effective_after_trading_days, + ) + log_context = RuntimeLogContext( + platform=self.platform, + deploy_target=self.deploy_target, + service_name=self.service_name, + strategy_profile=self.strategy_profile, + account_scope=self.account_scope, + account_region=self.account_region, + project_id=self.project_id, + extra_fields=dict(self.extra_context_fields), + ).with_run(self.run_id_builder()) + report = self.report_builder( + platform=log_context.platform, + deploy_target=log_context.deploy_target, + service_name=log_context.service_name, + strategy_profile=self.strategy_profile, + strategy_domain=self.strategy_domain, + account_scope=log_context.account_scope, + account_region=log_context.account_region, + run_id=log_context.run_id, + run_source="cloud_run", + dry_run=self.dry_run, + started_at=started_at, + summary={ + "managed_symbols": list(self.managed_symbols), + "account_prefix": self.account_prefix, + "benchmark_symbol": self.benchmark_symbol, + "strategy_display_name": self.strategy_display_name, + "strategy_display_name_localized": self.strategy_display_name_localized, + **timing_summary, + }, + ) + return log_context, report + + def log_event(self, log_context: RuntimeLogContext, event: str, **fields: Any) -> dict[str, Any]: + return self.event_logger( + log_context, + event, + printer=self.printer, + **fields, + ) + + def persist_execution_report(self, report: dict[str, Any]) -> str | None: + persisted = self.report_persister( + report, + base_dir=self.report_base_dir, + gcs_prefix_uri=self.report_gcs_prefix_uri, + gcp_project_id=self.project_id, + ) + if isinstance(persisted, str): + return persisted + return getattr(persisted, "gcs_uri", None) or getattr(persisted, "local_path", None) + + +def build_runtime_reporting_adapters( + *, + platform: str, + deploy_target: str, + service_name: str, + strategy_profile: str, + strategy_domain: str | None, + account_scope: str | None, + account_region: str | None, + project_id: str | None, + extra_context_fields: Mapping[str, Any] | None = None, + managed_symbols: tuple[str, ...], + account_prefix: str = "", + benchmark_symbol: str = "", + strategy_display_name: str = "", + strategy_display_name_localized: str = "", + dry_run: bool = False, + signal_effective_after_trading_days: int | None = None, + report_base_dir: str | None = None, + report_gcs_prefix_uri: str | None = None, + run_id_builder: Callable[[], str], + event_logger: Callable[..., dict[str, Any]], + report_builder: Callable[..., dict[str, Any]], + report_persister: Callable[..., Any], + printer: Callable[..., Any] = print, + clock: Callable[[], datetime] = _utcnow, +) -> LongBridgeRuntimeReportingAdapters: + return LongBridgeRuntimeReportingAdapters( + platform=platform, + deploy_target=deploy_target, + service_name=service_name, + strategy_profile=strategy_profile, + strategy_domain=strategy_domain, + account_scope=account_scope, + account_region=account_region, + project_id=project_id, + extra_context_fields=dict(extra_context_fields or {}), + managed_symbols=tuple(managed_symbols), + account_prefix=str(account_prefix or ""), + benchmark_symbol=str(benchmark_symbol or ""), + strategy_display_name=str(strategy_display_name or ""), + strategy_display_name_localized=str(strategy_display_name_localized or ""), + dry_run=bool(dry_run), + signal_effective_after_trading_days=signal_effective_after_trading_days, + report_base_dir=report_base_dir, + report_gcs_prefix_uri=report_gcs_prefix_uri, + run_id_builder=run_id_builder, + event_logger=event_logger, + report_builder=report_builder, + report_persister=report_persister, + printer=printer, + clock=clock, + ) diff --git a/application/runtime_strategy_adapters.py b/application/runtime_strategy_adapters.py new file mode 100644 index 0000000..26a508f --- /dev/null +++ b/application/runtime_strategy_adapters.py @@ -0,0 +1,115 @@ +"""Builder helpers for LongBridge strategy evaluation adapters.""" + +from __future__ import annotations + +from collections.abc import Collection, Mapping, Callable +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class LongBridgeRuntimeStrategyAdapters: + strategy_runtime: Any + strategy_profile: str + strategy_runtime_config: Mapping[str, Any] + available_inputs: Collection[str] + benchmark_symbol: str + signal_text_fn: Callable[[str], str] + translator: Callable[..., str] + broker_adapters: Any + calculate_rotation_indicators_fn: Callable[..., Any] + build_strategy_evaluation_inputs_fn: Callable[..., dict[str, Any]] + map_strategy_decision_to_plan_fn: Callable[..., dict[str, Any]] + + def calculate_strategy_indicators(self, quote_context): + available_inputs = set(self.available_inputs) + if "feature_snapshot" in available_inputs and not ( + {"benchmark_history", "qqq_history", "derived_indicators", "indicators"} & available_inputs + ): + return {} + if "market_history" in available_inputs or "benchmark_history" in available_inputs or "qqq_history" in available_inputs: + market_data_port = self.broker_adapters.build_market_data_port(quote_context) + if "market_history" in available_inputs: + market_inputs = { + "market_history": self.broker_adapters.build_market_history_loader(market_data_port), + } + if "benchmark_history" in available_inputs: + market_inputs["benchmark_history"] = self.broker_adapters.build_price_history( + market_data_port, + self.benchmark_symbol, + ) + if "qqq_history" in available_inputs: + market_inputs["qqq_history"] = self.broker_adapters.build_price_history( + market_data_port, + self.benchmark_symbol, + ) + return market_inputs + if "benchmark_history" in available_inputs or "qqq_history" in available_inputs: + return self.broker_adapters.build_price_history(market_data_port, self.benchmark_symbol) + trend_ma_window = int(self.strategy_runtime_config.get("trend_ma_window", 150)) + return self.calculate_rotation_indicators_fn(quote_context, trend_window=trend_ma_window) + + def resolve_rebalance_plan(self, *, indicators, snapshot=None, account_state=None): + available_inputs = set(self.available_inputs) + resolved_snapshot = snapshot + if resolved_snapshot is None and account_state is not None: + resolved_snapshot = self.broker_adapters.build_portfolio_snapshot_from_account_state(account_state) + resolved_account_state = account_state + if resolved_account_state is None and "account_state" in available_inputs and resolved_snapshot is not None: + resolved_account_state = self.broker_adapters.build_account_state_from_snapshot(resolved_snapshot) + market_inputs = { + "market_history": indicators, + "derived_indicators": indicators, + "indicators": indicators, + "benchmark_history": indicators, + "qqq_history": indicators, + } + if isinstance(indicators, dict) and any( + key in indicators for key in ("market_history", "benchmark_history", "qqq_history") + ): + market_inputs.update(indicators) + evaluation_inputs = self.build_strategy_evaluation_inputs_fn( + available_inputs=available_inputs, + market_inputs=market_inputs, + portfolio_snapshot=resolved_snapshot, + account_state=resolved_account_state, + translator=self.translator, + signal_text_fn=self.signal_text_fn, + ) + evaluation = self.strategy_runtime.evaluate(**evaluation_inputs) + return self.map_strategy_decision_to_plan_fn( + evaluation.decision, + account_state=resolved_account_state if "account_state" in available_inputs else None, + snapshot=resolved_snapshot, + strategy_profile=self.strategy_profile, + runtime_metadata=getattr(evaluation, "metadata", None), + ) + + +def build_runtime_strategy_adapters( + *, + strategy_runtime: Any, + strategy_profile: str, + strategy_runtime_config: Mapping[str, Any], + available_inputs: Collection[str], + benchmark_symbol: str, + signal_text_fn: Callable[[str], str], + translator: Callable[..., str], + broker_adapters: Any, + calculate_rotation_indicators_fn: Callable[..., Any], + build_strategy_evaluation_inputs_fn: Callable[..., dict[str, Any]], + map_strategy_decision_to_plan_fn: Callable[..., dict[str, Any]], +) -> LongBridgeRuntimeStrategyAdapters: + return LongBridgeRuntimeStrategyAdapters( + strategy_runtime=strategy_runtime, + strategy_profile=str(strategy_profile), + strategy_runtime_config=dict(strategy_runtime_config), + available_inputs=tuple(available_inputs), + benchmark_symbol=str(benchmark_symbol), + signal_text_fn=signal_text_fn, + translator=translator, + broker_adapters=broker_adapters, + calculate_rotation_indicators_fn=calculate_rotation_indicators_fn, + build_strategy_evaluation_inputs_fn=build_strategy_evaluation_inputs_fn, + map_strategy_decision_to_plan_fn=map_strategy_decision_to_plan_fn, + ) diff --git a/decision_mapper.py b/decision_mapper.py index d51798c..3d52689 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -90,10 +90,16 @@ def _build_weight_translation_annotations( *, total_equity: float, liquid_cash: float, + runtime_metadata: Mapping[str, Any] | None = None, ) -> ValueTargetExecutionAnnotations: - diagnostics = dict(decision.diagnostics) + diagnostics = {**dict(runtime_metadata or {}), **dict(decision.diagnostics)} + execution_annotations: dict[str, Any] = {} + raw_runtime_annotations = runtime_metadata.get("execution_annotations") if isinstance(runtime_metadata, Mapping) else None + if isinstance(raw_runtime_annotations, Mapping): + execution_annotations.update(raw_runtime_annotations) raw_annotations = diagnostics.get("execution_annotations") - execution_annotations = dict(raw_annotations) if isinstance(raw_annotations, Mapping) else {} + if isinstance(raw_annotations, Mapping): + execution_annotations.update(raw_annotations) threshold_value = _default_threshold_value(total_equity) signal_display = str( diagnostics.get("signal_description") @@ -135,6 +141,40 @@ def _build_weight_translation_annotations( if diagnostics.get("exit_line") is not None else None ), + signal_date=( + str(execution_annotations.get("signal_date") or diagnostics.get("signal_date") or "").strip() or None + ), + effective_date=( + str(execution_annotations.get("effective_date") or diagnostics.get("effective_date") or "").strip() + or None + ), + execution_timing_contract=( + str( + execution_annotations.get("execution_timing_contract") + or diagnostics.get("execution_timing_contract") + or "" + ).strip() + or None + ), + execution_calendar_source=( + str( + execution_annotations.get("execution_calendar_source") + or diagnostics.get("execution_calendar_source") + or "" + ).strip() + or None + ), + signal_effective_after_trading_days=( + int(signal_delay) + if ( + signal_delay := execution_annotations.get( + "signal_effective_after_trading_days", + diagnostics.get("signal_effective_after_trading_days"), + ) + ) + is not None + else None + ), current_min_trade=threshold_value, investable_cash=max(0.0, float(liquid_cash)), ) @@ -157,6 +197,7 @@ def _normalize_to_value_target_decision( decision: StrategyDecision, *, portfolio_inputs, + runtime_metadata: Mapping[str, Any] | None = None, ) -> tuple[StrategyDecision, ValueTargetExecutionAnnotations | None]: target_mode = resolve_decision_target_mode(decision) no_execute = "no_execute" in set(decision.risk_flags) @@ -174,6 +215,7 @@ def _normalize_to_value_target_decision( decision, total_equity=float(portfolio_inputs.total_equity), liquid_cash=float(portfolio_inputs.liquid_cash), + runtime_metadata=runtime_metadata, ) synthetic = _build_hold_current_value_decision(portfolio_inputs) @@ -181,6 +223,7 @@ def _normalize_to_value_target_decision( decision, total_equity=float(portfolio_inputs.total_equity), liquid_cash=float(portfolio_inputs.liquid_cash), + runtime_metadata=runtime_metadata, ) return synthetic, synthetic_annotations @@ -197,6 +240,11 @@ def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[ "signal_display", "status_display", "dashboard_text", + "signal_date", + "effective_date", + "execution_timing_contract", + "execution_calendar_source", + "signal_effective_after_trading_days", "benchmark_symbol", "benchmark_price", "long_trend_value", @@ -209,6 +257,11 @@ def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[ "signal_display": "", "status_display": "", "dashboard_text": "", + "signal_date": "", + "effective_date": "", + "execution_timing_contract": "", + "execution_calendar_source": "", + "signal_effective_after_trading_days": None, "benchmark_symbol": "QQQ", "benchmark_price": 0.0, "long_trend_value": 0.0, @@ -227,6 +280,11 @@ def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[ "signal_display", "status_display", "dashboard_text", + "signal_date", + "effective_date", + "execution_timing_contract", + "execution_calendar_source", + "signal_effective_after_trading_days", "benchmark_symbol", "benchmark_price", "long_trend_value", @@ -239,6 +297,11 @@ def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[ "signal_display": "", "status_display": "", "dashboard_text": "", + "signal_date": "", + "effective_date": "", + "execution_timing_contract": "", + "execution_calendar_source": "", + "signal_effective_after_trading_days": None, "benchmark_symbol": "QQQ", "benchmark_price": 0.0, "long_trend_value": 0.0, @@ -255,6 +318,11 @@ def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[ "signal_display", "status_display", "dashboard_text", + "signal_date", + "effective_date", + "execution_timing_contract", + "execution_calendar_source", + "signal_effective_after_trading_days", "benchmark_symbol", "benchmark_price", "long_trend_value", @@ -270,6 +338,11 @@ def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[ "signal_display": "", "status_display": "", "dashboard_text": "", + "signal_date": "", + "effective_date": "", + "execution_timing_contract": "", + "execution_calendar_source": "", + "signal_effective_after_trading_days": None, "deploy_ratio_text": "", "income_ratio_text": "", "income_locked_ratio_text": "", @@ -285,16 +358,35 @@ def map_strategy_decision_to_plan( account_state: Mapping[str, Any] | None = None, snapshot: Any | None = None, strategy_profile: str, + runtime_metadata: Mapping[str, Any] | None = None, ) -> dict[str, Any]: canonical_profile = resolve_canonical_profile(strategy_profile) portfolio_inputs = _build_portfolio_inputs(account_state=account_state, snapshot=snapshot) normalized_decision, normalized_annotations = _normalize_to_value_target_decision( decision, portfolio_inputs=portfolio_inputs, + runtime_metadata=runtime_metadata, ) annotations = normalized_annotations if annotations is None: - annotations = build_value_target_execution_annotations(normalized_decision) + merged_diagnostics = {**dict(runtime_metadata or {}), **dict(normalized_decision.diagnostics)} + merged_execution_annotations: dict[str, Any] = {} + raw_runtime_annotations = runtime_metadata.get("execution_annotations") if isinstance(runtime_metadata, Mapping) else None + if isinstance(raw_runtime_annotations, Mapping): + merged_execution_annotations.update(raw_runtime_annotations) + raw_decision_annotations = merged_diagnostics.get("execution_annotations") + if isinstance(raw_decision_annotations, Mapping): + merged_execution_annotations.update(raw_decision_annotations) + merged_decision = StrategyDecision( + positions=normalized_decision.positions, + budgets=normalized_decision.budgets, + risk_flags=normalized_decision.risk_flags, + diagnostics={ + **merged_diagnostics, + "execution_annotations": merged_execution_annotations, + }, + ) + annotations = build_value_target_execution_annotations(merged_decision) investable_cash = annotations.investable_cash if investable_cash is None: investable_cash = max( @@ -315,6 +407,11 @@ def map_strategy_decision_to_plan( benchmark_price=annotations.benchmark_price, long_trend_value=annotations.long_trend_value, exit_line=annotations.exit_line, + signal_date=annotations.signal_date, + effective_date=annotations.effective_date, + execution_timing_contract=annotations.execution_timing_contract, + execution_calendar_source=annotations.execution_calendar_source, + signal_effective_after_trading_days=annotations.signal_effective_after_trading_days, deploy_ratio_text=annotations.deploy_ratio_text, income_ratio_text=annotations.income_ratio_text, income_locked_ratio_text=annotations.income_locked_ratio_text, diff --git a/main.py b/main.py index 49b0cb9..2fdcf10 100644 --- a/main.py +++ b/main.py @@ -6,44 +6,26 @@ import os import time import traceback -from datetime import datetime, timezone -import pandas as pd +from datetime import datetime from flask import Flask import google.auth +from application.runtime_broker_adapters import build_runtime_broker_adapters +from application.runtime_composer import build_runtime_composer from application.rebalance_service import run_strategy as run_rebalance_cycle +from application.runtime_strategy_adapters import build_runtime_strategy_adapters from entrypoints.cloud_run import is_market_open_now from runtime_config_support import load_platform_runtime_settings -from decision_mapper import map_strategy_decision_to_plan -from notifications.order_alerts import ( - is_filled_status as notifications_is_filled_status, - is_partial_filled_status as notifications_is_partial_filled_status, - is_terminal_error_status as notifications_is_terminal_error_status, - monitor_submitted_order_status as notifications_monitor_submitted_order_status, - send_order_status_message as notifications_send_order_status_message, - submit_order_with_alert as notifications_submit_order_with_alert, -) -from notifications.events import NotificationPublisher, RenderedNotification -from notifications.telegram import ( - build_issue_notifier, - build_prefixer, - build_sender, - build_signal_text, - build_strategy_display_name, - build_translator, -) +from notifications.telegram import build_signal_text, build_strategy_display_name, build_translator from quant_platform_kit.common.runtime_reports import ( append_runtime_report_error, build_runtime_report_base, finalize_runtime_report, persist_runtime_report, ) -from quant_platform_kit.strategy_contracts import ( - build_portfolio_snapshot_from_account_state as qpk_build_portfolio_snapshot_from_account_state, - build_strategy_evaluation_inputs, -) -from runtime_logging import RuntimeLogContext, build_run_id, emit_runtime_log +from quant_platform_kit.strategy_contracts import build_strategy_evaluation_inputs +from runtime_logging import build_run_id, emit_runtime_log from quant_platform_kit.longbridge import ( build_contexts, calculate_rotation_indicators, @@ -56,6 +38,7 @@ submit_order, ) from strategy_runtime import load_strategy_runtime +from decision_mapper import map_strategy_decision_to_plan app = Flask(__name__) @@ -88,6 +71,11 @@ def get_project_id(): MANAGED_SYMBOLS = STRATEGY_RUNTIME.managed_symbols AVAILABLE_INPUTS = frozenset(STRATEGY_RUNTIME.runtime_adapter.available_inputs) BENCHMARK_SYMBOL = str(STRATEGY_RUNTIME_CONFIG.get("benchmark_symbol", "QQQ")) +SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS = getattr( + getattr(STRATEGY_RUNTIME.runtime_adapter, "runtime_policy", None), + "signal_effective_after_trading_days", + None, +) # Order pricing: limit order discount/premium relative to last price LIMIT_SELL_DISCOUNT = 0.995 # sell limit at 0.5% below last @@ -111,294 +99,99 @@ def t(key, **kwargs): STRATEGY_PROFILE, fallback_name=STRATEGY_DISPLAY_NAME, ) -RUNTIME_LOG_CONTEXT = RuntimeLogContext( - platform="longbridge", - deploy_target="cloud_run", - service_name=os.getenv("K_SERVICE", "longbridge-platform"), +BROKER_ADAPTERS = build_runtime_broker_adapters( + strategy_symbols=tuple(MANAGED_SYMBOLS), + account_hash=ACCOUNT_PREFIX or ACCOUNT_REGION or "longbridge", + fetch_last_price_fn=fetch_last_price, + fetch_strategy_account_state_fn=lambda quote_context, trade_context: fetch_strategy_account_state( + quote_context, + trade_context, + list(MANAGED_SYMBOLS), + ), + submit_order_fn=submit_order, +) +STRATEGY_ADAPTERS = build_runtime_strategy_adapters( + strategy_runtime=STRATEGY_RUNTIME, strategy_profile=STRATEGY_PROFILE, - account_scope=ACCOUNT_REGION, - account_region=ACCOUNT_REGION, - project_id=PROJECT_ID, - extra_fields={ - "account_prefix": ACCOUNT_PREFIX, - "strategy_display_name": STRATEGY_DISPLAY_NAME, - "strategy_display_name_localized": strategy_display_name, - }, + strategy_runtime_config=STRATEGY_RUNTIME_CONFIG, + available_inputs=AVAILABLE_INPUTS, + benchmark_symbol=BENCHMARK_SYMBOL, + signal_text_fn=signal_text, + translator=t, + broker_adapters=BROKER_ADAPTERS, + calculate_rotation_indicators_fn=calculate_rotation_indicators, + build_strategy_evaluation_inputs_fn=build_strategy_evaluation_inputs, + map_strategy_decision_to_plan_fn=map_strategy_decision_to_plan, ) -def with_prefix(message: str) -> str: - return build_prefixer(ACCOUNT_PREFIX)(message) - -def send_tg_message(message): - return build_sender(TG_TOKEN, TG_CHAT_ID, with_prefix_fn=with_prefix)(message) - - -def publish_notification(*, detailed_text, compact_text): - publisher = NotificationPublisher( - log_message=lambda message: print(with_prefix(message), flush=True), - send_message=send_tg_message, - ) - publisher.publish( - RenderedNotification( - detailed_text=detailed_text, - compact_text=compact_text, - ) - ) - - -def notify_issue(title, detail): - return build_issue_notifier(with_prefix_fn=with_prefix, send_tg_message_fn=send_tg_message)(title, detail) - - -def log_runtime_event(log_context, event, **fields): - return emit_runtime_log( - log_context, - event, - printer=lambda line: print(line, flush=True), - **fields, - ) - -def build_execution_report(log_context): - return build_runtime_report_base( - platform=log_context.platform, - deploy_target=log_context.deploy_target, - service_name=log_context.service_name, +def build_composer(): + return build_runtime_composer( + project_id=PROJECT_ID, + secret_name=SECRET_NAME, + token_refresh_threshold_days=TOKEN_REFRESH_THRESHOLD_DAYS, + account_prefix=ACCOUNT_PREFIX, + account_region=ACCOUNT_REGION, strategy_profile=STRATEGY_PROFILE, + strategy_display_name=STRATEGY_DISPLAY_NAME, + strategy_display_name_localized=strategy_display_name, strategy_domain=RUNTIME_SETTINGS.strategy_domain, - account_scope=log_context.account_scope, - account_region=log_context.account_region, - run_id=log_context.run_id, - run_source="cloud_run", - dry_run=RUNTIME_SETTINGS.dry_run_only, - started_at=datetime.now(timezone.utc), - summary={ - "managed_symbols": list(MANAGED_SYMBOLS), - "account_prefix": ACCOUNT_PREFIX, - "benchmark_symbol": BENCHMARK_SYMBOL, - "strategy_display_name": STRATEGY_DISPLAY_NAME, - "strategy_display_name_localized": strategy_display_name, - }, - ) - - -def persist_execution_report(report): - persisted = persist_runtime_report( - report, - base_dir=os.getenv("EXECUTION_REPORT_OUTPUT_DIR"), - gcs_prefix_uri=os.getenv("EXECUTION_REPORT_GCS_URI"), - gcp_project_id=PROJECT_ID, - ) - return persisted.gcs_uri or persisted.local_path - - -def is_filled_status(status): - return notifications_is_filled_status(status) - -def is_partial_filled_status(status): - return notifications_is_partial_filled_status(status) - -def is_terminal_error_status(status): - return notifications_is_terminal_error_status(status) - -def send_order_status_message(title, symbol, side_text, quantity, order_id, status, executed_qty="0", executed_price="0", reason=""): - del title - notifications_send_order_status_message( - symbol, - side_text, - quantity, - order_id, - status, - translator=t, - send_tg_message=send_tg_message, - executed_qty=executed_qty, - executed_price=executed_price, - reason=reason, - ) - - -def safe_quote_last_price(q_ctx, symbol): - """Get last done price for a symbol; returns None if quote unavailable.""" - try: - return fetch_last_price(q_ctx, symbol) - except Exception as e: - notify_issue("Quote failed", f"Symbol: {symbol}\n{e}") - return None - - -def estimate_cash_buy_quantity_safe(t_ctx, symbol, order_kind, ref_price): - """Max buy quantity by cash; ref_price required even for market orders. Returns None on error.""" - try: - return estimate_max_purchase_quantity( - t_ctx, - symbol, - order_kind=order_kind, - ref_price=ref_price, - ) - except Exception: - notify_issue( - "Estimate max buy failed", - f"Symbol: {symbol}\nOrderKind: {order_kind}\n{traceback.format_exc()}" - ) - return None - -def monitor_submitted_order_status(t_ctx, symbol, side_text, quantity, order_id): - notifications_monitor_submitted_order_status( - t_ctx, - symbol, - side_text, - quantity, - order_id, - fetch_order_status=fetch_order_status, + notify_lang=NOTIFY_LANG, + tg_token=TG_TOKEN, + tg_chat_id=TG_CHAT_ID, + managed_symbols=tuple(MANAGED_SYMBOLS), + benchmark_symbol=BENCHMARK_SYMBOL, + signal_effective_after_trading_days=SIGNAL_EFFECTIVE_AFTER_TRADING_DAYS, + separator=SEPARATOR, + limit_sell_discount=LIMIT_SELL_DISCOUNT, + limit_buy_premium=LIMIT_BUY_PREMIUM, order_poll_interval_sec=ORDER_POLL_INTERVAL_SEC, order_poll_max_attempts=ORDER_POLL_MAX_ATTEMPTS, + dry_run_only=RUNTIME_SETTINGS.dry_run_only, + broker_adapters=BROKER_ADAPTERS, + strategy_adapters=STRATEGY_ADAPTERS, + estimate_max_purchase_quantity_fn=estimate_max_purchase_quantity, + fetch_order_status_fn=fetch_order_status, + fetch_token_from_secret_fn=fetch_token_from_secret, + refresh_token_if_needed_fn=refresh_token_if_needed, + build_contexts_fn=build_contexts, + run_id_builder=build_run_id, + event_logger=emit_runtime_log, + report_builder=build_runtime_report_base, + report_persister=persist_runtime_report, translator=t, - send_tg_message=send_tg_message, - notify_issue=notify_issue, + env_reader=os.getenv, sleeper=time.sleep, - ) - -def submit_order_with_alert(t_ctx, symbol, order_type, side, quantity, logs, log_message, submitted_price=None): - return notifications_submit_order_with_alert( - t_ctx, - symbol, - order_type, - side, - quantity, - logs, - log_message, - submit_order=submit_order, - fetch_order_status=fetch_order_status, - translator=t, - send_tg_message=send_tg_message, - notify_issue=notify_issue, - order_poll_interval_sec=ORDER_POLL_INTERVAL_SEC, - order_poll_max_attempts=ORDER_POLL_MAX_ATTEMPTS, - sleeper=time.sleep, - print_with_prefix=lambda message: print(with_prefix(message), flush=True), - submitted_price=submitted_price, - ) - -# --------------------------------------------------------------------------- -# Strategy: NYSE hours check, indicators, balance/positions, target allocation, sell then buy -# --------------------------------------------------------------------------- -def calculate_strategy_indicators(quote_context): - if "feature_snapshot" in AVAILABLE_INPUTS and not ({"benchmark_history", "qqq_history", "derived_indicators", "indicators"} & AVAILABLE_INPUTS): - return {} - if "market_history" in AVAILABLE_INPUTS: - market_inputs = {"market_history": build_market_history_loader(quote_context)} - if "benchmark_history" in AVAILABLE_INPUTS: - market_inputs["benchmark_history"] = fetch_daily_price_history(quote_context, f"{BENCHMARK_SYMBOL}.US") - if "qqq_history" in AVAILABLE_INPUTS: - market_inputs["qqq_history"] = fetch_daily_price_history(quote_context, f"{BENCHMARK_SYMBOL}.US") - return market_inputs - if "benchmark_history" in AVAILABLE_INPUTS or "qqq_history" in AVAILABLE_INPUTS: - return fetch_daily_price_history(quote_context, f"{BENCHMARK_SYMBOL}.US") - trend_ma_window = int(STRATEGY_RUNTIME_CONFIG.get("trend_ma_window", 150)) - return calculate_rotation_indicators(quote_context, trend_window=trend_ma_window) - - -def build_market_history_loader(quote_context): - def load_market_history(_broker_client, symbol, *_args, **_kwargs): - history = fetch_daily_price_history(quote_context, f"{str(symbol).strip().upper()}.US") - if not history: - return pd.Series(dtype=float) - index = pd.bdate_range(end=pd.Timestamp.utcnow().normalize(), periods=len(history)) - closes = [float(bar["close"]) for bar in history] - return pd.Series(closes, index=index, dtype=float) - - return load_market_history - - -def fetch_daily_price_history(quote_context, symbol: str, *, lookback: int = 260): - from longport.openapi import AdjustType, Period - - bars = quote_context.candlesticks(symbol, Period.Day, lookback, AdjustType.ForwardAdjust) - if not bars: - return None - history = [] - for bar in bars: - close = float(bar.close) - history.append( - { - "close": close, - "high": float(getattr(bar, "high", close)), - "low": float(getattr(bar, "low", close)), - } - ) - return history - - -def fetch_managed_account_state(quote_context, trade_context): - return fetch_strategy_account_state(quote_context, trade_context, list(MANAGED_SYMBOLS)) - - -def build_portfolio_snapshot_from_account_state(account_state): - return qpk_build_portfolio_snapshot_from_account_state( - account_state, - strategy_symbols=MANAGED_SYMBOLS, - metadata={"account_hash": ACCOUNT_PREFIX or ACCOUNT_REGION or "longbridge"}, - ) - - -def resolve_rebalance_plan(*, indicators, account_state): - snapshot = None - if "portfolio_snapshot" in AVAILABLE_INPUTS or "snapshot" in AVAILABLE_INPUTS: - snapshot = build_portfolio_snapshot_from_account_state(account_state) - market_inputs = { - "market_history": indicators, - "derived_indicators": indicators, - "indicators": indicators, - "benchmark_history": indicators, - "qqq_history": indicators, - } - if isinstance(indicators, dict) and any( - key in indicators for key in ("market_history", "benchmark_history", "qqq_history") - ): - market_inputs.update(indicators) - evaluation_inputs = build_strategy_evaluation_inputs( - available_inputs=AVAILABLE_INPUTS, - market_inputs=market_inputs, - portfolio_snapshot=snapshot, - account_state=account_state, - translator=t, - signal_text_fn=signal_text, - ) - evaluation = STRATEGY_RUNTIME.evaluate( - **evaluation_inputs, - ) - return map_strategy_decision_to_plan( - evaluation.decision, - account_state=account_state if "account_state" in AVAILABLE_INPUTS else None, - snapshot=snapshot, - strategy_profile=STRATEGY_PROFILE, + printer=print, ) def run_strategy(): - log_context = RUNTIME_LOG_CONTEXT.with_run(build_run_id()) - report = build_execution_report(log_context) + composer = build_composer() + reporting_adapters = composer.build_reporting_adapters() + log_context, report = reporting_adapters.start_run() + notification_adapters = composer.build_notification_adapters() try: - log_runtime_event( + reporting_adapters.log_event( log_context, "strategy_cycle_started", message="Starting strategy execution", ) - print(with_prefix(f"[{datetime.now()}] Starting strategy..."), flush=True) + print(composer.with_prefix(f"[{datetime.now()}] Starting strategy..."), flush=True) market_open = is_market_open_now() if isinstance(market_open, tuple): market_open, error = market_open - log_runtime_event( + reporting_adapters.log_event( log_context, "market_hours_check_failed", message="Market hours check failed", severity="WARNING", error_message=str(error), ) - print(with_prefix(f"Market hours check failed: {error}"), flush=True) + print(composer.with_prefix(f"Market hours check failed: {error}"), flush=True) if not market_open: - log_runtime_event( + reporting_adapters.log_event( log_context, "outside_market_hours", message="Outside market hours; skip execution", @@ -410,36 +203,14 @@ def run_strategy(): "skip_reason": "market_closed", }, ) - print(with_prefix("Outside market hours; skip."), flush=True) + print(composer.with_prefix("Outside market hours; skip."), flush=True) return run_rebalance_cycle( - project_id=PROJECT_ID, - secret_name=SECRET_NAME, - token_refresh_threshold_days=TOKEN_REFRESH_THRESHOLD_DAYS, - limit_sell_discount=LIMIT_SELL_DISCOUNT, - limit_buy_premium=LIMIT_BUY_PREMIUM, - separator=SEPARATOR, - translator=t, - with_prefix=with_prefix, - send_tg_message=send_tg_message, - notify_issue=notify_issue, - fetch_token_from_secret=fetch_token_from_secret, - refresh_token_if_needed=refresh_token_if_needed, - build_contexts=build_contexts, - calculate_strategy_indicators=calculate_strategy_indicators, - fetch_strategy_account_state=fetch_managed_account_state, - resolve_rebalance_plan=resolve_rebalance_plan, - fetch_last_price=fetch_last_price, - estimate_max_purchase_quantity=estimate_max_purchase_quantity, - submit_order_with_alert=submit_order_with_alert, - dry_run_only=RUNTIME_SETTINGS.dry_run_only, - strategy_display_name=strategy_display_name, - post_sell_refresh_attempts=ORDER_POLL_MAX_ATTEMPTS, - post_sell_refresh_interval_sec=ORDER_POLL_INTERVAL_SEC, - sleeper=time.sleep, + runtime=composer.build_rebalance_runtime(), + config=composer.build_rebalance_config(), ) finalize_runtime_report(report, status="ok") - log_runtime_event( + reporting_adapters.log_event( log_context, "strategy_cycle_completed", message="Strategy execution completed", @@ -453,7 +224,7 @@ def run_strategy(): error_type=type(exc).__name__, ) finalize_runtime_report(report, status="error") - log_runtime_event( + reporting_adapters.log_event( log_context, "strategy_cycle_failed", message="Strategy execution failed", @@ -462,13 +233,13 @@ def run_strategy(): error_message=str(exc), ) err = traceback.format_exc() - publish_notification( + notification_adapters.publish_cycle_notification( detailed_text=f"Strategy error:\n{err}", compact_text=f"{t('error_title')}\n{err}", ) finally: try: - report_path = persist_execution_report(report) + report_path = reporting_adapters.persist_execution_report(report) print(f"execution_report {report_path}", flush=True) except Exception as persist_exc: print(f"failed to persist execution report: {persist_exc}", flush=True) diff --git a/notifications/order_alerts.py b/notifications/order_alerts.py index a0c2386..6244f13 100644 --- a/notifications/order_alerts.py +++ b/notifications/order_alerts.py @@ -2,8 +2,24 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import traceback +from notifications.events import NotificationPublisher, RenderedNotification + + +@dataclass(frozen=True) +class OrderLifecycleEvent: + symbol: str + side_text: str + quantity: int | str + order_id: str + status: str + executed_qty: str = "0" + executed_price: str = "0" + reason: str = "" + def is_filled_status(status): return "Filled" in status and "PartialFilled" not in status @@ -17,68 +33,104 @@ def is_terminal_error_status(status): return any(keyword in status for keyword in ["Rejected", "Canceled", "Expired"]) -def send_order_status_message( +def build_order_lifecycle_event( symbol, side_text, quantity, order_id, status, *, - translator, - send_tg_message, executed_qty="0", executed_price="0", reason="", ): - localized_side = translator("side_buy") if side_text == "Buy" else translator("side_sell") - root_symbol = symbol.split(".")[0] if "." in symbol else symbol - - if is_filled_status(status): - message = translator( + return OrderLifecycleEvent( + symbol=str(symbol), + side_text=str(side_text), + quantity=quantity, + order_id=str(order_id or ""), + status=str(status or ""), + executed_qty=str(executed_qty or "0"), + executed_price=str(executed_price or "0"), + reason=str(reason or ""), + ) + + +def render_order_lifecycle_message(event: OrderLifecycleEvent, *, translator) -> str: + localized_side = translator("side_buy") if event.side_text == "Buy" else translator("side_sell") + root_symbol = event.symbol.split(".")[0] if "." in event.symbol else event.symbol + + if is_filled_status(event.status): + return translator( "order_filled", symbol=root_symbol, side=localized_side, - qty=quantity, - price=executed_price, - order_id=order_id, + qty=event.quantity, + price=event.executed_price, + order_id=event.order_id, ) - elif is_partial_filled_status(status): - message = translator( + if is_partial_filled_status(event.status): + return translator( "order_partial", symbol=root_symbol, side=localized_side, - executed=executed_qty, - qty=quantity, - price=executed_price, - order_id=order_id, + executed=event.executed_qty, + qty=event.quantity, + price=event.executed_price, + order_id=event.order_id, ) - elif is_terminal_error_status(status): + if is_terminal_error_status(event.status): status_label = ( translator("status_rejected") - if "Rejected" in status - else (translator("status_canceled") if "Canceled" in status else translator("status_expired")) + if "Rejected" in event.status + else (translator("status_canceled") if "Canceled" in event.status else translator("status_expired")) ) - message = translator( + return translator( "order_error", symbol=root_symbol, side=localized_side, - qty=quantity, + qty=event.quantity, status=status_label, - order_id=order_id, - reason=reason or "—", - ) - else: - message = translator( - "order_filled", - symbol=root_symbol, - side=localized_side, - qty=quantity, - price=executed_price, - order_id=order_id, + order_id=event.order_id, + reason=event.reason or "—", ) + return translator( + "order_filled", + symbol=root_symbol, + side=localized_side, + qty=event.quantity, + price=event.executed_price, + order_id=event.order_id, + ) + + +def render_order_lifecycle_notification( + event: OrderLifecycleEvent, + *, + translator, + include_detailed_text: bool = False, +) -> RenderedNotification: + message = render_order_lifecycle_message(event, translator=translator) + return RenderedNotification( + detailed_text=message if include_detailed_text else "", + compact_text=message, + ) - send_tg_message(message) +def publish_order_lifecycle_event( + event: OrderLifecycleEvent, + *, + translator, + publisher: NotificationPublisher, + include_detailed_text: bool = False, +) -> None: + publisher.publish( + render_order_lifecycle_notification( + event, + translator=translator, + include_detailed_text=include_detailed_text, + ) + ) def monitor_submitted_order_status( trade_context, @@ -90,8 +142,7 @@ def monitor_submitted_order_status( fetch_order_status, order_poll_interval_sec, order_poll_max_attempts, - translator, - send_tg_message, + publish_order_event: Callable[[OrderLifecycleEvent], None], notify_issue, sleeper, ): @@ -109,124 +160,29 @@ def monitor_submitted_order_status( reject_message = order_status["reason"] executed_qty = order_status["executed_qty"] executed_price = order_status["executed_price"] + event = build_order_lifecycle_event( + symbol, + side_text, + quantity, + order_id, + status, + executed_qty=executed_qty, + executed_price=executed_price, + reason=reject_message, + ) if is_filled_status(status): - send_order_status_message( - symbol, - side_text, - quantity, - order_id, - status, - translator=translator, - send_tg_message=send_tg_message, - executed_qty=executed_qty, - executed_price=executed_price, - ) + publish_order_event(event) return if is_partial_filled_status(status): - send_order_status_message( - symbol, - side_text, - quantity, - order_id, - status, - translator=translator, - send_tg_message=send_tg_message, - executed_qty=executed_qty, - executed_price=executed_price, - ) + publish_order_event(event) if is_terminal_error_status(status): - send_order_status_message( - symbol, - side_text, - quantity, - order_id, - status, - translator=translator, - send_tg_message=send_tg_message, - executed_qty=executed_qty, - executed_price=executed_price, - reason=reject_message, - ) + publish_order_event(event) return except Exception: notify_issue( "Order status poll failed", f"Symbol: {symbol} Side: {side_text} Qty: {quantity} Order: {order_id}\n{traceback.format_exc()}", ) - - - -def _append_order_id_suffix(log_message, order_id, *, translator): - order_id_text = str(order_id or "").strip() - if not order_id_text: - return log_message - suffix = str(translator("order_id_suffix", order_id=order_id_text)).strip() - if not suffix or suffix == "order_id_suffix": - suffix = f"[order_id={order_id_text}]" - return f"{log_message} {suffix}" - - -def submit_order_with_alert( - trade_context, - symbol, - order_type, - side, - quantity, - logs, - log_message, - *, - submit_order, - fetch_order_status, - translator, - send_tg_message, - notify_issue, - order_poll_interval_sec, - order_poll_max_attempts, - sleeper, - print_with_prefix, - submitted_price=None, -): - side_text = "Buy" if side == "buy" else "Sell" - - try: - report = submit_order( - trade_context, - symbol, - order_kind=order_type, - side=side, - quantity=quantity, - submitted_price=submitted_price, - ) - order_id = report.broker_order_id or "" - log_with_order_id = _append_order_id_suffix(log_message, order_id, translator=translator) - print_with_prefix(f"OK {log_with_order_id}") - logs.append(log_with_order_id) - monitor_submitted_order_status( - trade_context, - symbol, - side_text, - quantity, - order_id, - fetch_order_status=fetch_order_status, - order_poll_interval_sec=order_poll_interval_sec, - order_poll_max_attempts=order_poll_max_attempts, - translator=translator, - send_tg_message=send_tg_message, - notify_issue=notify_issue, - sleeper=sleeper, - ) - return True - except Exception: - notify_issue( - "Order submit failed", - ( - f"Symbol: {symbol} Side: {side_text} Qty: {quantity} " - f"Type: {order_type} Price: {submitted_price if submitted_price is not None else 'MO'}\n" - f"{traceback.format_exc()}" - ), - ) - return False - diff --git a/notifications/renderers.py b/notifications/renderers.py new file mode 100644 index 0000000..16fd674 --- /dev/null +++ b/notifications/renderers.py @@ -0,0 +1,346 @@ +"""Notification rendering helpers for LongBridgePlatform.""" + +from __future__ import annotations + +import re + +from notifications.events import RenderedNotification + + +_ZH_REASON_REPLACEMENTS = ( + ("feature snapshot guard blocked execution", "特征快照校验阻止执行"), + ("feature snapshot required", "需要特征快照"), + ("feature snapshot compute failed", "特征快照计算失败"), + ("feature_snapshot_download_failed", "特征快照下载失败"), + ("feature_snapshot_compute_failed", "特征快照计算失败"), + ("feature_snapshot_path_missing", "缺少特征快照路径"), + ("feature_snapshot_missing", "特征快照不存在"), + ("feature_snapshot_stale", "特征快照过旧"), + ("feature_snapshot_manifest_missing", "缺少快照清单"), + ("feature_snapshot_profile_mismatch", "快照策略名不匹配"), + ("feature_snapshot_config_name_mismatch", "快照配置名不匹配"), + ("feature_snapshot_config_path_mismatch", "快照配置路径不匹配"), + ("feature_snapshot_contract_version_mismatch", "快照契约版本不匹配"), + ("soxl_soxx_trend_income", "SOXL/SOXX 半导体趋势收益"), + ("tqqq_growth_income", "TQQQ 增长收益"), + ("global_etf_rotation", "全球 ETF 轮动"), + ("russell_1000_multi_factor_defensive", "罗素1000多因子"), + ("tech_communication_pullback_enhancement", "科技通信回调增强"), + ("qqq_tech_enhancement", "科技通信回调增强"), + ("mega_cap_leader_rotation_aggressive", "Mega Cap 激进龙头轮动"), + ("mega_cap_leader_rotation_dynamic_top20", "Mega Cap 动态 Top20 龙头轮动"), + ("mega_cap_leader_rotation_top50_balanced", "Mega Cap Top50 平衡龙头轮动"), + ("dynamic_mega_leveraged_pullback", "Mega Cap 2x 回调策略"), + ("outside_monthly_execution_window", "当前不在月度执行窗口"), + ("no_execution_window_after_snapshot", "快照后没有可用执行窗口"), + ("no-op", "不执行"), + ("monthly snapshot cadence", "月度快照节奏"), + ("waiting inside execution window", "等待进入执行窗口"), + ("small_account_warning=true", "小账户提示=是"), + ("portfolio_equity=", "净值="), + ("min_recommended_equity=", "建议最低净值="), + ( + "integer_shares_min_position_value_may_prevent_backtest_replication", + "整数股和最小仓位限制可能导致实盘无法完全复现回测", + ), + ( + "integer-share minimum position sizing may prevent backtest replication", + "整数股和最小仓位限制可能导致实盘无法完全复现回测", + ), + ("small account warning: portfolio equity", "小账户提示:净值"), + ("small account warning", "小账户提示"), + ("is below the recommended", "低于建议"), + ("is below recommended", "低于建议"), + ("snapshot_as_of=", "快照日期="), + ("snapshot=", "快照日期="), + ("allowed=", "允许日期="), + ("", "未知"), + ("", "无"), + ("RISK-ON", "风险开启"), + ("DE-LEVER", "降杠杆"), + ("regime=hard_defense", "市场阶段=强防御"), + ("regime=soft_defense", "市场阶段=软防御"), + ("regime=risk_on", "市场阶段=进攻"), + ("benchmark_trend=down", "基准趋势=向下"), + ("benchmark_trend=up", "基准趋势=向上"), + ("benchmark=down", "基准趋势=向下"), + ("benchmark=up", "基准趋势=向上"), + ("breadth=", "市场宽度="), + ("target_stock=", "目标股票仓位="), + ("realized_stock=", "实际股票仓位="), + ("stock_exposure=", "股票目标仓位="), + ("safe_haven=", "避险仓位="), + ("selected=", "入选标的数="), + ("top=", "前排标的="), + ("no_selection", "无入选标的"), + ("outside_execution_window", "当前不在执行窗口"), + ("insufficient_buying_power", "购买力不足"), + ("missing_price", "缺少报价"), + ("no_equity", "无净值"), + ("fail_closed", "关闭执行"), + ("reason=", "原因="), +) +_DETAIL_FIELD_SPLIT_RE = re.compile(r"\s+(?=[^\s=::]+[=::])") + + +def _translator_uses_zh(translator) -> bool: + sample = str(translator("no_trades")) + return any("\u4e00" <= ch <= "\u9fff" for ch in sample) + + +def _localize_notification_text(text, *, translator): + value = str(text or "").strip() + if not value or not _translator_uses_zh(translator): + return value + localized = value + for source, target in _ZH_REASON_REPLACEMENTS: + localized = localized.replace(source, target) + return localized + + +def _split_detail_segment(text): + value = str(text or "").strip() + if not value: + return [] + if "=" not in value and ":" not in value and ":" not in value: + return [value] + return [part.strip() for part in _DETAIL_FIELD_SPLIT_RE.split(value) if part.strip()] + + +def _split_labeled_text(text): + segments = [segment.strip() for segment in str(text or "").split(" | ") if segment.strip()] + if not segments: + return [] + lines = [segments[0]] + for segment in segments[1:]: + lines.extend(_split_detail_segment(segment)) + return lines + + +def _append_labeled_text(lines, template_key, value, *, translator, value_key): + parts = _split_labeled_text(value) + if not parts: + return + lines.append(translator(template_key, **{value_key: parts[0]})) + lines.extend(f" - {part}" for part in parts[1:]) + + +def _build_timing_audit_lines(execution, *, translator): + signal_date = str(execution.get("signal_date") or "").strip() + effective_date = str(execution.get("effective_date") or "").strip() + contract = str(execution.get("execution_timing_contract") or "").strip() + if not signal_date and not effective_date and not contract: + return [] + label = "⏱ 执行时点" if _translator_uses_zh(translator) else "⏱ Timing" + if signal_date and effective_date: + value = f"{signal_date} -> {effective_date}" + else: + value = signal_date or effective_date or contract + if contract and contract not in value: + value = f"{value} ({contract})" if value else contract + return [f"{label}: {value}"] + + +def _has_benchmark_context(execution): + return any( + float(execution.get(key) or 0.0) > 0.0 + for key in ("benchmark_price", "long_trend_value", "exit_line") + ) + + +def _build_benchmark_lines(execution, *, translator): + if not _has_benchmark_context(execution): + return [] + benchmark_symbol = str(execution.get("benchmark_symbol") or "QQQ") + benchmark_price = float(execution.get("benchmark_price") or 0.0) + long_trend_value = float(execution.get("long_trend_value") or 0.0) + exit_line = float(execution.get("exit_line") or 0.0) + return [ + translator("benchmark_title", symbol=benchmark_symbol), + f" - {translator('benchmark_price', symbol=benchmark_symbol, value=f'{benchmark_price:.2f}')}", + f" - {translator('benchmark_ma200', value=f'{long_trend_value:.2f}')}", + f" - {translator('benchmark_exit', value=f'{exit_line:.2f}')}", + ] + + +def _format_dashboard_text(text) -> str: + return "\n".join( + line.rstrip() + for line in str(text or "").splitlines() + if line.strip() + ) + + +def _append_dashboard_lines(lines, *, execution) -> None: + dashboard_text = _format_dashboard_text(execution.get("dashboard_text")) + if dashboard_text: + lines.extend(dashboard_text.splitlines()) + + +def _append_timing_lines(lines, *, execution, translator) -> None: + lines.extend(_build_timing_audit_lines(execution, translator=translator)) + + +def _append_status_lines(lines, *, execution, translator, signal_key): + status_display = _localize_notification_text(execution.get("status_display"), translator=translator) + if status_display: + _append_labeled_text(lines, "market_status", status_display, translator=translator, value_key="status") + + deploy_ratio_text = str(execution.get("deploy_ratio_text") or "").strip() + if deploy_ratio_text: + lines.append(translator("risk_position", ratio=deploy_ratio_text)) + + income_ratio_text = str(execution.get("income_ratio_text") or "").strip() + if income_ratio_text: + lines.append(translator("income_target", ratio=income_ratio_text)) + + income_locked_ratio_text = str(execution.get("income_locked_ratio_text") or "").strip() + if income_locked_ratio_text: + lines.append(translator("income_locked", ratio=income_locked_ratio_text)) + + signal_display = _localize_notification_text(execution.get("signal_display"), translator=translator) + if signal_display: + _append_labeled_text(lines, signal_key, signal_display, translator=translator, value_key="msg") + + lines.extend(_build_benchmark_lines(execution, translator=translator)) + + +def _first_detail_line(text) -> str: + parts = _split_labeled_text(text) + return parts[0] if parts else "" + + +def _append_compact_status_lines(lines, *, execution, translator, signal_key): + status_summary = _first_detail_line( + _localize_notification_text(execution.get("status_display"), translator=translator) + ) + if status_summary: + lines.append(translator("market_status", status=status_summary)) + + signal_summary = _first_detail_line( + _localize_notification_text(execution.get("signal_display"), translator=translator) + ) + if signal_summary: + lines.append(translator(signal_key, msg=signal_summary)) + + +def _append_strategy_line(lines, *, strategy_display_name, translator): + name = str(strategy_display_name or "").strip() + if name: + lines.append(translator("strategy_label", name=name)) + + +def render_rebalance_notification( + *, + execution, + logs, + skip_logs, + note_logs, + translator, + separator, + strategy_display_name, + dry_run_only, +) -> RenderedNotification: + formatted_logs = "\n".join(f" - {log}" for log in [*logs, *skip_logs, *note_logs]) + detailed_lines = [translator("rebalance_title")] + _append_strategy_line(detailed_lines, strategy_display_name=strategy_display_name, translator=translator) + if dry_run_only: + detailed_lines.append(translator("dry_run_banner")) + _append_dashboard_lines(detailed_lines, execution=execution) + _append_timing_lines(detailed_lines, execution=execution, translator=translator) + _append_status_lines( + detailed_lines, + execution=execution, + translator=translator, + signal_key="signal", + ) + detailed_lines.extend([separator, translator("order_logs_title"), formatted_logs]) + + compact_lines = [translator("rebalance_title")] + _append_strategy_line(compact_lines, strategy_display_name=strategy_display_name, translator=translator) + if dry_run_only: + compact_lines.append(translator("dry_run_banner")) + _append_dashboard_lines(compact_lines, execution=execution) + _append_timing_lines(compact_lines, execution=execution, translator=translator) + _append_compact_status_lines( + compact_lines, + execution=execution, + translator=translator, + signal_key="signal", + ) + compact_lines.extend([separator, translator("order_logs_title"), formatted_logs]) + return RenderedNotification( + detailed_text="\n".join(detailed_lines), + compact_text="\n".join(compact_lines), + ) + + +def render_heartbeat_notification( + *, + execution, + skip_logs, + note_logs, + translator, + separator, + strategy_display_name, + dry_run_only, +) -> RenderedNotification: + detailed_lines = [translator("heartbeat_title")] + _append_strategy_line(detailed_lines, strategy_display_name=strategy_display_name, translator=translator) + if dry_run_only: + detailed_lines.append(translator("dry_run_banner")) + _append_dashboard_lines(detailed_lines, execution=execution) + _append_timing_lines(detailed_lines, execution=execution, translator=translator) + detailed_lines.append(separator) + _append_status_lines( + detailed_lines, + execution=execution, + translator=translator, + signal_key="heartbeat_signal", + ) + detailed_lines.extend( + [ + separator, + translator("no_executable_orders") if (skip_logs or note_logs) else translator("no_trades"), + ] + ) + detailed_text = "\n".join(detailed_lines) + if skip_logs: + detailed_text += ( + f"\n{separator}\n" + f"{translator('skipped_actions')}\n" + + "\n".join(f" - {log}" for log in skip_logs) + ) + if note_logs: + detailed_text += ( + f"\n{separator}\n" + f"{translator('notes_title')}\n" + + "\n".join(f" - {log}" for log in note_logs) + ) + + compact_lines = [translator("heartbeat_title")] + _append_strategy_line(compact_lines, strategy_display_name=strategy_display_name, translator=translator) + if dry_run_only: + compact_lines.append(translator("dry_run_banner")) + _append_dashboard_lines(compact_lines, execution=execution) + _append_timing_lines(compact_lines, execution=execution, translator=translator) + _append_compact_status_lines( + compact_lines, + execution=execution, + translator=translator, + signal_key="heartbeat_signal", + ) + compact_lines.append( + translator("no_executable_orders") if (skip_logs or note_logs) else translator("no_trades") + ) + if skip_logs: + compact_lines.extend([separator, translator("skipped_actions")]) + compact_lines.extend(f" - {log}" for log in skip_logs) + if note_logs: + compact_lines.extend([separator, translator("notes_title")]) + compact_lines.extend(f" - {log}" for log in note_logs) + + return RenderedNotification( + detailed_text=detailed_text, + compact_text="\n".join(compact_lines), + ) diff --git a/strategy_runtime.py b/strategy_runtime.py index fddf0f0..aa91e2d 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -13,6 +13,8 @@ StrategyDecision, StrategyEntrypoint, StrategyRuntimeAdapter, + apply_runtime_policy_to_runtime_config, + build_execution_timing_metadata, build_strategy_context_from_available_inputs, ) from runtime_config_support import PlatformRuntimeSettings @@ -66,6 +68,7 @@ def evaluate( runtime_config.setdefault("translator", translator) if signal_text_fn is not None: runtime_config.setdefault("signal_text_fn", signal_text_fn) + apply_runtime_policy_to_runtime_config(runtime_config, self.runtime_adapter) if _FEATURE_SNAPSHOT_INPUT in frozenset(self.entrypoint.manifest.required_inputs): return self._evaluate_feature_snapshot_strategy( @@ -73,10 +76,11 @@ def evaluate( available_inputs=available_inputs, ) + as_of = datetime.now(timezone.utc) ctx = build_strategy_context_from_available_inputs( entrypoint=self.entrypoint, runtime_adapter=self.runtime_adapter, - as_of=datetime.now(timezone.utc), + as_of=as_of, available_inputs=available_inputs, runtime_config=runtime_config, ) @@ -86,6 +90,12 @@ def evaluate( metadata={ "strategy_profile": self.profile, "strategy_display_name": self.display_name, + **build_execution_timing_metadata( + signal_date=as_of, + signal_effective_after_trading_days=( + self.runtime_adapter.runtime_policy.signal_effective_after_trading_days + ), + ), }, ) diff --git a/tests/test_order_alerts.py b/tests/test_order_alerts.py index dda6ecc..fd89494 100644 --- a/tests/test_order_alerts.py +++ b/tests/test_order_alerts.py @@ -1,39 +1,80 @@ -from types import SimpleNamespace - -from notifications.order_alerts import submit_order_with_alert +from notifications.events import NotificationPublisher +from notifications.order_alerts import ( + build_order_lifecycle_event, + monitor_submitted_order_status, + publish_order_lifecycle_event, + render_order_lifecycle_message, +) from notifications.telegram import build_translator -def test_submit_order_with_alert_localizes_order_id_suffix_for_zh(): - logs = [] - printed = [] - - submitted = submit_order_with_alert( - trade_context=object(), - symbol="BOXX.US", - order_type="market", - side="buy", - quantity=49, - logs=logs, - log_message="📈 [市价买入] BOXX: 49股 @ $116.31", - submit_order=lambda *_args, **_kwargs: SimpleNamespace(broker_order_id="1227343614540054528"), - fetch_order_status=lambda *_args, **_kwargs: None, +def test_render_order_lifecycle_message_localizes_order_fill_for_zh(): + message = render_order_lifecycle_message( + build_order_lifecycle_event( + "BOXX.US", + "Buy", + 49, + "1227343614540054528", + "Filled", + executed_price="116.31", + ), translator=build_translator("zh"), - send_tg_message=lambda _message: None, - notify_issue=lambda *_args, **_kwargs: None, - order_poll_interval_sec=0, - order_poll_max_attempts=0, - sleeper=lambda _seconds: None, - print_with_prefix=printed.append, ) - assert submitted is True - assert logs == ["📈 [市价买入] BOXX: 49股 @ $116.31 (订单号: 1227343614540054528)"] - assert "order_id=" not in logs[0] - assert printed == ["OK 📈 [市价买入] BOXX: 49股 @ $116.31 (订单号: 1227343614540054528)"] + assert message == "✅ 订单成交 | BOXX 买入 49股 均价 $116.31(订单号: 1227343614540054528)" def test_build_translator_localizes_semiconductor_trend_status_for_zh(): translate = build_translator("zh") assert translate("market_status_delever", asset="SOXX") == "🛡️ 降杠杆(SOXX)" assert translate("signal_delever", window=150, ratio="40.2%") == "SOXL 跌破 150 日均线,切换至 SOXX,交易层风险仓位 40.2%" + + +def test_publish_order_lifecycle_event_routes_rendering_through_publisher(): + messages = [] + + publish_order_lifecycle_event( + build_order_lifecycle_event( + "SOXL.US", + "Buy", + 10, + "order-1", + "Filled", + executed_price="123.45", + ), + translator=build_translator("en"), + publisher=NotificationPublisher( + log_message=lambda _message: None, + send_message=messages.append, + ), + ) + + assert messages == ["✅ Order Filled | SOXL Buy 10 shares avg $123.45 (ID: order-1)"] + + +def test_monitor_submitted_order_status_emits_lifecycle_events_via_callback(): + events = [] + + monitor_submitted_order_status( + trade_context=object(), + symbol="SOXL.US", + side_text="Buy", + quantity=10, + order_id="order-1", + fetch_order_status=lambda *_args, **_kwargs: { + "status": "PartialFilled", + "reason": "", + "executed_qty": "4", + "executed_price": "122.10", + }, + order_poll_interval_sec=0, + order_poll_max_attempts=1, + publish_order_event=events.append, + notify_issue=lambda *_args, **_kwargs: None, + sleeper=lambda _seconds: None, + ) + + assert len(events) == 1 + assert events[0].symbol == "SOXL.US" + assert events[0].status == "PartialFilled" + assert events[0].executed_qty == "4" diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index e85a108..b337722 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -1,7 +1,6 @@ import sys import unittest from pathlib import Path -from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] @@ -20,9 +19,19 @@ requests_stub = types.ModuleType("requests") requests_stub.post = lambda *args, **kwargs: None -with patch.dict(sys.modules, {"requests": requests_stub}): +_original_requests_module = sys.modules.get("requests") +sys.modules["requests"] = requests_stub +try: from application import rebalance_service + from application.runtime_dependencies import LongBridgeRebalanceConfig, LongBridgeRebalanceRuntime from notifications.telegram import build_translator + from quant_platform_kit.common.models import ExecutionReport, PortfolioSnapshot, Position, QuoteSnapshot + from quant_platform_kit.common.port_adapters import CallableExecutionPort, CallableMarketDataPort, CallableNotificationPort, CallablePortfolioPort +finally: + if _original_requests_module is None: + sys.modules.pop("requests", None) + else: + sys.modules["requests"] = _original_requests_module def _build_plan( @@ -53,6 +62,9 @@ def _build_plan( long_trend_value=0.0, exit_line=0.0, cash_by_currency=None, + signal_date="2026-04-21", + effective_date="2026-04-22", + execution_timing_contract="next_trading_day", ): if dashboard_text is None: dashboard_lines = [ @@ -112,11 +124,193 @@ def _build_plan( "benchmark_price": float(benchmark_price), "long_trend_value": float(long_trend_value), "exit_line": float(exit_line), + "signal_date": signal_date, + "effective_date": effective_date, + "execution_timing_contract": execution_timing_contract, }, } +def _build_snapshot(plan, *, phase=""): + portfolio = dict(plan["portfolio"]) + metadata = {"cash_by_currency": dict(portfolio.get("cash_by_currency") or {})} + if phase: + metadata["phase"] = phase + return PortfolioSnapshot( + as_of="2026-04-21", + total_equity=float(portfolio["total_equity"]), + buying_power=float(portfolio["liquid_cash"]), + cash_balance=float(portfolio["liquid_cash"]), + positions=tuple( + Position( + symbol=symbol, + quantity=float(portfolio["quantities"].get(symbol, 0)), + market_value=float(portfolio["market_values"].get(symbol, 0.0)), + ) + for symbol in portfolio["strategy_symbols"] + ), + metadata=metadata, + ) + + class RebalanceServiceNotificationTests(unittest.TestCase): + def test_run_strategy_prefers_portfolio_port_runtime_path(self): + sent_messages = [] + observed = {} + snapshot = PortfolioSnapshot( + as_of="2026-04-21", + total_equity=60000.0, + buying_power=101.95, + cash_balance=101.95, + positions=( + Position(symbol="SOXX", quantity=0, market_value=0.0), + ), + metadata={"cash_by_currency": {"USD": 101.95}}, + ) + plan = _build_plan( + strategy_symbols=("SOXX",), + risk_symbols=("SOXX",), + targets={"SOXX": 34718.05}, + market_values={"SOXX": 0.0}, + sellable_quantities={"SOXX": 0}, + quantities={"SOXX": 0}, + current_min_trade=100.0, + trade_threshold_value=100.0, + investable_cash=101.95, + market_status="🛡️ DE-LEVER (SOXX)", + deploy_ratio_text="57.9%", + income_ratio_text="0.0%", + income_locked_ratio_text="38.3%", + signal_message="SOXL 跌破 150 日均线,切换至 SOXX,交易层风险仓位 57.9%", + available_cash=101.95, + total_strategy_equity=60000.0, + portfolio_rows=(("SOXX",),), + ) + + rebalance_service.run_strategy( + runtime=LongBridgeRebalanceRuntime( + bootstrap=lambda: ("quote-context", "trade-context", {"soxl": {"price": 1.0}}), + resolve_rebalance_plan=lambda *, indicators, snapshot=None, account_state=None: ( + observed.setdefault("indicators", indicators), + observed.setdefault("snapshot", snapshot), + observed.setdefault("account_state", account_state), + plan, + )[-1], + market_data_port_factory=lambda _quote_context: CallableMarketDataPort( + quote_loader=lambda symbol: QuoteSnapshot( + symbol=symbol, + as_of="2026-04-21", + last_price=322.74, + ) + ), + estimate_max_purchase_quantity=lambda *args, **kwargs: 0, + notifications=CallableNotificationPort(sent_messages.append), + notify_issue=lambda title, detail: sent_messages.append(f"{title}\n{detail}"), + portfolio_port_factory=lambda _quote_context, _trade_context: CallablePortfolioPort( + lambda: snapshot + ), + execution_port_factory=lambda _trade_context: CallableExecutionPort( + lambda _order_intent: (_ for _ in ()).throw(AssertionError("unexpected order submit")) + ), + ), + config=LongBridgeRebalanceConfig( + limit_sell_discount=0.995, + limit_buy_premium=1.005, + separator="━━━━━━━━━━━━━━━━━━", + translator=build_translator("zh"), + with_prefix=lambda message: f"[HK/LongBridgeQuant] {message}", + strategy_display_name="SOXL/SOXX 半导体趋势收益", + ), + ) + + self.assertIs(observed["snapshot"], snapshot) + self.assertIsNone(observed["account_state"]) + self.assertEqual(observed["indicators"], {"soxl": {"price": 1.0}}) + self.assertEqual(len(sent_messages), 1) + self.assertIn("【心跳", sent_messages[0]) + + def test_run_strategy_supports_execution_port_runtime_path(self): + sent_messages = [] + observed_orders = [] + observed_post_submit = [] + snapshot = PortfolioSnapshot( + as_of="2026-04-21", + total_equity=60000.0, + buying_power=50000.0, + cash_balance=50000.0, + positions=(Position(symbol="SOXX", quantity=0, market_value=0.0),), + metadata={"cash_by_currency": {"USD": 50000.0}}, + ) + plan = _build_plan( + strategy_symbols=("SOXX",), + risk_symbols=("SOXX",), + targets={"SOXX": 34718.05}, + market_values={"SOXX": 0.0}, + sellable_quantities={"SOXX": 0}, + quantities={"SOXX": 0}, + current_min_trade=100.0, + trade_threshold_value=100.0, + investable_cash=50000.0, + market_status="🛡️ DE-LEVER (SOXX)", + deploy_ratio_text="57.9%", + income_ratio_text="0.0%", + income_locked_ratio_text="38.3%", + signal_message="SOXL 跌破 150 日均线,切换至 SOXX,交易层风险仓位 57.9%", + available_cash=50000.0, + total_strategy_equity=60000.0, + portfolio_rows=(("SOXX",),), + ) + + rebalance_service.run_strategy( + runtime=LongBridgeRebalanceRuntime( + bootstrap=lambda: ("quote-context", "trade-context", {"soxl": {"price": 1.0}}), + resolve_rebalance_plan=lambda *, indicators, snapshot=None: plan, + market_data_port_factory=lambda _quote_context: CallableMarketDataPort( + quote_loader=lambda symbol: QuoteSnapshot( + symbol=symbol, + as_of="2026-04-21", + last_price=322.74, + ) + ), + estimate_max_purchase_quantity=lambda *args, **kwargs: 200, + notifications=CallableNotificationPort(sent_messages.append), + notify_issue=lambda title, detail: sent_messages.append(f"{title}\n{detail}"), + portfolio_port_factory=lambda _quote_context, _trade_context: CallablePortfolioPort( + lambda: snapshot + ), + execution_port_factory=lambda _trade_context: CallableExecutionPort( + lambda order_intent: ( + observed_orders.append(order_intent), + ExecutionReport( + symbol=order_intent.symbol, + side=order_intent.side, + quantity=order_intent.quantity, + status="submitted", + broker_order_id="lb-order-1", + ), + )[-1] + ), + post_submit_order=lambda trade_context, order_intent, report: observed_post_submit.append( + (trade_context, order_intent.symbol, report.broker_order_id) + ), + ), + config=LongBridgeRebalanceConfig( + limit_sell_discount=0.995, + limit_buy_premium=1.005, + separator="━━━━━━━━━━━━━━━━━━", + translator=build_translator("zh"), + with_prefix=lambda message: f"[HK/LongBridgeQuant] {message}", + strategy_display_name="SOXL/SOXX 半导体趋势收益", + ), + ) + + self.assertEqual(len(observed_orders), 1) + self.assertEqual(observed_orders[0].symbol, "SOXX.US") + self.assertEqual(observed_orders[0].order_type, "limit") + self.assertEqual(observed_post_submit, [("trade-context", "SOXX.US", "lb-order-1")]) + self.assertEqual(len(sent_messages), 1) + self.assertIn("【调仓", sent_messages[0]) + def test_append_status_lines_localizes_snapshot_guard_text_for_zh(self): lines = [] rebalance_service._append_status_lines( @@ -201,87 +395,87 @@ def _run_strategy( *, prices, refreshed_plan=None, - account_states=None, + portfolio_snapshots=None, estimate_max_purchase_quantity_value=0, dry_run_only=False, strategy_display_name="SOXL/SOXX 半导体趋势收益", post_sell_refresh_attempts=1, ): sent_messages = [] - observed_account_states = [] + observed_snapshots = [] + observed_orders = [] observed_sleeps = [] - def fake_send_tg_message(message): - sent_messages.append(message) - - def fake_notify_issue(title, detail): - fake_send_tg_message(f"{title}\n{detail}") - - def fake_submit_order_with_alert( - trade_context, - symbol, - order_type, - side, - quantity, - logs, - log_message, - *, - submitted_price=None, - ): - del trade_context, order_type, side, quantity, submitted_price - logs.append(f"{log_message} (订单号: test-order)") - return True - if isinstance(refreshed_plan, (list, tuple)): plan_side_effect = [plan, *refreshed_plan] else: plan_side_effect = [plan, refreshed_plan or plan] observed_plan_inputs = [] - account_state_values = list(account_states or [{}, {}]) + snapshot_values = list( + portfolio_snapshots + or [ + _build_snapshot(plan, phase="before_cycle"), + _build_snapshot(refreshed_plan or plan, phase="after_cycle"), + ] + ) - def fake_fetch_strategy_account_state(quote_context, trade_context): - del quote_context, trade_context - if not account_state_values: - raise AssertionError("unexpected extra fetch_strategy_account_state call") - value = account_state_values.pop(0) - observed_account_states.append(value) + def fake_load_snapshot(): + if not snapshot_values: + raise AssertionError("unexpected extra portfolio snapshot refresh") + value = snapshot_values.pop(0) + observed_snapshots.append(value) return value - def fake_resolve_rebalance_plan(*, indicators, account_state): - observed_plan_inputs.append((indicators, account_state)) + def fake_resolve_rebalance_plan(*, indicators, snapshot): + observed_plan_inputs.append((indicators, snapshot)) if not plan_side_effect: raise AssertionError("unexpected extra resolve_rebalance_plan call") return plan_side_effect.pop(0) rebalance_service.run_strategy( - project_id="project-1", - secret_name="secret-1", - token_refresh_threshold_days=30, - limit_sell_discount=0.995, - limit_buy_premium=1.005, - separator="━━━━━━━━━━━━━━━━━━", - translator=build_translator("zh"), - with_prefix=lambda message: f"[HK/LongBridgeQuant] {message}", - send_tg_message=fake_send_tg_message, - notify_issue=fake_notify_issue, - fetch_token_from_secret=lambda project_id, secret_name: "refresh-token", - refresh_token_if_needed=lambda *args, **kwargs: "live-token", - build_contexts=lambda app_key, app_secret, token: ("quote-context", "trade-context"), - calculate_strategy_indicators=lambda quote_context: {"soxl": {"price": 1, "ma_trend": 2}}, - fetch_strategy_account_state=fake_fetch_strategy_account_state, - resolve_rebalance_plan=fake_resolve_rebalance_plan, - fetch_last_price=lambda quote_context, symbol: prices[symbol], - estimate_max_purchase_quantity=lambda *args, **kwargs: estimate_max_purchase_quantity_value, - submit_order_with_alert=fake_submit_order_with_alert, - dry_run_only=dry_run_only, - strategy_display_name=strategy_display_name, - post_sell_refresh_attempts=post_sell_refresh_attempts, - post_sell_refresh_interval_sec=0.0, - sleeper=observed_sleeps.append, - ) - - return sent_messages, observed_account_states, observed_plan_inputs + runtime=LongBridgeRebalanceRuntime( + bootstrap=lambda: ("quote-context", "trade-context", {"soxl": {"price": 1, "ma_trend": 2}}), + resolve_rebalance_plan=fake_resolve_rebalance_plan, + market_data_port_factory=lambda _quote_context: CallableMarketDataPort( + quote_loader=lambda symbol: QuoteSnapshot( + symbol=symbol, + as_of="2026-04-21", + last_price=float(prices[symbol]), + ) + ), + estimate_max_purchase_quantity=lambda *args, **kwargs: estimate_max_purchase_quantity_value, + notifications=CallableNotificationPort(sent_messages.append), + notify_issue=lambda title, detail: sent_messages.append(f"{title}\n{detail}"), + portfolio_port_factory=lambda _quote_context, _trade_context: CallablePortfolioPort(fake_load_snapshot), + execution_port_factory=lambda _trade_context: CallableExecutionPort( + lambda order_intent: ( + observed_orders.append(order_intent), + ExecutionReport( + symbol=order_intent.symbol, + side=order_intent.side, + quantity=order_intent.quantity, + status="submitted", + broker_order_id="test-order", + ), + )[-1] + ), + ), + config=LongBridgeRebalanceConfig( + limit_sell_discount=0.995, + limit_buy_premium=1.005, + separator="━━━━━━━━━━━━━━━━━━", + translator=build_translator("zh"), + with_prefix=lambda message: f"[HK/LongBridgeQuant] {message}", + strategy_display_name=strategy_display_name, + dry_run_only=dry_run_only, + post_sell_refresh_attempts=post_sell_refresh_attempts, + post_sell_refresh_interval_sec=0.0, + sleeper=observed_sleeps.append, + ), + ) + + return sent_messages, observed_snapshots, observed_plan_inputs def test_sell_then_buy_skip_is_sent_in_single_summary_message(self): plan = _build_plan( @@ -311,6 +505,7 @@ def test_sell_then_buy_skip_is_sent_in_single_summary_message(self): self.assertEqual(len(sent_messages), 1) self.assertIn("🔔 【调仓指令】", sent_messages[0]) self.assertIn("🧭 策略: SOXL/SOXX 半导体趋势收益", sent_messages[0]) + self.assertIn("⏱ 执行时点: 2026-04-21 -> 2026-04-22 (next_trading_day)", sent_messages[0]) self.assertIn("限价卖出", sent_messages[0]) self.assertIn("买入说明", sent_messages[0]) self.assertIn("SOXX.US", sent_messages[0]) @@ -342,6 +537,7 @@ def test_buy_skip_without_orders_is_sent_in_single_heartbeat_message(self): self.assertEqual(len(sent_messages), 1) self.assertIn("💓 【心跳检测】", sent_messages[0]) + self.assertIn("⏱ 执行时点: 2026-04-21 -> 2026-04-22 (next_trading_day)", sent_messages[0]) self.assertIn("本轮没有可执行订单", sent_messages[0]) self.assertIn("说明", sent_messages[0]) self.assertIn("可投资现金", sent_messages[0]) @@ -488,15 +684,17 @@ def test_refreshes_account_state_after_sell_and_can_place_followup_buy(self): total_strategy_equity=60000.0, portfolio_rows=(("SOXL", "SOXX"),), ) - sent_messages, observed_account_states, observed_plan_inputs = self._run_strategy( + before_sell_snapshot = _build_snapshot(initial_plan, phase="before_sell") + after_sell_snapshot = _build_snapshot(refreshed_plan, phase="after_sell") + sent_messages, observed_snapshots, observed_plan_inputs = self._run_strategy( initial_plan, refreshed_plan=refreshed_plan, - account_states=[{"phase": "before_sell"}, {"phase": "after_sell"}], + portfolio_snapshots=[before_sell_snapshot, after_sell_snapshot], prices={"SOXL.US": 45.94, "SOXX.US": 322.74}, estimate_max_purchase_quantity_value=200, ) - self.assertEqual(observed_account_states, [{"phase": "before_sell"}, {"phase": "after_sell"}]) + self.assertEqual(observed_snapshots, [before_sell_snapshot, after_sell_snapshot]) self.assertEqual(len(sent_messages), 1) self.assertIn("🔔 【调仓指令】", sent_messages[0]) self.assertIn("限价卖出", sent_messages[0]) @@ -569,13 +767,16 @@ def test_retries_account_refresh_after_sell_until_buying_power_updates(self): portfolio_rows=(("TQQQ", "BOXX"),), ) - sent_messages, observed_account_states, observed_plan_inputs = self._run_strategy( + before_sell_snapshot = _build_snapshot(initial_plan, phase="before_sell") + stale_snapshot = _build_snapshot(stale_refreshed_plan, phase="stale_after_sell") + settled_snapshot = _build_snapshot(settled_refreshed_plan, phase="settled_after_sell") + sent_messages, observed_snapshots, observed_plan_inputs = self._run_strategy( initial_plan, refreshed_plan=[stale_refreshed_plan, settled_refreshed_plan], - account_states=[ - {"phase": "before_sell"}, - {"phase": "stale_after_sell"}, - {"phase": "settled_after_sell"}, + portfolio_snapshots=[ + before_sell_snapshot, + stale_snapshot, + settled_snapshot, ], prices={"TQQQ.US": 50.0, "BOXX.US": 100.0}, estimate_max_purchase_quantity_value=200, @@ -584,12 +785,8 @@ def test_retries_account_refresh_after_sell_until_buying_power_updates(self): ) self.assertEqual( - observed_account_states, - [ - {"phase": "before_sell"}, - {"phase": "stale_after_sell"}, - {"phase": "settled_after_sell"}, - ], + observed_snapshots, + [before_sell_snapshot, stale_snapshot, settled_snapshot], ) self.assertEqual(len(observed_plan_inputs), 3) self.assertEqual(len(sent_messages), 1) @@ -640,7 +837,10 @@ def test_dry_run_replaces_real_order_submission_with_summary_lines(self): sent_messages, _, _ = self._run_strategy( initial_plan, refreshed_plan=refreshed_plan, - account_states=[{"phase": "before_sell"}, {"phase": "after_sell"}], + portfolio_snapshots=[ + _build_snapshot(initial_plan, phase="before_sell"), + _build_snapshot(refreshed_plan, phase="after_sell"), + ], prices={"SOXL.US": 45.94, "SOXX.US": 322.74}, estimate_max_purchase_quantity_value=200, dry_run_only=True, diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index c172e0f..3398d90 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -98,7 +98,8 @@ def run(self, *args, **kwargs): merged_runtime_config={"trend_ma_window": 150}, managed_symbols=("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"), runtime_adapter=types.SimpleNamespace( - available_inputs=frozenset({"derived_indicators", "portfolio_snapshot"}) + available_inputs=frozenset({"derived_indicators", "portfolio_snapshot"}), + runtime_policy=types.SimpleNamespace(signal_effective_after_trading_days=1), ), evaluate=lambda **_kwargs: None, ) @@ -156,8 +157,7 @@ def load_module(): clear=False, ): sys.modules.pop("main", None) - module = importlib.import_module("main") - return importlib.reload(module) + return importlib.import_module("main") class RequestHandlingTests(unittest.TestCase): @@ -218,8 +218,11 @@ def test_run_strategy_persists_machine_readable_report(self): module.emit_runtime_log = lambda *args, **kwargs: None module.is_market_open_now = lambda: True module.run_rebalance_cycle = lambda **_kwargs: None - module.persist_execution_report = ( - lambda report: observed_reports.append(dict(report)) or "/tmp/runtime-report.json" + module.persist_runtime_report = ( + lambda report, **_kwargs: observed_reports.append(dict(report)) or types.SimpleNamespace( + local_path="/tmp/runtime-report.json", + gcs_uri=None, + ) ) module.run_strategy() @@ -234,6 +237,9 @@ def test_run_strategy_persists_machine_readable_report(self): self.assertEqual(report["summary"]["managed_symbols"], list(module.MANAGED_SYMBOLS)) self.assertEqual(report["summary"]["strategy_display_name"], module.STRATEGY_DISPLAY_NAME) self.assertEqual(report["summary"]["strategy_display_name_localized"], module.strategy_display_name) + self.assertEqual(report["summary"]["execution_timing_contract"], "next_trading_day") + self.assertTrue(report["summary"]["signal_date"]) + self.assertTrue(report["summary"]["effective_date"]) if __name__ == "__main__": diff --git a/tests/test_runtime_bootstrap_adapters.py b/tests/test_runtime_bootstrap_adapters.py new file mode 100644 index 0000000..2f04d22 --- /dev/null +++ b/tests/test_runtime_bootstrap_adapters.py @@ -0,0 +1,75 @@ +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from application.runtime_bootstrap_adapters import build_runtime_bootstrap + + +def test_build_runtime_bootstrap_refreshes_token_and_builds_contexts(): + observed = {} + bootstrap = build_runtime_bootstrap( + project_id="project-1", + secret_name="secret-1", + token_refresh_threshold_days=30, + fetch_token_from_secret_fn=lambda project_id, secret_name: ( + observed.setdefault("fetch_secret", (project_id, secret_name)), + "refresh-token", + )[-1], + refresh_token_if_needed_fn=lambda token, **kwargs: ( + observed.setdefault("refresh", (token, kwargs)), + "live-token", + )[-1], + build_contexts_fn=lambda app_key, app_secret, token: ( + observed.setdefault("contexts", (app_key, app_secret, token)), + ("quote-context", "trade-context"), + )[-1], + calculate_strategy_indicators_fn=lambda quote_context: ( + observed.setdefault("indicators", quote_context), + {"qqq": {"price": 123.45}}, + )[-1], + env_reader=lambda name, default="": { + "LONGPORT_APP_KEY": "app-key", + "LONGPORT_APP_SECRET": "app-secret", + }.get(name, default), + ) + + result = bootstrap() + + assert observed["fetch_secret"] == ("project-1", "secret-1") + assert observed["refresh"] == ( + "refresh-token", + { + "project_id": "project-1", + "secret_name": "secret-1", + "app_key": "app-key", + "app_secret": "app-secret", + "refresh_threshold_days": 30, + }, + ) + assert observed["contexts"] == ("app-key", "app-secret", "live-token") + assert observed["indicators"] == "quote-context" + assert result == ("quote-context", "trade-context", {"qqq": {"price": 123.45}}) + + +def test_build_runtime_bootstrap_raises_when_indicators_unavailable(): + bootstrap = build_runtime_bootstrap( + project_id=None, + secret_name="secret-1", + token_refresh_threshold_days=30, + fetch_token_from_secret_fn=lambda *_args, **_kwargs: "refresh-token", + refresh_token_if_needed_fn=lambda token, **_kwargs: token, + build_contexts_fn=lambda *_args, **_kwargs: ("quote-context", "trade-context"), + calculate_strategy_indicators_fn=lambda _quote_context: None, + env_reader=lambda _name, default="": default, + ) + + try: + bootstrap() + except Exception as exc: # noqa: PERF203 + assert str(exc) == "Quote data missing or API limited; cannot compute indicators" + else: + raise AssertionError("expected bootstrap to raise when indicators are unavailable") diff --git a/tests/test_runtime_broker_adapters.py b/tests/test_runtime_broker_adapters.py new file mode 100644 index 0000000..ac2edab --- /dev/null +++ b/tests/test_runtime_broker_adapters.py @@ -0,0 +1,125 @@ +import sys +import types +from datetime import datetime, timezone +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +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: + sys.path.insert(0, str(PLATFORM_KIT_SRC)) + +from application.runtime_broker_adapters import build_runtime_broker_adapters +from quant_platform_kit.common.models import ExecutionReport, OrderIntent + + +def test_build_market_data_port_normalizes_symbols_and_caches_quotes(): + observed = {"quotes": [], "history": []} + openapi_module = types.ModuleType("longport.openapi") + openapi_module.Period = types.SimpleNamespace(Day="day") + openapi_module.AdjustType = types.SimpleNamespace(ForwardAdjust="forward") + + class QuoteContext: + def candlesticks(self, symbol, period, lookback, adjust_type): + observed["history"].append((symbol, period, lookback, adjust_type)) + return [ + types.SimpleNamespace(close=123.45, high=124.0, low=122.0, timestamp="2026-04-18T00:00:00Z"), + types.SimpleNamespace(close=125.67, high=126.0, low=124.0, timestamp="2026-04-21T00:00:00Z"), + ] + + adapters = build_runtime_broker_adapters( + strategy_symbols=("SOXL",), + account_hash="HK", + fetch_last_price_fn=lambda _quote_context, symbol: ( + observed["quotes"].append(symbol), + 125.67, + )[-1], + fetch_strategy_account_state_fn=lambda *_args, **_kwargs: {}, + submit_order_fn=lambda *_args, **_kwargs: None, + clock=lambda: datetime(2026, 4, 21, tzinfo=timezone.utc), + ) + + original_openapi_module = sys.modules.get("longport.openapi") + sys.modules["longport.openapi"] = openapi_module + try: + market_data_port = adapters.build_market_data_port(QuoteContext()) + quote_a = market_data_port.get_quote("soxl") + quote_b = market_data_port.get_quote("SOXL.US") + series = market_data_port.get_price_series("soxl") + finally: + if original_openapi_module is None: + sys.modules.pop("longport.openapi", None) + else: + sys.modules["longport.openapi"] = original_openapi_module + + assert quote_a.symbol == "SOXL.US" + assert quote_a.last_price == 125.67 + assert quote_a == quote_b + assert observed["quotes"] == ["SOXL.US"] + assert observed["history"] == [("SOXL.US", "day", 260, "forward")] + assert series.symbol == "SOXL.US" + assert [point.close for point in series.points] == [123.45, 125.67] + + +def test_build_portfolio_and_execution_ports_adapt_runtime_calls(): + observed = {"account_reads": [], "orders": []} + adapters = build_runtime_broker_adapters( + strategy_symbols=("SOXL", "BOXX"), + account_hash="HK-001", + fetch_last_price_fn=lambda *_args, **_kwargs: 0.0, + fetch_strategy_account_state_fn=lambda quote_context, trade_context: ( + observed["account_reads"].append((quote_context, trade_context)), + { + "market_values": {"SOXL": 1200.0, "BOXX": 800.0}, + "quantities": {"SOXL": 10, "BOXX": 7}, + "available_cash": 500.0, + "total_strategy_equity": 2500.0, + "cash_by_currency": {"USD": 500.0}, + "sellable_quantities": {"SOXL": 10, "BOXX": 7}, + }, + )[-1], + submit_order_fn=lambda trade_context, symbol, **kwargs: ( + observed["orders"].append((trade_context, symbol, kwargs)), + ExecutionReport( + symbol=symbol, + side=kwargs["side"], + quantity=kwargs["quantity"], + status="submitted", + broker_order_id="order-1", + ), + )[-1], + ) + + portfolio_port = adapters.build_portfolio_port("quote-context", "trade-context") + snapshot = portfolio_port.get_portfolio_snapshot() + execution_port = adapters.build_execution_port("trade-context") + report = execution_port.submit_order( + OrderIntent( + symbol="SOXL.US", + side="buy", + quantity=3, + order_type="limit", + limit_price=126.5, + ) + ) + + assert observed["account_reads"] == [("quote-context", "trade-context")] + assert snapshot.total_equity == 2500.0 + assert snapshot.buying_power == 500.0 + assert snapshot.metadata["account_hash"] == "HK-001" + assert snapshot.metadata["cash_by_currency"] == {"USD": 500.0} + assert observed["orders"] == [ + ( + "trade-context", + "SOXL.US", + { + "order_kind": "limit", + "side": "buy", + "quantity": 3, + "submitted_price": 126.5, + }, + ) + ] + assert report.broker_order_id == "order-1" diff --git a/tests/test_runtime_composer.py b/tests/test_runtime_composer.py new file mode 100644 index 0000000..3952f44 --- /dev/null +++ b/tests/test_runtime_composer.py @@ -0,0 +1,115 @@ +import sys +from pathlib import Path +from types import SimpleNamespace + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from application.runtime_composer import LongBridgeRuntimeComposer + + +def test_runtime_composer_builds_runtime_and_config_from_local_builders(): + observed = {} + + def fake_notification_builder(**kwargs): + observed["notification_builder"] = kwargs + return SimpleNamespace( + notification_port="notification-port", + notify_issue="notify-issue", + post_submit_order="post-submit-order", + ) + + def fake_reporting_builder(**kwargs): + observed["reporting_builder"] = kwargs + return "reporting-adapters" + + def fake_bootstrap_builder(**kwargs): + observed["bootstrap_builder"] = kwargs + return "bootstrap" + + composer = LongBridgeRuntimeComposer( + project_id="project-1", + secret_name="secret-1", + token_refresh_threshold_days=30, + account_prefix="HK", + account_region="HK", + strategy_profile="soxl_soxx_trend_income", + strategy_display_name="SOXL/SOXX Semiconductor Trend Income", + strategy_display_name_localized="SOXL/SOXX 半导体趋势收益", + strategy_domain="us_equity", + notify_lang="en", + tg_token="tg-token", + tg_chat_id="chat-id", + managed_symbols=("SOXL", "SOXX"), + benchmark_symbol="QQQ", + signal_effective_after_trading_days=1, + separator="━━━━━━━━━━━━━━━━━━", + limit_sell_discount=0.995, + limit_buy_premium=1.005, + order_poll_interval_sec=1, + order_poll_max_attempts=8, + dry_run_only=True, + broker_adapters=SimpleNamespace( + build_market_data_port="market-data-port-factory", + build_portfolio_port="portfolio-port-factory", + build_execution_port="execution-port-factory", + ), + strategy_adapters=SimpleNamespace( + calculate_strategy_indicators="strategy-indicators", + resolve_rebalance_plan="resolve-plan", + ), + estimate_max_purchase_quantity_fn="estimate-max-purchase", + fetch_order_status_fn="fetch-order-status", + fetch_token_from_secret_fn="fetch-token", + refresh_token_if_needed_fn="refresh-token", + build_contexts_fn="build-contexts", + run_id_builder=lambda: "run-001", + event_logger="event-logger", + report_builder="report-builder", + report_persister="report-persister", + translator=lambda key, **_kwargs: key, + prefixer_builder=lambda prefix: lambda message: f"[{prefix}] {message}", + sender_builder=lambda token, chat_id, *, with_prefix_fn: lambda message: observed.setdefault( + "sent_message", + (token, chat_id, with_prefix_fn(message)), + ), + env_reader=lambda name, default="": { + "K_SERVICE": "longbridge-platform", + "EXECUTION_REPORT_OUTPUT_DIR": "/tmp/runtime-reports", + "EXECUTION_REPORT_GCS_URI": "gs://bucket/runtime-reports", + }.get(name, default), + sleeper=lambda _seconds: None, + printer=lambda *_args, **_kwargs: None, + notification_adapter_builder=fake_notification_builder, + reporting_adapter_builder=fake_reporting_builder, + bootstrap_builder=fake_bootstrap_builder, + ) + + assert composer.with_prefix("hello") == "[HK] hello" + composer.send_tg_message("hello") + assert observed["sent_message"] == ("tg-token", "chat-id", "[HK] hello") + + notification_adapters = composer.build_notification_adapters() + reporting_adapters = composer.build_reporting_adapters() + runtime = composer.build_rebalance_runtime() + config = composer.build_rebalance_config() + + assert notification_adapters.notification_port == "notification-port" + assert reporting_adapters == "reporting-adapters" + assert observed["notification_builder"]["fetch_order_status"] == "fetch-order-status" + assert observed["reporting_builder"]["service_name"] == "longbridge-platform" + assert observed["reporting_builder"]["report_base_dir"] == "/tmp/runtime-reports" + assert observed["reporting_builder"]["signal_effective_after_trading_days"] == 1 + assert observed["bootstrap_builder"]["secret_name"] == "secret-1" + assert observed["bootstrap_builder"]["calculate_strategy_indicators_fn"] == "strategy-indicators" + assert runtime.bootstrap == "bootstrap" + assert runtime.resolve_rebalance_plan == "resolve-plan" + assert runtime.market_data_port_factory == "market-data-port-factory" + assert runtime.notifications == "notification-port" + assert runtime.post_submit_order == "post-submit-order" + assert config.limit_sell_discount == 0.995 + assert config.limit_buy_premium == 1.005 + 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 5c1b063..8b6f045 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -67,10 +67,7 @@ def test_platform_supported_profiles_are_filtered_by_registry(self): get_supported_profiles_for_platform(LONGBRIDGE_PLATFORM), frozenset( { - "dynamic_mega_leveraged_pullback", "global_etf_rotation", - "mega_cap_leader_rotation_aggressive", - "mega_cap_leader_rotation_dynamic_top20", "mega_cap_leader_rotation_top50_balanced", "russell_1000_multi_factor_defensive", "tqqq_growth_income", @@ -214,7 +211,7 @@ def test_platform_profile_status_matrix_matches_current_longbridge_rollout(self) self.assertTrue(by_profile["tech_communication_pullback_enhancement"]["enabled"]) self.assertEqual(by_profile["tech_communication_pullback_enhancement"]["display_name"], "Tech/Communication Pullback Enhancement") self.assertTrue(by_profile["mega_cap_leader_rotation_dynamic_top20"]["eligible"]) - self.assertTrue(by_profile["mega_cap_leader_rotation_dynamic_top20"]["enabled"]) + self.assertFalse(by_profile["mega_cap_leader_rotation_dynamic_top20"]["enabled"]) self.assertEqual(by_profile["mega_cap_leader_rotation_dynamic_top20"]["display_name"], "Mega Cap Leader Rotation Dynamic Top20") self.assertTrue(by_profile["mega_cap_leader_rotation_top50_balanced"]["eligible"]) self.assertTrue(by_profile["mega_cap_leader_rotation_top50_balanced"]["enabled"]) @@ -223,10 +220,10 @@ def test_platform_profile_status_matrix_matches_current_longbridge_rollout(self) "Mega Cap Leader Rotation Top50 Balanced", ) self.assertTrue(by_profile["mega_cap_leader_rotation_aggressive"]["eligible"]) - self.assertTrue(by_profile["mega_cap_leader_rotation_aggressive"]["enabled"]) + self.assertFalse(by_profile["mega_cap_leader_rotation_aggressive"]["enabled"]) self.assertEqual(by_profile["mega_cap_leader_rotation_aggressive"]["display_name"], "Mega Cap Leader Rotation Aggressive") self.assertTrue(by_profile["dynamic_mega_leveraged_pullback"]["eligible"]) - self.assertTrue(by_profile["dynamic_mega_leveraged_pullback"]["enabled"]) + self.assertFalse(by_profile["dynamic_mega_leveraged_pullback"]["enabled"]) self.assertEqual(by_profile["dynamic_mega_leveraged_pullback"]["display_name"], "Dynamic Mega Leveraged Pullback") def test_loads_feature_snapshot_env_for_tech_profile(self): @@ -253,21 +250,21 @@ def test_derives_feature_snapshot_paths_from_artifact_root(self): with patch.dict( os.environ, { - "STRATEGY_PROFILE": "dynamic_mega_leveraged_pullback", + "STRATEGY_PROFILE": "mega_cap_leader_rotation_top50_balanced", "STRATEGY_ARTIFACT_ROOT": tmp_dir, }, clear=True, ): settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") - expected_dir = Path(tmp_dir) / "dynamic_mega_leveraged_pullback" + expected_dir = Path(tmp_dir) / "mega_cap_leader_rotation_top50_balanced" self.assertEqual( settings.feature_snapshot_path, - str(expected_dir / "dynamic_mega_leveraged_pullback_feature_snapshot_latest.csv"), + str(expected_dir / "mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv"), ) self.assertEqual( settings.feature_snapshot_manifest_path, - str(expected_dir / "dynamic_mega_leveraged_pullback_feature_snapshot_latest.csv.manifest.json"), + str(expected_dir / "mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv.manifest.json"), ) def test_print_strategy_profile_status_json_matches_registry(self): @@ -305,21 +302,13 @@ def test_print_strategy_profile_status_json_matches_registry(self): self.assertEqual(by_profile["tech_communication_pullback_enhancement"]["input_mode"], "feature_snapshot") self.assertTrue(by_profile["tech_communication_pullback_enhancement"]["requires_snapshot_artifacts"]) self.assertTrue(by_profile["tech_communication_pullback_enhancement"]["requires_strategy_config_path"]) - self.assertEqual(by_profile["mega_cap_leader_rotation_dynamic_top20"]["profile_group"], "snapshot_backed") - self.assertEqual(by_profile["mega_cap_leader_rotation_dynamic_top20"]["input_mode"], "feature_snapshot") - self.assertTrue(by_profile["mega_cap_leader_rotation_dynamic_top20"]["requires_snapshot_artifacts"]) - self.assertFalse(by_profile["mega_cap_leader_rotation_dynamic_top20"]["requires_strategy_config_path"]) + self.assertFalse(by_profile["mega_cap_leader_rotation_dynamic_top20"]["enabled"]) + self.assertFalse(by_profile["mega_cap_leader_rotation_aggressive"]["enabled"]) + self.assertFalse(by_profile["dynamic_mega_leveraged_pullback"]["enabled"]) self.assertEqual(by_profile["mega_cap_leader_rotation_top50_balanced"]["profile_group"], "snapshot_backed") self.assertEqual(by_profile["mega_cap_leader_rotation_top50_balanced"]["input_mode"], "feature_snapshot") self.assertTrue(by_profile["mega_cap_leader_rotation_top50_balanced"]["requires_snapshot_artifacts"]) self.assertFalse(by_profile["mega_cap_leader_rotation_top50_balanced"]["requires_strategy_config_path"]) - self.assertEqual(by_profile["dynamic_mega_leveraged_pullback"]["profile_group"], "snapshot_backed") - self.assertEqual( - by_profile["dynamic_mega_leveraged_pullback"]["input_mode"], - "feature_snapshot+market_history+benchmark_history+portfolio_snapshot", - ) - self.assertTrue(by_profile["dynamic_mega_leveraged_pullback"]["requires_snapshot_artifacts"]) - self.assertFalse(by_profile["dynamic_mega_leveraged_pullback"]["requires_strategy_config_path"]) self.assertFalse( by_profile["russell_1000_multi_factor_defensive"]["requires_strategy_config_path"] ) @@ -406,7 +395,7 @@ def test_print_strategy_switch_env_plan_for_russell(self): self.assertIn("LONGBRIDGE_STRATEGY_CONFIG_PATH", plan["remove_if_present"]) - def test_print_strategy_switch_env_plan_for_mega_cap_dynamic_top20(self): + def test_print_strategy_switch_env_plan_rejects_archived_mega_cap_dynamic_top20(self): result = subprocess.run( [ sys.executable, @@ -417,26 +406,12 @@ def test_print_strategy_switch_env_plan_for_mega_cap_dynamic_top20(self): "hk", "--json", ], - check=True, capture_output=True, text=True, ) - plan = json.loads(result.stdout) - self.assertEqual(plan["canonical_profile"], "mega_cap_leader_rotation_dynamic_top20") - self.assertEqual(plan["set_env"]["ACCOUNT_REGION"], "HK") - self.assertEqual(plan["set_env"]["ACCOUNT_PREFIX"], "HK") - self.assertEqual(plan["profile_group"], "snapshot_backed") - self.assertEqual(plan["input_mode"], "feature_snapshot") - self.assertTrue(plan["requires_snapshot_artifacts"]) - self.assertFalse(plan["requires_strategy_config_path"]) - self.assertEqual(plan["set_env"]["LONGBRIDGE_FEATURE_SNAPSHOT_PATH"], "") - self.assertEqual(plan["set_env"]["LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH"], "") - self.assertIn("LONGBRIDGE_STRATEGY_CONFIG_PATH", plan["remove_if_present"]) - self.assertEqual( - plan["hints"]["feature_snapshot_filename"], - "mega_cap_leader_rotation_dynamic_top20_feature_snapshot_latest.csv", - ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Unsupported STRATEGY_PROFILE", result.stderr) def test_print_strategy_switch_env_plan_for_mega_cap_top50_balanced(self): result = subprocess.run( @@ -469,7 +444,7 @@ def test_print_strategy_switch_env_plan_for_mega_cap_top50_balanced(self): "mega_cap_leader_rotation_top50_balanced_feature_snapshot_latest.csv", ) - def test_print_strategy_switch_env_plan_for_dynamic_mega_leveraged_pullback_sg(self): + def test_print_strategy_switch_env_plan_rejects_archived_dynamic_mega_leveraged_pullback_sg(self): result = subprocess.run( [ sys.executable, @@ -480,30 +455,12 @@ def test_print_strategy_switch_env_plan_for_dynamic_mega_leveraged_pullback_sg(s "sg", "--json", ], - check=True, capture_output=True, text=True, ) - plan = json.loads(result.stdout) - self.assertEqual(plan["canonical_profile"], "dynamic_mega_leveraged_pullback") - self.assertEqual(plan["set_env"]["ACCOUNT_REGION"], "SG") - self.assertEqual(plan["set_env"]["ACCOUNT_PREFIX"], "SG") - self.assertEqual(plan["profile_group"], "snapshot_backed") - self.assertEqual( - plan["input_mode"], - "feature_snapshot+market_history+benchmark_history+portfolio_snapshot", - ) - self.assertTrue(plan["requires_snapshot_artifacts"]) - self.assertFalse(plan["requires_strategy_config_path"]) - self.assertEqual(plan["set_env"]["LONGBRIDGE_FEATURE_SNAPSHOT_PATH"], "") - self.assertEqual(plan["set_env"]["LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH"], "") - self.assertIn("LONGBRIDGE_DRY_RUN_ONLY", plan["optional_env"]) - self.assertIn("LONGBRIDGE_STRATEGY_CONFIG_PATH", plan["remove_if_present"]) - self.assertEqual( - plan["hints"]["feature_snapshot_filename"], - "dynamic_mega_leveraged_pullback_feature_snapshot_latest.csv", - ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Unsupported STRATEGY_PROFILE", result.stderr) def test_print_strategy_switch_env_plan_for_tech_uses_bundled_config_by_default(self): result = subprocess.run( diff --git a/tests/test_runtime_notification_adapters.py b/tests/test_runtime_notification_adapters.py new file mode 100644 index 0000000..b04fa6f --- /dev/null +++ b/tests/test_runtime_notification_adapters.py @@ -0,0 +1,54 @@ +from types import SimpleNamespace + +from application.runtime_notification_adapters import build_runtime_notification_adapters +from notifications.telegram import build_translator + + +def test_runtime_notification_adapters_publish_cycle_notification_uses_log_and_send_sinks(): + logs = [] + messages = [] + adapters = build_runtime_notification_adapters( + with_prefix=lambda message: f"[HK] {message}", + send_message=messages.append, + translator=build_translator("en"), + fetch_order_status=lambda *_args, **_kwargs: None, + order_poll_interval_sec=0, + order_poll_max_attempts=0, + sleeper=lambda _seconds: None, + log_message=logs.append, + ) + + adapters.publish_cycle_notification( + detailed_text="detailed line", + compact_text="compact line", + ) + + assert logs == ["detailed line"] + assert messages == ["compact line"] + + +def test_runtime_notification_adapters_post_submit_order_publishes_order_events(): + messages = [] + adapters = build_runtime_notification_adapters( + with_prefix=lambda message: f"[HK] {message}", + send_message=messages.append, + translator=build_translator("en"), + fetch_order_status=lambda *_args, **_kwargs: { + "status": "Filled", + "reason": "", + "executed_qty": "10", + "executed_price": "123.45", + }, + order_poll_interval_sec=0, + order_poll_max_attempts=1, + sleeper=lambda _seconds: None, + log_message=lambda _message: None, + ) + + adapters.post_submit_order( + "trade-context", + SimpleNamespace(symbol="SOXL.US", side="buy", quantity=10), + SimpleNamespace(broker_order_id="order-1"), + ) + + assert messages == ["✅ Order Filled | SOXL Buy 10 shares avg $123.45 (ID: order-1)"] diff --git a/tests/test_runtime_reporting_adapters.py b/tests/test_runtime_reporting_adapters.py new file mode 100644 index 0000000..aa4c224 --- /dev/null +++ b/tests/test_runtime_reporting_adapters.py @@ -0,0 +1,111 @@ +import sys +import types +from datetime import datetime, timezone +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from application.runtime_reporting_adapters import build_runtime_reporting_adapters + + +def test_runtime_reporting_adapters_start_run_builds_context_and_report(): + observed = {} + + def fake_report_builder(**kwargs): + observed["report_builder"] = kwargs + return {"run_id": kwargs["run_id"]} + + adapters = build_runtime_reporting_adapters( + platform="longbridge", + deploy_target="cloud_run", + service_name="longbridge-platform", + strategy_profile="soxl_soxx_trend_income", + strategy_domain="us_equity", + account_scope="HK", + account_region="HK", + project_id="project-1", + extra_context_fields={"account_prefix": "HK"}, + managed_symbols=("SOXL", "SOXX"), + account_prefix="HK", + benchmark_symbol="QQQ", + strategy_display_name="SOXL/SOXX Semiconductor Trend Income", + strategy_display_name_localized="SOXL/SOXX 半导体趋势收益", + dry_run=True, + signal_effective_after_trading_days=1, + report_base_dir="/tmp/reports", + report_gcs_prefix_uri="gs://bucket/reports", + run_id_builder=lambda: "run-001", + event_logger=lambda *_args, **_kwargs: {}, + report_builder=fake_report_builder, + report_persister=lambda *_args, **_kwargs: None, + printer=lambda *_args, **_kwargs: None, + clock=lambda: datetime(2026, 4, 21, tzinfo=timezone.utc), + ) + + log_context, report = adapters.start_run() + + assert log_context.run_id == "run-001" + assert log_context.extra_fields["account_prefix"] == "HK" + assert observed["report_builder"]["run_id"] == "run-001" + assert observed["report_builder"]["dry_run"] is True + summary = observed["report_builder"]["summary"] + assert summary["managed_symbols"] == ["SOXL", "SOXX"] + assert summary["account_prefix"] == "HK" + assert summary["benchmark_symbol"] == "QQQ" + assert summary["strategy_display_name"] == "SOXL/SOXX Semiconductor Trend Income" + assert summary["strategy_display_name_localized"] == "SOXL/SOXX 半导体趋势收益" + assert summary["signal_date"] == "2026-04-21" + assert summary["effective_date"] == "2026-04-22" + assert summary["execution_timing_contract"] == "next_trading_day" + assert summary["signal_effective_after_trading_days"] == 1 + assert summary["execution_calendar_source"] in { + "pandas_market_calendars", + "business_day_fallback", + } + assert report == {"run_id": "run-001"} + + +def test_runtime_reporting_adapters_log_and_persist_route_to_dependencies(): + observed = {} + + def fake_report_persister(report, **kwargs): + observed["persist"] = (report, kwargs) + return types.SimpleNamespace(local_path="/tmp/report.json", gcs_uri=None) + + adapters = build_runtime_reporting_adapters( + platform="longbridge", + deploy_target="cloud_run", + service_name="longbridge-platform", + strategy_profile="soxl_soxx_trend_income", + strategy_domain="us_equity", + account_scope="HK", + account_region="HK", + project_id="project-1", + managed_symbols=(), + signal_effective_after_trading_days=1, + run_id_builder=lambda: "run-001", + event_logger=lambda context, event, **kwargs: observed.setdefault( + "event", + (context.run_id, event, kwargs), + ), + report_builder=lambda **kwargs: kwargs, + report_persister=fake_report_persister, + printer=lambda line, flush=True: observed.setdefault("printer", (line, flush)), + ) + + log_context, report = adapters.start_run() + adapters.log_event(log_context, "strategy_cycle_started", message="Starting strategy execution") + persisted = adapters.persist_execution_report(report) + + assert observed["event"][0] == "run-001" + assert observed["event"][1] == "strategy_cycle_started" + assert observed["event"][2]["printer"] is adapters.printer + assert observed["persist"][1] == { + "base_dir": None, + "gcs_prefix_uri": None, + "gcp_project_id": "project-1", + } + assert persisted == "/tmp/report.json" diff --git a/tests/test_runtime_strategy_adapters.py b/tests/test_runtime_strategy_adapters.py new file mode 100644 index 0000000..c754ef0 --- /dev/null +++ b/tests/test_runtime_strategy_adapters.py @@ -0,0 +1,140 @@ +import sys +from pathlib import Path +from types import SimpleNamespace + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from application.runtime_strategy_adapters import build_runtime_strategy_adapters + + +def test_runtime_strategy_adapters_build_market_history_inputs(): + observed = {} + + class FakeBrokerAdapters: + def build_market_data_port(self, quote_context): + observed["market_data_port_context"] = quote_context + return "market-data-port" + + def build_market_history_loader(self, market_data_port): + observed["market_history_loader_port"] = market_data_port + return "market-history-loader" + + def build_price_history(self, market_data_port, symbol): + observed.setdefault("price_history_calls", []).append((market_data_port, symbol)) + return [{"close": 1.0}] + + adapters = build_runtime_strategy_adapters( + strategy_runtime=SimpleNamespace(evaluate=lambda **_kwargs: None), + strategy_profile="soxl_soxx_trend_income", + strategy_runtime_config={"trend_ma_window": 150}, + available_inputs=("market_history", "benchmark_history", "qqq_history"), + benchmark_symbol="QQQ", + signal_text_fn=lambda icon: f"signal:{icon}", + translator=lambda key, **_kwargs: key, + broker_adapters=FakeBrokerAdapters(), + calculate_rotation_indicators_fn=lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("unexpected fallback")), + build_strategy_evaluation_inputs_fn=lambda **_kwargs: {}, + map_strategy_decision_to_plan_fn=lambda *_args, **_kwargs: {}, + ) + + result = adapters.calculate_strategy_indicators("quote-context") + + assert observed["market_data_port_context"] == "quote-context" + assert observed["market_history_loader_port"] == "market-data-port" + assert observed["price_history_calls"] == [("market-data-port", "QQQ"), ("market-data-port", "QQQ")] + assert result == { + "market_history": "market-history-loader", + "benchmark_history": [{"close": 1.0}], + "qqq_history": [{"close": 1.0}], + } + + +def test_runtime_strategy_adapters_fall_back_to_rotation_indicators(): + observed = {} + + def fake_rotation_indicators(quote_context, *, trend_window): + observed["rotation_call"] = (quote_context, trend_window) + return {"rotation": True} + + adapters = build_runtime_strategy_adapters( + strategy_runtime=SimpleNamespace(evaluate=lambda **_kwargs: None), + strategy_profile="soxl_soxx_trend_income", + strategy_runtime_config={"trend_ma_window": 180}, + available_inputs=("portfolio_snapshot",), + benchmark_symbol="QQQ", + signal_text_fn=lambda icon: f"signal:{icon}", + translator=lambda key, **_kwargs: key, + broker_adapters=SimpleNamespace(build_market_data_port=lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("unexpected market data port"))), + calculate_rotation_indicators_fn=fake_rotation_indicators, + build_strategy_evaluation_inputs_fn=lambda **_kwargs: {}, + map_strategy_decision_to_plan_fn=lambda *_args, **_kwargs: {}, + ) + + result = adapters.calculate_strategy_indicators("quote-context") + + assert observed["rotation_call"] == ("quote-context", 180) + assert result == {"rotation": True} + + +def test_runtime_strategy_adapters_resolve_plan_builds_inputs_and_maps_decision(): + observed = {} + + class FakeBrokerAdapters: + def build_portfolio_snapshot_from_account_state(self, account_state): + observed["snapshot_from_account_state"] = account_state + return "snapshot-from-account-state" + + def build_account_state_from_snapshot(self, snapshot): + observed["account_state_from_snapshot"] = snapshot + return {"derived": True} + + def fake_evaluate(**kwargs): + observed["evaluation_inputs"] = kwargs + return SimpleNamespace(decision="decision-1") + + def fake_build_inputs(**kwargs): + observed["build_inputs"] = kwargs + return { + "portfolio_snapshot": kwargs["portfolio_snapshot"], + "account_state": kwargs["account_state"], + "translator": kwargs["translator"], + "signal_text_fn": kwargs["signal_text_fn"], + } + + def fake_map_plan(decision, **kwargs): + observed["map_call"] = (decision, kwargs) + return {"plan": True} + + adapters = build_runtime_strategy_adapters( + strategy_runtime=SimpleNamespace(evaluate=fake_evaluate), + strategy_profile="soxl_soxx_trend_income", + strategy_runtime_config={"trend_ma_window": 150}, + available_inputs=("portfolio_snapshot", "account_state", "benchmark_history"), + benchmark_symbol="QQQ", + signal_text_fn=lambda icon: f"signal:{icon}", + translator=lambda key, **_kwargs: f"tr:{key}", + broker_adapters=FakeBrokerAdapters(), + calculate_rotation_indicators_fn=lambda *_args, **_kwargs: {}, + build_strategy_evaluation_inputs_fn=fake_build_inputs, + map_strategy_decision_to_plan_fn=fake_map_plan, + ) + + result = adapters.resolve_rebalance_plan(indicators={"benchmark_history": [{"close": 1.0}]}, snapshot="snapshot-1") + + assert observed["account_state_from_snapshot"] == "snapshot-1" + assert observed["build_inputs"]["portfolio_snapshot"] == "snapshot-1" + assert observed["build_inputs"]["account_state"] == {"derived": True} + assert observed["evaluation_inputs"]["portfolio_snapshot"] == "snapshot-1" + assert observed["map_call"] == ( + "decision-1", + { + "account_state": {"derived": True}, + "snapshot": "snapshot-1", + "strategy_profile": "soxl_soxx_trend_income", + "runtime_metadata": None, + }, + ) + assert result == {"plan": True} diff --git a/tests/test_shared_chat_id_fallback.py b/tests/test_shared_chat_id_fallback.py index 4225b84..da6f361 100644 --- a/tests/test_shared_chat_id_fallback.py +++ b/tests/test_shared_chat_id_fallback.py @@ -143,7 +143,6 @@ def test_global_telegram_chat_id_is_used(self): ): sys.modules.pop("main", None) module = importlib.import_module("main") - module = importlib.reload(module) self.assertEqual(module.TG_CHAT_ID, "shared-chat-id") diff --git a/tests/test_strategy_loader.py b/tests/test_strategy_loader.py index 499f74a..27d0802 100644 --- a/tests/test_strategy_loader.py +++ b/tests/test_strategy_loader.py @@ -44,35 +44,28 @@ def test_load_strategy_entrypoint_resolves_soxl_soxx_trend_income(self): ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"), ) - def test_load_strategy_entrypoint_resolves_mega_cap_dynamic_top20(self): + def test_load_strategy_entrypoint_rejects_archived_mega_cap_dynamic_top20(self): try: from strategy_loader import load_strategy_entrypoint_for_profile - entrypoint = load_strategy_entrypoint_for_profile("mega_cap_leader_rotation_dynamic_top20") + with self.assertRaises(ValueError): + load_strategy_entrypoint_for_profile("mega_cap_leader_rotation_dynamic_top20") except ModuleNotFoundError as exc: if exc.name in {"numpy", "pandas"}: self.skipTest(f"{exc.name} is not installed") raise - self.assertEqual(entrypoint.manifest.profile, "mega_cap_leader_rotation_dynamic_top20") - self.assertEqual(entrypoint.manifest.required_inputs, frozenset({"feature_snapshot"})) - - def test_load_strategy_entrypoint_resolves_dynamic_mega_leveraged_pullback(self): + def test_load_strategy_entrypoint_rejects_archived_dynamic_mega_leveraged_pullback(self): try: from strategy_loader import load_strategy_entrypoint_for_profile - entrypoint = load_strategy_entrypoint_for_profile("dynamic_mega_leveraged_pullback") + with self.assertRaises(ValueError): + load_strategy_entrypoint_for_profile("dynamic_mega_leveraged_pullback") except ModuleNotFoundError as exc: if exc.name in {"numpy", "pandas"}: self.skipTest(f"{exc.name} is not installed") raise - self.assertEqual(entrypoint.manifest.profile, "dynamic_mega_leveraged_pullback") - self.assertEqual( - entrypoint.manifest.required_inputs, - frozenset({"feature_snapshot", "market_history", "benchmark_history", "portfolio_snapshot"}), - ) - def test_load_strategy_entrypoint_rejects_legacy_soxl_alias(self): try: from strategy_loader import load_strategy_entrypoint_for_profile @@ -118,31 +111,17 @@ def test_load_strategy_runtime_adapter_declares_russell_inputs(self): self.assertEqual(adapter.portfolio_input_name, "portfolio_snapshot") self.assertEqual(adapter.status_icon, "📏") - def test_load_strategy_runtime_adapter_declares_mega_cap_inputs(self): + def test_load_strategy_runtime_adapter_rejects_archived_mega_cap_inputs(self): from strategy_loader import load_strategy_runtime_adapter_for_profile - adapter = load_strategy_runtime_adapter_for_profile("mega_cap_leader_rotation_dynamic_top20") - - self.assertEqual( - adapter.available_inputs, - frozenset({"feature_snapshot", "portfolio_snapshot"}), - ) - self.assertEqual(adapter.portfolio_input_name, "portfolio_snapshot") - self.assertTrue(adapter.require_snapshot_manifest) - self.assertEqual(adapter.status_icon, "👑") + with self.assertRaises(ValueError): + load_strategy_runtime_adapter_for_profile("mega_cap_leader_rotation_dynamic_top20") - def test_load_strategy_runtime_adapter_declares_dynamic_mega_leveraged_inputs(self): + def test_load_strategy_runtime_adapter_rejects_archived_dynamic_mega_leveraged_inputs(self): from strategy_loader import load_strategy_runtime_adapter_for_profile - adapter = load_strategy_runtime_adapter_for_profile("dynamic_mega_leveraged_pullback") - - self.assertEqual( - adapter.available_inputs, - frozenset({"feature_snapshot", "market_history", "benchmark_history", "portfolio_snapshot"}), - ) - self.assertEqual(adapter.portfolio_input_name, "portfolio_snapshot") - self.assertTrue(adapter.require_snapshot_manifest) - self.assertEqual(adapter.status_icon, "2x") + with self.assertRaises(ValueError): + load_strategy_runtime_adapter_for_profile("dynamic_mega_leveraged_pullback") def test_load_strategy_runtime_adapter_declares_hybrid_inputs(self): from strategy_loader import load_strategy_runtime_adapter_for_profile diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index 5074eb9..eb20e5c 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -126,6 +126,11 @@ def _build_runtime_settings( class StrategyRuntimeTests(unittest.TestCase): def test_market_history_runtime_loads_loader_into_context(self): + class _FixedDatetime: + @classmethod + def now(cls, tz=None): + return datetime(2026, 4, 1, tzinfo=tz or timezone.utc) + class _GlobalEntrypoint: manifest = StrategyManifest( profile="global_etf_rotation", @@ -143,7 +148,10 @@ def evaluate(self, ctx): entrypoint = _GlobalEntrypoint() runtime = strategy_runtime_module.LoadedStrategyRuntime( entrypoint=entrypoint, - runtime_adapter=StrategyRuntimeAdapter(portfolio_input_name="portfolio_snapshot"), + runtime_adapter=StrategyRuntimeAdapter( + portfolio_input_name="portfolio_snapshot", + runtime_policy=StrategyRuntimePolicy(signal_effective_after_trading_days=1), + ), runtime_settings=_build_runtime_settings("global_etf_rotation"), merged_runtime_config={"safe_haven": "BIL", "ranking_pool": ("VOO", "VGK")}, ) @@ -157,44 +165,62 @@ def market_history_loader(*_args, **_kwargs): buying_power=200.0, positions=(), ) - result = runtime.evaluate( - market_history=market_history_loader, - portfolio_snapshot=snapshot, - translator=lambda key, **_kwargs: key, - ) + with patch.object(strategy_runtime_module, "datetime", _FixedDatetime): + result = runtime.evaluate( + market_history=market_history_loader, + portfolio_snapshot=snapshot, + translator=lambda key, **_kwargs: key, + ) self.assertIs(entrypoint.ctx.market_data["market_history"], market_history_loader) self.assertIs(entrypoint.ctx.portfolio, snapshot) + self.assertEqual(entrypoint.ctx.runtime_config["signal_effective_after_trading_days"], 1) self.assertEqual(result.metadata["strategy_profile"], "global_etf_rotation") + self.assertEqual(result.metadata["signal_date"], "2026-04-01") + self.assertEqual(result.metadata["effective_date"], "2026-04-02") + self.assertEqual(result.metadata["execution_timing_contract"], "next_trading_day") def test_runtime_exposes_managed_symbols_and_injects_translator(self): + class _FixedDatetime: + @classmethod + def now(cls, tz=None): + return datetime(2026, 4, 1, tzinfo=tz or timezone.utc) + entrypoint = _SemiconductorEntrypoint() runtime = strategy_runtime_module.LoadedStrategyRuntime( entrypoint=entrypoint, - runtime_adapter=StrategyRuntimeAdapter(portfolio_input_name="portfolio_snapshot"), + runtime_adapter=StrategyRuntimeAdapter( + portfolio_input_name="portfolio_snapshot", + runtime_policy=StrategyRuntimePolicy(signal_effective_after_trading_days=1), + ), runtime_settings=_build_runtime_settings("soxl_soxx_trend_income"), merged_runtime_config={"managed_symbols": ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI")}, ) - result = runtime.evaluate( - derived_indicators={"soxl": {"price": 1.0, "ma_trend": 2.0}}, - portfolio_snapshot=PortfolioSnapshot( - as_of=datetime.now(timezone.utc), - total_equity=100.0, - buying_power=100.0, - positions=(), - ), - translator=lambda key, **_kwargs: key, - signal_text_fn=lambda icon: f"signal:{icon}", - ) + with patch.object(strategy_runtime_module, "datetime", _FixedDatetime): + result = runtime.evaluate( + derived_indicators={"soxl": {"price": 1.0, "ma_trend": 2.0}}, + portfolio_snapshot=PortfolioSnapshot( + as_of=datetime.now(timezone.utc), + total_equity=100.0, + buying_power=100.0, + positions=(), + ), + translator=lambda key, **_kwargs: key, + signal_text_fn=lambda icon: f"signal:{icon}", + ) self.assertEqual(runtime.managed_symbols, ("SOXL", "SOXX", "BOXX", "QQQI", "SPYI")) self.assertEqual(entrypoint.ctx.market_data["derived_indicators"]["soxl"]["price"], 1.0) self.assertEqual(entrypoint.ctx.portfolio.total_equity, 100.0) self.assertIn("translator", entrypoint.ctx.runtime_config) self.assertEqual(entrypoint.ctx.runtime_config["signal_text_fn"]("idle"), "signal:idle") + self.assertEqual(entrypoint.ctx.runtime_config["signal_effective_after_trading_days"], 1) self.assertEqual(result.metadata["strategy_profile"], "soxl_soxx_trend_income") self.assertEqual(result.metadata["strategy_display_name"], "SOXL/SOXX Semiconductor Trend Income") + self.assertEqual(result.metadata["signal_date"], "2026-04-01") + self.assertEqual(result.metadata["effective_date"], "2026-04-02") + self.assertEqual(result.metadata["execution_timing_contract"], "next_trading_day") def test_load_strategy_runtime_uses_entrypoint_default_config(self): entrypoint = _SemiconductorEntrypoint()