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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
resolve_strategy_run_period,
)
from decision_mapper import map_strategy_decision_to_plan
from notifications.telegram import build_sender, build_translator, render_cycle_summary
from notifications.telegram import build_sender, build_translator, render_cycle_notification
from quant_platform_kit.common.execution_outcomes import (
DEFAULT_EXECUTION_BLOCKING_SKIP_REASONS,
filter_execution_blocking_skips,
Expand All @@ -57,7 +57,7 @@
load_configured_strategy_plugin_signals,
parse_strategy_plugin_mounts,
)
from quant_platform_kit.notifications.events import NotificationPublisher, RenderedNotification
from quant_platform_kit.notifications.events import NotificationPublisher
from quant_platform_kit.notifications.strategy_plugin_alerts import (
StrategyPluginAlertStateSettings,
build_strategy_plugin_alert_context_label as build_alert_context_label,
Expand Down Expand Up @@ -238,7 +238,7 @@ def _publish_cycle_notification(
if not settings.tg_token or not settings.tg_chat_id:
return False
sender = build_sender(settings.tg_token, settings.tg_chat_id)
message = render_cycle_summary(result, lang=settings.notify_lang)
notification = render_cycle_notification(result, lang=settings.notify_lang)
def publish_log(text: str) -> None:
try:
log_message(text, flush=True)
Expand All @@ -256,7 +256,7 @@ def send_and_capture(text: str) -> bool | None:
NotificationPublisher(
log_message=publish_log,
send_message=send_and_capture,
).publish(RenderedNotification(detailed_text=message, compact_text=message))
).publish(notification)
return delivery_sent


Expand Down
87 changes: 87 additions & 0 deletions notifications/compact_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Adapter for compact, user-facing notification sections."""

from __future__ import annotations

import re
from collections.abc import Iterable

_HOLDINGS_HEADERS = {
"💼 持仓",
"💼 策略持仓",
"💼 当前持仓",
"💼 Holdings",
"💼 Current Holdings",
"💼 Strategy Holdings",
"💼 Strategy holdings",
}
_NUMBER_RE = re.compile(r"[-+]?\d[\d,]*(?:\.\d+)?")


def _contains_nonzero_number(text: str) -> bool:
for match in _NUMBER_RE.finditer(text):
try:
if abs(float(match.group(0).replace(",", ""))) > 1e-12:
return True
except ValueError:
continue
return False


def _localize_holding_detail(detail: str, *, locale: str) -> str:
if str(locale).lower().startswith("zh"):
return re.sub(r"\s+shares?\b", "股", detail, flags=re.IGNORECASE)

def replace_share(match: re.Match[str]) -> str:
quantity = match.group(1)
try:
unit = "share" if abs(float(quantity.replace(",", ""))) == 1 else "shares"
except ValueError:
unit = "shares"
return f"{quantity} {unit}"

return re.sub(r"([-+]?\d[\d,]*(?:\.\d+)?)\s*股", replace_share, detail)


def adapt_compact_sections(
dashboard_text: str,
*,
locale: str,
supplemental_lines: Iterable[object] = (),
) -> tuple[str, ...]:
"""Return normalized non-zero holdings followed by explicit supplements.

Holdings are read from the already-rendered same-cycle dashboard so each
platform keeps ownership of broker-specific valuation and quantity rules.
Supplemental lines must already be localized by the platform translator.
"""
holdings: list[str] = []
in_holdings = False
for raw_line in str(dashboard_text or "").splitlines():
line = raw_line.strip()
if line in _HOLDINGS_HEADERS:
in_holdings = True
continue
if not in_holdings or not line:
continue
if line.startswith("━") or line.startswith(("📌", "💵", "📊", "🎯", "🧾", "⏱", "🧩")):
break
normalized = line.lstrip("-• ").strip()
if ":" not in normalized and ":" not in normalized:
continue
separator = ":" if ":" in normalized else ":"
symbol, detail = (part.strip() for part in normalized.split(separator, 1))
if not symbol or not detail or not _contains_nonzero_number(detail):
continue
detail = _localize_holding_detail(detail, locale=locale)
holdings.append(f"- {symbol}: {detail}")

lines: list[str] = []
if holdings:
lines.append("💼 持仓" if str(locale).lower().startswith("zh") else "💼 Holdings")
lines.extend(holdings)

for raw_line in supplemental_lines:
line = str(raw_line or "").strip()
if line and line not in lines:
lines.append(line)
return tuple(lines)
124 changes: 124 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
localize_price_source_label as _localize_price_source_label,
present as _present,
)
from notifications.compact_adapter import adapt_compact_sections
from quant_platform_kit.notifications.events import RenderedNotification

try:
from quant_platform_kit.common.notification_localization import (
Expand Down Expand Up @@ -1162,3 +1164,125 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
else:
lines.append(translator("no_rebalance_needed"))
return "\n".join(str(line) for line in lines if str(line).strip())


def _compact_cycle_total_assets_line(
result: Mapping[str, Any],
*,
translator: Callable[..., str],
has_submitted_orders: bool,
) -> str:
if not has_submitted_orders:
snapshot = result.get("heartbeat_account_snapshot")
snapshot = snapshot if isinstance(snapshot, Mapping) else {}
observed_at = str(snapshot.get("observed_at") or "").strip()
amount = snapshot.get("net_assets")
if (
isinstance(amount, (int, float))
and not isinstance(amount, bool)
and math.isfinite(amount)
and amount > 0
and observed_at
):
return translator("heartbeat_account_equity", value=f"USD {amount:,.2f}")

portfolio = result.get("portfolio")
portfolio = portfolio if isinstance(portfolio, Mapping) else {}
total_equity = _safe_float(portfolio.get("total_equity"))
if total_equity is None:
return ""
execution = result.get("execution")
execution = execution if isinstance(execution, Mapping) else {}
cash_only = bool(execution.get("cash_only_execution", portfolio.get("cash_only_execution", True)))
label = translator("total_assets" if cash_only else "total_assets_margin")
return f"💰 {label}: {_format_money(total_equity)}"


def _compact_strategy_name(result: Mapping[str, Any], *, lang: str, translator) -> str:
strategy_profile = str(result.get("strategy_profile") or "").strip()
strategy_name = str(result.get("strategy_display_name") or strategy_profile).strip()
strategy_metadata = result.get("strategy_metadata")
if strategy_metadata is not None:
from quant_platform_kit.common.notification_localization import resolve_strategy_display_name

return resolve_strategy_display_name(lang, strategy_metadata, translator=translator)
translated = translator(f"strategy_name_{strategy_profile}") if strategy_profile else ""
if translated and translated != f"strategy_name_{strategy_profile}":
return translated
return strategy_name


def render_cycle_notification(result: Mapping[str, Any], *, lang: str = "en") -> RenderedNotification:
"""Render full technical detail for logs and a concise user-facing message."""
translator = build_translator(lang)
submitted = list(result.get("submitted_orders") or ())
skipped = list(result.get("skipped_orders") or ())
allocation = dict(result.get("allocation") or {})
portfolio = dict(result.get("portfolio") or {})
execution = dict(result.get("execution") or {})
target_diff_lines = _format_target_diff_lines(allocation, portfolio, translator=translator)
meaningful_skipped = [
item for item in skipped if str(item.get("reason") or "") != "below_trade_threshold"
]
has_rebalance_attempt = bool(submitted or target_diff_lines or meaningful_skipped)

lines = [translator("rebalance_title" if has_rebalance_attempt else "heartbeat_title")]
strategy_name = _compact_strategy_name(result, lang=lang, translator=translator)
if strategy_name:
lines.append(translator("strategy_label", name=strategy_name))
total_assets_line = _compact_cycle_total_assets_line(
result,
translator=translator,
has_submitted_orders=bool(submitted),
)
if total_assets_line:
lines.append(total_assets_line)
if bool(result.get("dry_run_only")):
lines.append(translator("dry_run_banner"))
compact_dashboard = "\n".join(
_format_dashboard_lines(portfolio, execution, translator=translator)
)
lines.extend(
adapt_compact_sections(
compact_dashboard,
locale=lang,
supplemental_lines=result.get("compact_supplemental_lines", ()),
)
)

if bool(result.get("execution_blocked")):
blocked = list(result.get("execution_blocking_skips") or skipped)
reason = _format_skipped_reason(blocked, translator=translator)
if bool(result.get("funding_blocked")):
banner_key = "funding_blocked_banner"
elif bool(result.get("execution_block_retryable")):
banner_key = "execution_blocked_retryable_banner"
else:
banner_key = "execution_blocked_banner"
lines.append(translator(banner_key, reason=reason))

if submitted:
lines.extend(
_format_order_lines(
submitted,
dry_run_only=bool(result.get("dry_run_only")),
translator=translator,
)
)
hard_skips = [
item
for item in meaningful_skipped
if str(item.get("reason") or "")
not in {"buy_quantity_zero", "sell_quantity_zero", "quantity_zero", "min_notional"}
]
if hard_skips:
lines.append(translator("no_order_submitted", reason=_format_skipped_reason(hard_skips, translator=translator)))
elif skipped and has_rebalance_attempt:
lines.append(translator("no_order_submitted", reason=_format_skipped_reason(skipped, translator=translator)))
else:
lines.append(translator("no_rebalance_needed"))

return RenderedNotification(
detailed_text=render_cycle_summary(result, lang=lang),
compact_text="\n".join(str(line) for line in lines if str(line).strip()),
)
Loading
Loading