From 28d455693ae9c1900f384161ec44978d99d13292 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 9 Jul 2026 04:17:40 +0800 Subject: [PATCH 1/6] feat(rebalance): record execution telemetry via PerformanceMonitor Persist IBKR execution summaries after each strategy cycle using QuantPlatformKit try_record_platform_execution(). Co-Authored-By: Claude Co-authored-by: Cursor --- application/rebalance_service.py | 32 ++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/application/rebalance_service.py b/application/rebalance_service.py index d2b4d84..079f67d 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -24,6 +24,7 @@ localize_notification_text as _base_localize_notification_text, translator_uses_zh as _base_translator_uses_zh, ) +from quant_platform_kit.strategy_lifecycle.performance_monitor import try_record_platform_execution from quant_platform_kit.common.port_adapters import CallableNotificationPort, CallablePortfolioPort @@ -37,6 +38,31 @@ _DETAIL_FIELD_SPLIT_RE = re.compile(r"\s+(?=[^\s=::]+[=::])") +def _record_platform_execution_telemetry( + signal_metadata: Mapping[str, object] | None, + execution_summary: Mapping[str, object] | None, +) -> None: + metadata = signal_metadata if isinstance(signal_metadata, Mapping) else {} + profile = str(metadata.get("strategy_profile") or "").strip() + if not profile: + return + summary = dict(execution_summary or {}) + try_record_platform_execution( + profile, + { + "platform": "ibkr", + "mode": summary.get("mode"), + "execution_status": summary.get("execution_status"), + "no_op_reason": summary.get("no_op_reason") or metadata.get("no_op_reason"), + "orders_submitted": list(summary.get("orders_submitted") or ()), + "orders_filled": list(summary.get("orders_filled") or ()), + "orders_skipped": list(summary.get("orders_skipped") or ()), + "trade_date": summary.get("trade_date") or metadata.get("trade_date"), + }, + domain=str(metadata.get("domain") or ""), + ) + + def _format_text(value, *, fallback: str) -> str: text = str(value).strip() if value is not None else "" return text or fallback @@ -885,6 +911,7 @@ def run_strategy_core( extra_notification_lines=config.extra_notification_lines, ) ) + _record_platform_execution_telemetry(signal_metadata, {}) return StrategyCycleResult( result="OK - no-op" if notification_suppressed else "OK - heartbeat", signal_metadata=dict(signal_metadata or {}), @@ -969,6 +996,10 @@ def run_strategy_core( extra_notification_lines=config.extra_notification_lines, ) ) + _record_platform_execution_telemetry( + signal_metadata, + {"action_done": False, "no_op_reason": "execution_already_recorded"}, + ) return StrategyCycleResult( result="OK - no-op" if notification_suppressed else "OK - heartbeat", signal_metadata=dict(signal_metadata or {}), @@ -1063,6 +1094,7 @@ def run_strategy_core( ), flush=True, ) + _record_platform_execution_telemetry(signal_metadata, dict(execution_summary or {})) return StrategyCycleResult( result="OK - executed", signal_metadata=dict(signal_metadata or {}), diff --git a/pyproject.toml b/pyproject.toml index 3a7e027..d5a1654 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "google-cloud-secret-manager", "google-cloud-storage", "yfinance", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f1d2c323b2a96383acec83a07bbf1816938c4650", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@724fb81e5bc7423cae5e073c3094c33ed1dba1d4", "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@17ddb86c72d44b2c7b78ba7a10d8f71b21180166", "hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@b6a8ac2ad3c8110b5ea74fb059c8206388d63bcd", ] From 98a877e3e87784162e8e10c75c44083de7485aaa Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:10:32 +0800 Subject: [PATCH 2/6] fix(ci): align QPK pin with canonical 0af622a after #199 merge Co-Authored-By: Claude Co-authored-by: Cursor --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d5a1654..a7338a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "google-cloud-secret-manager", "google-cloud-storage", "yfinance", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@724fb81e5bc7423cae5e073c3094c33ed1dba1d4", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@0af622ac9d47f7ef93f9379f9ded314c27a344ff", "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@17ddb86c72d44b2c7b78ba7a10d8f71b21180166", "hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@b6a8ac2ad3c8110b5ea74fb059c8206388d63bcd", ] From a1443157e63d0b5c16c6e41c4ecb0890325f58fc Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:10:37 +0800 Subject: [PATCH 3/6] refactor(notifications): migrate to shared renderer_base (-253 lines) Replace locally duplicated functions with imports from quant_platform_kit.notifications.renderer_base. Keep thin wrappers for IBKR-specific data access patterns (e.g. execution_annotations flattening, hardcoded cash labels). Co-Authored-By: Claude --- notifications/renderers.py | 327 +++++-------------------------------- 1 file changed, 37 insertions(+), 290 deletions(-) diff --git a/notifications/renderers.py b/notifications/renderers.py index a4bcaea..04b35bf 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -3,52 +3,35 @@ from __future__ import annotations from collections.abc import Mapping -import re from notifications.events import RenderedNotification from quant_platform_kit.common.quantity import format_quantity 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"), -} - -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("_", " ") +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, + compact_dashboard_lines, + 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, + is_truthy as _is_truthy, + localize_price_source_label as _localize_price_source_label, + localize_timing_contract as _localize_timing_contract, + present as _present, + relabel_dashboard_cash_labels as _relabel_dashboard_cash_labels_shared, + resolve_execution, + split_detail_segment as _split_detail_segment, + split_labeled_text as _split_labeled_text, + translator_uses_zh as _translator_uses_zh, +) _EXTRA_ZH_REASON_REPLACEMENTS = ( ("pending_orders_detected", "检测到未完成订单"), @@ -60,8 +43,6 @@ def _localize_price_source_label(value, *, translator=None, locale=None): ("fail_reason=", "失败原因="), ("decision=", "决策="), ) -_DETAIL_FIELD_SPLIT_RE = re.compile(r"\s+(?=[^\s=::]+[=::])") - def _format_text(value, *, fallback: str) -> str: text = str(value).strip() if value is not None else "" @@ -79,10 +60,6 @@ def _format_symbol_preview(symbols, *, limit: int = 3) -> str: return ",".join(shown) -def _translator_uses_zh(translator) -> bool: - return _base_translator_uses_zh(translator) - - def _extra_notification_lines(extra_notification_lines) -> list[str]: return [str(line).strip() for line in extra_notification_lines or () if str(line).strip()] @@ -95,42 +72,6 @@ def _localize_notification_text(text: str, *, 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: str) -> list[str]: - 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: str) -> list[str]: - 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 _format_prefixed_text(prefix: str, text: str) -> list[str]: parts = _split_labeled_text(text) if not parts: @@ -445,235 +386,41 @@ def _format_dashboard_text(text) -> str: def _relabel_dashboard_cash_labels(text: str, *, cash_only_execution: bool) -> 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)", - ) - value = value.replace("购买力", "可用现金") - value = value.replace("Buying power", "Available cash") - 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)", + """Delegates to shared renderer_base; IBKR uses hardcoded labels (no translator).""" + return _relabel_dashboard_cash_labels_shared( + text, cash_only_execution=cash_only_execution, translator=None, ) - value = value.replace("可用现金", "购买力") - value = value.replace("Available cash", "Buying power") - return value def _format_compact_dashboard_text(text) -> str: lines = [] for line in _format_dashboard_text(text).splitlines(): - stripped = line.strip() - lowered = stripped.lower() - if stripped.startswith(("⏱", "🧾", "🛡️", "📊", "🎯")): - continue - if lowered.startswith(("signal:", "signal:", "market status:")): - continue - if stripped.startswith(("信号:", "信号:", "市场状态:", "市场状态:")): + if not line.strip() or is_compact_dashboard_audit_line(line): continue lines.append(line) return "\n".join(lines) def _build_timing_audit_lines(signal_metadata, *, translator) -> list[str]: - metadata = signal_metadata if isinstance(signal_metadata, Mapping) else {} - raw_annotations = metadata.get("execution_annotations") - annotations = raw_annotations if isinstance(raw_annotations, Mapping) else {} - signal_date = str(annotations.get("signal_date") or metadata.get("signal_date") or "").strip() - effective_date = str(annotations.get("effective_date") or metadata.get("effective_date") or "").strip() - contract = str( - annotations.get("execution_timing_contract") - or metadata.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}"] - - -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 _is_truthy(value) -> bool: - if isinstance(value, bool): - return value - return str(value or "").strip().lower() in {"1", "true", "yes", "y"} - - -def _effective_volatility_delever_threshold(signal_metadata, *, prefix: str): - mode = str(signal_metadata.get(f"{prefix}_threshold_mode") or "").strip().lower() - dynamic_threshold = signal_metadata.get(f"{prefix}_dynamic_threshold") - if mode == "rolling_percentile" and _present(dynamic_threshold): - return dynamic_threshold - return signal_metadata.get(f"{prefix}_threshold") - - -def _format_volatility_delever_threshold_detail(signal_metadata, *, prefix: str, translator) -> str: - mode = str(signal_metadata.get(f"{prefix}_threshold_mode") or "").strip().lower() - fixed_threshold = signal_metadata.get(f"{prefix}_threshold") - dynamic_threshold = signal_metadata.get(f"{prefix}_dynamic_threshold") - if mode == "rolling_percentile": - kwargs = { - "percentile": _format_percentile(signal_metadata.get(f"{prefix}_dynamic_percentile")), - "lookback": _format_sample_count(signal_metadata.get(f"{prefix}_dynamic_lookback")), - "min_periods": _format_sample_count(signal_metadata.get(f"{prefix}_dynamic_min_periods")), - "sample_count": _format_sample_count(signal_metadata.get(f"{prefix}_dynamic_sample_count")), - "floor": _format_percent(signal_metadata.get(f"{prefix}_dynamic_floor")), - "cap": _format_percent(signal_metadata.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( - signal_metadata, - *, - prefix: str, - redirect_symbol: str, - translator, -) -> str: - retained_ratio = _as_float_or_none(signal_metadata.get(f"{prefix}_retained_ratio")) - redirected_ratio = _as_float_or_none(signal_metadata.get(f"{prefix}_redirected_ratio")) - if retained_ratio is None: - retained_ratio = _as_float_or_none(signal_metadata.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", + execution = resolve_execution( + signal_metadata if isinstance(signal_metadata, Mapping) else {}, ) + return _build_timing_audit_lines_shared(execution, translator=translator) def _build_tqqq_risk_control_lines(signal_metadata, *, translator) -> list[str]: - prefix = "dual_drive_volatility_delever" - if not _is_truthy(signal_metadata.get(f"{prefix}_applied")): - return [] - redirect_symbol = str(signal_metadata.get(f"{prefix}_redirect_symbol") or "QQQ").strip().upper() - window = str(signal_metadata.get(f"{prefix}_window") or "5").strip() - threshold = _effective_volatility_delever_threshold(signal_metadata, prefix=prefix) - threshold_detail = _format_volatility_delever_threshold_detail( - signal_metadata, - prefix=prefix, - translator=translator, - ) - allocation_detail = _format_tqqq_volatility_delever_allocation_detail( - signal_metadata, - prefix=prefix, - redirect_symbol=redirect_symbol or "QQQ", + return _build_tqqq_risk_control_lines_shared( + signal_metadata if isinstance(signal_metadata, Mapping) else {}, translator=translator, ) - if str(signal_metadata.get(f"{prefix}_trigger_reason") or "").strip() == "hysteresis_hold": - return [ - translator( - "risk_control_tqqq_volatility_delever_hysteresis_dynamic", - window=window, - volatility=_format_percent(signal_metadata.get(f"{prefix}_metric")), - exit_threshold=_format_percent(signal_metadata.get(f"{prefix}_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(signal_metadata.get(f"{prefix}_metric")), - threshold=_format_percent(threshold), - threshold_detail=threshold_detail, - source_symbol="TQQQ", - redirect_symbol=redirect_symbol or "QQQ", - allocation_detail=allocation_detail, - ) - ] 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 _strategy_dashboard_text(signal_metadata, *, translator) -> str: From cb1c7118f562cedcd083764721e65712543da0f1 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:17:13 +0800 Subject: [PATCH 4/6] chore: bump QPK pin to 8378e93 for notification renderer_base Co-Authored-By: Claude --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a7338a1..046dbf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "google-cloud-secret-manager", "google-cloud-storage", "yfinance", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@0af622ac9d47f7ef93f9379f9ded314c27a344ff", + "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", ] From 2efb862f4357af997569c2686f11a9ad5dc90555 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:17:32 +0800 Subject: [PATCH 5/6] fix(execution): universal whole-share retention rule for small accounts Replace the per-symbol hardcoded whitelist with a universal rule: if the account already holds a symbol (>0 shares) AND the strategy wants to keep >= 85% of 1 share's value, retain the position. Co-Authored-By: Claude --- application/execution_service.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/application/execution_service.py b/application/execution_service.py index 84d663f..63b99e4 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -1128,6 +1128,7 @@ def _limit_buy_price(symbol, price, default_premium, premium_by_symbol=None) -> DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0 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, } @@ -1175,20 +1176,40 @@ def _apply_safe_haven_cash_substitution_to_weights( return adjusted, tuple(dict.fromkeys(substituted)) -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: @@ -1484,7 +1505,7 @@ def record_quote_snapshot(symbol, snapshot) -> None: _can_afford_one_share = limit_price > 0.0 and investable >= limit_price held_quantity = max(0.0, float(positions.get(symbol, {}).get("quantity", 0.0) or 0.0)) if ( - _should_retain_existing_whole_share(symbol, target_value=target_value, price=price) + _should_retain_existing_whole_share(symbol, target_value=target_value, price=price, quantity=held_quantity) and price > 0.0 and 0.0 < target_value < price and held_quantity >= 1.0 From ebdacebd22e1c3e758f73bb69db21e7253862bbb Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 9 Jul 2026 05:30:52 +0800 Subject: [PATCH 6/6] fix: restore QPK pin to 8378e93 after merge conflict resolution Co-Authored-By: Claude --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a7338a1..046dbf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "google-cloud-secret-manager", "google-cloud-storage", "yfinance", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@0af622ac9d47f7ef93f9379f9ded314c27a344ff", + "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", ]