diff --git a/application/execution_service.py b/application/execution_service.py index 662be44..65944c2 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -265,6 +265,7 @@ class ExecutionCycleResult: DEFAULT_BUY_QUANTITY_STEP = 1.0 FRACTIONAL_BUY_QUANTITY_STEP = 0.0001 SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS = frozenset({"TQQQ", "SOXL"}) +_SMALL_ACCOUNT_RETENTION_MIN_TARGET_SHARE_RATIO_DEFAULT = 0.85 SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL = { "SOXX": 0.90, } @@ -272,6 +273,7 @@ class ExecutionCycleResult: "TQQQ": 0.90, "SOXL": 0.90, "SOXX": 0.90, + "QQQM": 0.85, } @@ -392,20 +394,40 @@ def _apply_safe_haven_cash_substitution( return adjusted_plan, adjusted_allocation -def _should_retain_existing_whole_share(symbol, *, target_value, price) -> bool: +def _should_retain_existing_whole_share(symbol, *, target_value, price, quantity=0.0) -> bool: + """Decide whether an existing whole-share position should be retained. + + Universal rule: if the account already holds this symbol (>0 shares) and the + strategy wants to keep a meaningful fraction of a share (target >= 85% of 1-share + price), retain the position. This prevents the sell-then-fail-to-rebuy cycle for + small accounts where target < 1 share but still close to it. + + Genuine reductions (target << 1 share) are NOT blocked — the sell proceeds. + The hardcoded lists act as overrides for symbols that need a different threshold. + """ normalized_symbol = str(symbol or "").strip().upper() + held = float(quantity or 0.0) + target = float(target_value or 0.0) + quote_price = max(0.0, float(price or 0.0)) + + # Universal: held + positive target + target close to 1-share price → retain + if held > 0.0 and target > 0.0 and quote_price > 0.0: + if target >= quote_price * _SMALL_ACCOUNT_RETENTION_MIN_TARGET_SHARE_RATIO_DEFAULT: + return True + + # Legacy whitelist — unconditional retention (safety net) if normalized_symbol in SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS: return True + # Legacy per-symbol ratio-based retention (override / tighter threshold) min_target_share_ratio = ( SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_MIN_TARGET_SHARE_RATIO_BY_SYMBOL.get(normalized_symbol) ) if min_target_share_ratio is None: return False - quote_price = max(0.0, float(price or 0.0)) if quote_price <= 0.0: return False - return max(0.0, float(target_value or 0.0)) >= quote_price * float(min_target_share_ratio) + return target >= quote_price * float(min_target_share_ratio) def _should_bootstrap_whole_share_buy(symbol, *, target_value, limit_price) -> bool: @@ -721,7 +743,7 @@ def _apply_small_account_whole_share_compatibility( ) # Skip bootstrap if the account cannot afford even 1 share at limit price. _can_afford_one_share = limit_price > 0.0 and _estimated_buying_power >= limit_price - if not _should_retain_existing_whole_share(symbol, target_value=target_value, price=price): + if not _should_retain_existing_whole_share(symbol, target_value=target_value, price=price, quantity=quantities.get(symbol, 0.0)): if ( quantities.get(symbol, 0.0) <= 0.0 and 0.0 < target_value < limit_price diff --git a/notifications/renderers.py b/notifications/renderers.py index b30f407..b00e274 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -3,31 +3,32 @@ from __future__ import annotations from collections.abc import Mapping -import re from notifications.events import RenderedNotification from quant_platform_kit.common.notification_localization import ( localize_notification_text as _base_localize_notification_text, - translator_uses_zh as _base_translator_uses_zh, ) - -_PRICE_SOURCE_LABELS = { - "longbridge_candlesticks": ("LongBridge 日线K线", "LongBridge daily candlesticks"), - "schwab_daily_history_with_live_quote_overlay": ("Schwab 日线历史", "Schwab daily history"), - "firstrade_ohlc_with_live_quote_overlay": ("Firstrade OHLC", "Firstrade OHLC"), - "market_quote": ("实时行情报价", "market quote"), - "mixed_market_quote_snapshot_close": ( - "实时行情报价 + 快照收盘价回补", - "market quote + snapshot close fallback", - ), - "mixed_market_quote_historical_close": ( - "实时行情报价 + 历史收盘价回补", - "market quote + historical close fallback", - ), - "snapshot_close": ("快照收盘价", "snapshot close"), - "historical_close": ("历史收盘价", "historical close"), - "market_data": ("市场数据", "market data"), -} +from quant_platform_kit.notifications.renderer_base import ( + as_float_or_none as _as_float_or_none, + build_timing_audit_lines as _build_timing_audit_lines_shared, + build_tqqq_risk_control_lines as _build_tqqq_risk_control_lines_shared, + effective_volatility_delever_threshold as _effective_volatility_delever_threshold, + format_percent as _format_percent, + format_percentile as _format_percentile, + format_sample_count as _format_sample_count, + format_signal_snapshot_line as _format_signal_snapshot_line_shared, + format_tqqq_volatility_delever_allocation_detail as _format_tqqq_volatility_delever_allocation_detail, + format_volatility_delever_threshold_detail as _format_volatility_delever_threshold_detail, + is_compact_dashboard_audit_line as _is_compact_dashboard_audit_line, + is_truthy, + localize_price_source_label as _localize_price_source_label, + localize_timing_contract as _localize_timing_contract_shared, + present as _present, + relabel_dashboard_cash_labels as _relabel_dashboard_cash_labels_shared, + split_detail_segment as _split_detail_segment, + split_labeled_text as _split_labeled_text, + translator_uses_zh as _translator_uses_zh, +) _LONG_BRIDGE_ZH_NOTIFICATION_REPLACEMENTS = ( ("regime=hard_defense", "市场阶段=强防御"), @@ -67,31 +68,6 @@ "universe_fallback": ("股票池复用", "universe fallback"), } -try: - from quant_platform_kit.common.notification_localization import ( - localize_price_source_label as _shared_localize_price_source_label, - ) -except ImportError: # pragma: no cover - compatibility with older pinned shared wheels - _shared_localize_price_source_label = None - - -def _localize_price_source_label(value, *, translator=None, locale=None): - source = str(value or "").strip() - use_zh = _base_translator_uses_zh(translator) if translator is not None else str(locale or "").startswith("zh") - if not source: - return "未知" if use_zh else "unknown" - label = _PRICE_SOURCE_LABELS.get(source) - if label is not None: - return label[0] if use_zh else label[1] - if _shared_localize_price_source_label is not None: - return _shared_localize_price_source_label(source, translator=translator, locale=locale) - return source.replace("_", " ") - -_DETAIL_FIELD_SPLIT_RE = re.compile(r"\s+(?=[^\s=::]+[=::])") - - -def _translator_uses_zh(translator) -> bool: - return _base_translator_uses_zh(translator) def _localize_notification_text(text, *, translator): @@ -123,39 +99,12 @@ def _localize_source_input_status(status, *, translator) -> str: def _localize_timing_contract(contract: str, *, translator) -> str: - value = str(contract or "").strip() - if not value: - return "" - if value == "same_trading_day": - return "当日执行" if _translator_uses_zh(translator) else "same trading day" - if value == "next_trading_day": - return "次一交易日执行" if _translator_uses_zh(translator) else "next trading day" - match = re.fullmatch(r"next_(\d+)_trading_days", value) - if match: - count = int(match.group(1)) - if _translator_uses_zh(translator): - return f"{count}个交易日后执行" - return f"next {count} trading days" - return _localize_notification_text(value, translator=translator) - - -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 + """Thin wrapper — adds LB-specific notification localisation fallback.""" + result = _localize_timing_contract_shared(contract, translator=translator) + if result and result not in ("当日执行", "same trading day", "次一交易日执行", "next trading day"): + if "个交易日后执行" not in result and "next " not in result: + return _localize_notification_text(result, translator=translator) + return result def _append_labeled_text(lines, template_key, value, *, translator, value_key): @@ -167,20 +116,7 @@ def _append_labeled_text(lines, template_key, value, *, translator, value_key): 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" - localized_contract = _localize_timing_contract(contract, translator=translator) - if signal_date and effective_date: - value = f"{signal_date} -> {effective_date}" - else: - value = signal_date or effective_date or localized_contract - if localized_contract and localized_contract not in value: - value = f"{value} ({localized_contract})" if value else localized_contract - return [f"{label}: {value}"] + return _build_timing_audit_lines_shared(execution, translator=translator) def _has_benchmark_context(execution): @@ -205,172 +141,11 @@ def _build_benchmark_lines(execution, *, translator): ] -def _format_percent(value) -> str: - try: - return f"{float(value) * 100:.1f}%" - except (TypeError, ValueError): - return "n/a" - - -def _as_float_or_none(value): - try: - return float(value) - except (TypeError, ValueError): - return None - - -def _format_percentile(value) -> str: - try: - percentile = float(value) * 100 - except (TypeError, ValueError): - return "p?" - if float(percentile).is_integer(): - return f"p{int(percentile)}" - return f"p{percentile:.1f}" - - -def _format_sample_count(value) -> str: - try: - count = float(value) - except (TypeError, ValueError): - return "n/a" - if float(count).is_integer(): - return str(int(count)) - return f"{count:.1f}" - - -def _present(value) -> bool: - return value not in (None, "") - - -def _effective_volatility_delever_threshold(execution, *, prefix: str): - mode = str(execution.get(f"{prefix}_threshold_mode") or "").strip().lower() - dynamic_threshold = execution.get(f"{prefix}_dynamic_threshold") - if mode == "rolling_percentile" and _present(dynamic_threshold): - return dynamic_threshold - return execution.get(f"{prefix}_threshold") - - -def _format_volatility_delever_threshold_detail(execution, *, prefix: str, translator) -> str: - mode = str(execution.get(f"{prefix}_threshold_mode") or "").strip().lower() - fixed_threshold = execution.get(f"{prefix}_threshold") - dynamic_threshold = execution.get(f"{prefix}_dynamic_threshold") - if mode == "rolling_percentile": - kwargs = { - "percentile": _format_percentile(execution.get(f"{prefix}_dynamic_percentile")), - "lookback": _format_sample_count(execution.get(f"{prefix}_dynamic_lookback")), - "min_periods": _format_sample_count(execution.get(f"{prefix}_dynamic_min_periods")), - "sample_count": _format_sample_count(execution.get(f"{prefix}_dynamic_sample_count")), - "floor": _format_percent(execution.get(f"{prefix}_dynamic_floor")), - "cap": _format_percent(execution.get(f"{prefix}_dynamic_cap")), - "fixed_threshold": _format_percent(fixed_threshold), - } - if _present(dynamic_threshold): - return translator("blend_gate_volatility_threshold_detail_dynamic", **kwargs) - return translator("blend_gate_volatility_threshold_detail_dynamic_fallback", **kwargs) - return translator( - "blend_gate_volatility_threshold_detail_fixed", - threshold=_format_percent(fixed_threshold), - ) - - -def _format_tqqq_volatility_delever_allocation_detail( - execution, - *, - prefix: str, - redirect_symbol: str, - translator, -) -> str: - retained_ratio = _as_float_or_none(execution.get(f"{prefix}_retained_ratio")) - redirected_ratio = _as_float_or_none(execution.get(f"{prefix}_redirected_ratio")) - if retained_ratio is None: - retained_ratio = _as_float_or_none(execution.get(f"{prefix}_retention_ratio")) - if redirected_ratio is None and retained_ratio is not None: - redirected_ratio = max(0.0, min(1.0, 1.0 - retained_ratio)) - return translator( - "tqqq_volatility_delever_allocation_detail", - retained_ratio=_format_percent(retained_ratio), - redirected_ratio=_format_percent(redirected_ratio), - redirect_symbol=redirect_symbol or "QQQ", - ) - - def _build_risk_control_lines(execution, *, translator): - if _is_truthy(execution.get("dual_drive_volatility_delever_applied")): - redirect_symbol = str(execution.get("dual_drive_volatility_delever_redirect_symbol") or "QQQ").strip().upper() - window = str(execution.get("dual_drive_volatility_delever_window") or "5").strip() - threshold = _effective_volatility_delever_threshold( - execution, - prefix="dual_drive_volatility_delever", - ) - threshold_detail = _format_volatility_delever_threshold_detail( - execution, - prefix="dual_drive_volatility_delever", - translator=translator, - ) - allocation_detail = _format_tqqq_volatility_delever_allocation_detail( - execution, - prefix="dual_drive_volatility_delever", - redirect_symbol=redirect_symbol or "QQQ", - translator=translator, - ) - if str(execution.get("dual_drive_volatility_delever_trigger_reason") or "").strip() == "hysteresis_hold": - return [ - translator( - "risk_control_tqqq_volatility_delever_hysteresis_dynamic", - window=window, - volatility=_format_percent(execution.get("dual_drive_volatility_delever_metric")), - exit_threshold=_format_percent(execution.get("dual_drive_volatility_delever_exit_threshold")), - threshold=_format_percent(threshold), - threshold_detail=threshold_detail, - source_symbol="TQQQ", - redirect_symbol=redirect_symbol or "QQQ", - allocation_detail=allocation_detail, - ) - ] - return [ - translator( - "risk_control_tqqq_volatility_delever_applied_dynamic", - window=window, - volatility=_format_percent(execution.get("dual_drive_volatility_delever_metric")), - threshold=_format_percent(threshold), - threshold_detail=threshold_detail, - source_symbol="TQQQ", - redirect_symbol=redirect_symbol or "QQQ", - allocation_detail=allocation_detail, - ) - ] - return [] - - -def _relabel_dashboard_buying_power(text: str, *, cash_only_execution: bool, translator) -> str: - value = str(text or "") - if cash_only_execution: - value = value.replace("总资产(策略净值)", "总资产(策略标的+现金,不含融资额度)") - value = value.replace( - "Total assets (strategy net liquidation)", - "Total assets (strategy symbols + cash, ex-margin)", - ) - target = translator("buying_power") - for source in ("Buying power", "购买力"): - if source != target: - value = value.replace(source, target) - return value - value = value.replace("总资产(策略标的+现金,不含融资额度)", "总资产(策略净值)") - value = value.replace("总资产(策略标的+现金)", "总资产(策略净值)") - value = value.replace( - "Total assets (strategy symbols + cash, ex-margin)", - "Total assets (strategy net liquidation)", - ) - value = value.replace( - "Total assets (strategy symbols + cash)", - "Total assets (strategy net liquidation)", + return _build_tqqq_risk_control_lines_shared( + execution if isinstance(execution, Mapping) else {}, + translator=translator, ) - target = translator("buying_power_margin") - for source in ("Available cash", "可用现金"): - if source != target: - value = value.replace(source, target) - return value def _format_dashboard_text(text, *, translator=None, cash_only_execution: bool = True) -> str: @@ -384,7 +159,7 @@ def _format_dashboard_text(text, *, translator=None, cash_only_execution: bool = lines.append(line) result = "\n".join(lines) if translator is not None: - result = _relabel_dashboard_buying_power( + result = _relabel_dashboard_cash_labels_shared( result, cash_only_execution=cash_only_execution, translator=translator, @@ -392,16 +167,6 @@ def _format_dashboard_text(text, *, translator=None, cash_only_execution: bool = return result -def _is_compact_dashboard_audit_line(line: str) -> bool: - text = str(line or "").strip() - lowered = text.lower() - return ( - text.startswith(("⏱", "🧾", "🧩 输入状态", "📊", "🎯", "🛡️")) - or lowered.startswith(("signal:", "signal:", "market status:")) - or text.startswith(("信号:", "信号:", "市场状态:", "市场状态:")) - ) - - def _append_dashboard_block(lines, *, execution, separator, translator, compact: bool = False) -> None: cash_only_execution = bool(execution.get("cash_only_execution", True)) dashboard_text = _format_dashboard_text( @@ -423,28 +188,11 @@ def _append_timing_lines(lines, *, execution, translator) -> None: def _format_signal_snapshot_line(snapshot, *, translator) -> str: - if not isinstance(snapshot, Mapping): - return "" - market_date = str(snapshot.get("market_date") or snapshot.get("signal_as_of") or "").strip() - source = str(snapshot.get("latest_price_source") or "").strip() - warning = snapshot.get("data_freshness_warning") - if not market_date and not source and warning in (None, "", False): - return "" - if _translator_uses_zh(translator): - parts = [ - f"日期 {market_date or '未知'}", - f"数据源 {_localize_price_source_label(source, translator=translator)}", - ] - if warning not in (None, "", False): - parts.append(f"提示 {_localize_notification_text(warning, translator=translator)}") - return "🧾 信号快照: " + " | ".join(parts) - parts = [ - f"date {market_date or 'unknown'}", - f"source {_localize_price_source_label(source, translator=translator)}", - ] - if warning not in (None, "", False): - parts.append(f"warning {warning}") - return "🧾 Signal snapshot: " + " | ".join(parts) + return _format_signal_snapshot_line_shared( + snapshot, + translator=translator, + localize_text=_localize_notification_text, + ) def _append_signal_snapshot_line(lines, *, execution, translator) -> None: @@ -454,9 +202,7 @@ def _append_signal_snapshot_line(lines, *, execution, translator) -> None: def _is_truthy(value) -> bool: - if isinstance(value, bool): - return value - return str(value or "").strip().lower() in {"1", "true", "yes", "y"} + return is_truthy(value) def _format_source_input_line(snapshot, *, translator) -> str: diff --git a/pyproject.toml b/pyproject.toml index ea59755..27b0fa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "google-cloud-storage", "google-auth", "longport==3.0.23", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f1d2c323b2a96383acec83a07bbf1816938c4650", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@8378e939d9324ea63a0f45c9f21ba0e2eeb1cfff", "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@17ddb86c72d44b2c7b78ba7a10d8f71b21180166", "hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@b6a8ac2ad3c8110b5ea74fb059c8206388d63bcd", ] diff --git a/qsl.toml b/qsl.toml index 402a4e4..60032cd 100644 --- a/qsl.toml +++ b/qsl.toml @@ -5,7 +5,7 @@ ring = 3 allow_legacy = false [qsl.requires] -quant_platform_kit = "37c81901160c5b31127a27dba1c63944933fb6bf" +quant_platform_kit = "8378e939d9324ea63a0f45c9f21ba0e2eeb1cfff" us_equity_strategies = "17ddb86c72d44b2c7b78ba7a10d8f71b21180166" hk_equity_strategies = "b6a8ac2ad3c8110b5ea74fb059c8206388d63bcd"