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
30 changes: 22 additions & 8 deletions docs/unified-daily-notification.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,25 @@ links come from the existing run report; absent evidence stays explicitly
unknown. This format change does not change execution, dependency pins or
notification schedules and does not establish deployed adoption or delivery.

Incident attention messages retain allowlisted completed block causes from
`execution_blocked_reason` or `no_op_reason`; unknown provider text retains the
fixed incident classification. The pinned shared compact renderer receives the
report's existing `started_at` and `console_run_url`, just like newer renderers.
It validates the supplied HTTPS link and removes query strings/fragments. A
missing or unsafe source link remains unavailable; a monitor run or guessed
console route is never substituted for a business run. These display fields do
not change routing, incident identity, risk decisions or execution permission.
Incident attention messages retain structured reason codes from
`execution_blocked_reason` or `no_op_reason`, with local operator explanations
for known block classes. Unknown structured codes remain visible after shared
redaction and direct the operator to risk records; arbitrary provider prose
retains the fixed incident classification. A blocked cycle is displayed as a
protection block, while other abnormal classifications retain operational
uncertainty. Snapshot diagnostics do not authorize execution or clear a block.

Account display uses the candidate producer's target-bound opaque identity,
masked by the shared renderer. Missing or invalid identity names the required
`DIGEST_OPAQUE_ACCOUNT_UID` / `IBKR_DIGEST_OPAQUE_ACCOUNT_UID` and/or
`DIGEST_TARGET_ID` / `IBKR_DIGEST_TARGET_ID` fields. Service/project names and
identities bound to a different runtime target are never substituted.

The shared renderer receives the report's existing `started_at` and the first
valid explicit `console_run_url`, `actions_run_url`, `cloud_run_url` or
`strategy_cycle_url` from the report or execution evidence. Shared validation
requires HTTPS and removes query strings/fragments. An absent or unsafe source
link is displayed as unavailable (`RUN_LINK_MISSING`); an unrelated Actions run
or guessed console route is never substituted for a business run. These display
fields do not change routing, incident identity, risk decisions or execution
permission, and do not establish deployed adoption.
69 changes: 58 additions & 11 deletions notifications/unified_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,25 +479,72 @@ def _cycle_attention_notification(kind, execution, report, diagnostics=""):
os.environ.get("QSL_NOTIFY_LANG") or os.environ.get("NOTIFY_LANG"))
profile = report.get("strategy_profile") or (report.get("runtime_target") or {}).get("strategy_profile")
profile = profile if isinstance(profile, str) and profile in STRATEGY_CATALOG.definitions else "unknown"
# Only fixed producer codes may cross the private execution boundary.
known_blocks = {"empty_position_decision", "strategy_risk_rejected",
"account_brake_source_or_submit_unavailable", "account_brake_submission_not_confirmed"}
reason = next((value for key in ("execution_blocked_reason", "no_op_reason")
if isinstance(value := execution.get(key), str) and value in known_blocks), kind)
# Structured codes only; arbitrary provider prose stays inside diagnostics.
reason = kind
for key in ("execution_blocked_reason", "no_op_reason"):
value = execution.get(key)
if isinstance(value, str) and re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(?::[a-zA-Z0-9_]+)?", value) and len(value) <= 160:
reason = redact_notification_diagnostics(value)
break
blocked = kind == "blocked_run"
decision = shared.AttentionDecision(
level=shared.AttentionLevel.ACTION, reason_codes=("operational_uncertain",),
level=shared.AttentionLevel.ACTION,
reason_codes=("new_risk_prohibited" if blocked else "operational_uncertain",),
dd_ratio=None, should_notify=True, next_step_key="open_console_confirm")
arguments = dict(locale=locale, platform="ibkr", account_alias="unknown",
# Use the same target-bound opaque identity as the candidate producer.
# Never substitute a service/project name or another target's environment.
try:
identity, _ = with_run(report, _identity, report.get("runtime_target"))
except (ValueError, TypeError, AttributeError):
identity = {}
missing = [field for field in ("opaque_account_uid", "target_id")
if not isinstance(identity.get(field), str) or identity[field].strip().lower() in {"", "unknown", "none", "account", "账户"}]
alias = "unknown" if missing else identity["opaque_account_uid"]
arguments = dict(locale=locale, platform="ibkr", account_alias=alias,
strategy_profile=profile, decision=decision, guard_reason_codes=(reason,))
# The pinned compact renderer already supports these evidence fields and
# validates/strips URLs. Keep that shared boundary instead of dropping the
# link and appending a second run-time section in the compatibility path.
from quant_platform_kit.risk.attention_display import attention_details
run_url = None
for source in (report, execution):
for field in ("console_run_url", "actions_run_url", "cloud_run_url", "strategy_cycle_url"):
value = source.get(field)
if isinstance(value, str):
detail = attention_details(locale="en", daily_loss_context=None, occurred_at=None, run_url=value)[-1]
if detail.startswith("Run details: https://"):
run_url = value
break
if run_url:
break
renderer = getattr(shared, "render_attention_notification", shared.render_attention_compact)
text = renderer(**arguments, occurred_at=report.get("started_at"), run_url=report.get("console_run_url"))
text = renderer(**arguments, occurred_at=report.get("started_at"), run_url=run_url)
# Extend the pinned renderer's vocabulary locally; do not change its guards.
from quant_platform_kit.common.operational_notification_localization import operational_notification_text
explanations = {
"blocked_run": ("保护规则阻断本次运行,请查看风控记录", "A protection rule blocked this run; review risk records"),
"empty_position_decision": ("空持仓决策受到保护规则限制,请查看风控记录", "Empty-position decision blocked by protection rules; review risk records"),
"strategy_risk_rejected": ("策略风控拒绝本次操作,请查看风控记录", "Strategy risk checks rejected this action; review risk records"),
"rejected": ("策略风控拒绝本次操作,请查看风控记录", "Strategy risk checks rejected this action; review risk records"),
"account_brake_source_or_submit_unavailable": ("账户保护数据或提交服务不可用,请查看风控记录", "Account protection source or submission service unavailable; review risk records"),
"account_brake_submission_not_confirmed": ("账户保护提交未确认,请查看风控记录", "Account protection submission unconfirmed; review risk records"),
}
explanation = explanations.get(reason.partition(":")[0])
if explanation:
text = text.replace(f"{reason}: " + operational_notification_text(locale, "attention_reason_unknown"),
f"{reason}: " + explanation[0 if locale == "zh" else 1])
text = text.replace(operational_notification_text(locale, "attention_reason_unknown"),
"未知原因码,请查看风控记录" if locale == "zh" else "Unknown reason code; review risk records")
if missing:
names = {"opaque_account_uid": "DIGEST_OPAQUE_ACCOUNT_UID/IBKR_DIGEST_OPAQUE_ACCOUNT_UID", "target_id": "DIGEST_TARGET_ID/IBKR_DIGEST_TARGET_ID"}
label = "账户未绑定(缺少或无效:" if locale == "zh" else "Account unbound (missing or invalid: "
label += ", ".join(names[field] for field in missing) + (")" if locale == "zh" else ")")
text = text.replace("账户未知" if locale == "zh" else "Account unknown", label, 1)
text = text.replace("未提供运行链接", "运行详情不可用(RUN_LINK_MISSING)").replace(
"Run link not provided", "Run details unavailable (RUN_LINK_MISSING)")
# Carry only the caller's decision diagnostics, never its legacy dashboard/title.
if diagnostics:
label = "诊断" if locale == "zh" else "Diagnostics"
text += f"\n{label}: {redact_notification_diagnostics(diagnostics)}"
text += ("\n快照决策不代表执行获准;本次运行仍需核对阻断或故障记录。" if locale == "zh" else
"\nA snapshot decision does not authorize execution; review this run's block or fault records.")
text = redact_sensitive_text(text)
return RenderedNotification(text, text)

Expand Down
2 changes: 1 addition & 1 deletion tests/test_attention_source_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def test_blocked_attention_preserves_known_source_cause(monkeypatch, field, code
assert execution == original


@pytest.mark.parametrize("value", ["private account detail", "synthetic_private_code", None, [], {}])
@pytest.mark.parametrize("value", ["private account detail", "private code with spaces", None, [], {}])
def test_unrecognized_source_reason_retains_fixed_fallback(monkeypatch, value):
monkeypatch.setenv("NOTIFY_LANG", "en")
body = adapter._cycle_attention_notification("blocked_run", {"no_op_reason": value}, {}).detailed_text
Expand Down
99 changes: 99 additions & 0 deletions tests/test_daily_etf_attention.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Source-only presentation regressions; no notification delivery or broker calls."""
from copy import deepcopy

import pytest

from notifications import unified_adapter as adapter


@pytest.fixture(autouse=True)
def isolated_identity(monkeypatch):
monkeypatch.setenv("QSL_NOTIFY_LANG", "zh")
for key in ("DIGEST_OPAQUE_ACCOUNT_UID", "IBKR_DIGEST_OPAQUE_ACCOUNT_UID", "DIGEST_TARGET_ID", "IBKR_DIGEST_TARGET_ID", "RUNTIME_TARGET_JSON", "DIGEST_TARGET_BINDINGS_JSON"):
monkeypatch.delenv(key, raising=False)


def render(execution=None, report=None, diagnostics=""):
return adapter._cycle_attention_notification("blocked_run", execution or {}, report or {}, diagnostics).detailed_text


@pytest.mark.parametrize("code,description", [
("blocked_run", "保护规则阻断"),
("empty_position_decision", "空持仓决策"),
("strategy_risk_rejected", "策略风控拒绝"),
("account_brake_source_or_submit_unavailable", "账户保护数据或提交服务不可用"),
("account_brake_submission_not_confirmed", "账户保护提交未确认"),
("rejected:too_many_positions", "策略风控拒绝"),
])
def test_known_block_explanation(code, description):
body = render({"execution_blocked_reason": code})
assert code in body and description in body
assert "无法解释此原因码" not in body


def test_unknown_structured_code_remains_visible():
body = render({"execution_blocked_reason": "fake_unknown_guard"})
assert "fake_unknown_guard" in body and "查看风控记录" in body


def test_authoritative_bound_identity(monkeypatch):
monkeypatch.setenv("DIGEST_OPAQUE_ACCOUNT_UID", "fake-account-42")
monkeypatch.setenv("DIGEST_TARGET_ID", "fake-target")
body = render(report={"strategy_profile": "global_etf_rotation"})
assert "••42" in body and "账户未知" not in body
assert "fake-account-42" not in body


def test_missing_identity_names_field():
body = render()
assert "账户未绑定" in body and "DIGEST_OPAQUE_ACCOUNT_UID" in body


def test_continue_snapshot_does_not_override_block():
execution = {"execution_status": "blocked", "snapshot_guard_decision": "proceed", "execution_blocked_reason": "empty_position_decision", "positions": []}
original = deepcopy(execution)
body = render(execution, diagnostics="无需调仓 · 快照决策=继续")
assert "operational_uncertain" not in body
assert "快照决策不代表执行获准" in body
assert adapter._kind(execution) == "blocked_run" and execution == original


@pytest.mark.parametrize("field", ["console_run_url", "actions_run_url", "cloud_run_url", "strategy_cycle_url"])
def test_explicit_source_run_link(field):
body = render(report={field: "https://runs.example.invalid/fake-cycle?token=fake-secret#private"})
assert "https://runs.example.invalid/fake-cycle" in body
assert "fake-secret" not in body and "#private" not in body


@pytest.mark.parametrize("url", [None, "javascript:fake", "https://user:pass@runs.example.invalid/fake"])
def test_no_link_is_explicitly_unavailable(url):
body = render(report={"console_run_url": url, "run_id": "fake-run"})
assert "运行详情不可用" in body
assert "https://" not in body


def test_unknown_code_and_diagnostics_redacted():
body = render({"execution_blocked_reason": "rejected:U00000001"}, diagnostics="account_id=U00000001 token=fake-secret")
assert "U00000001" not in body and "fake-secret" not in body
assert "rejected:" in body


@pytest.mark.parametrize("invalid", ["unknown", "none", " "])
def test_placeholder_identity_stays_unbound(monkeypatch, invalid):
monkeypatch.setenv("DIGEST_OPAQUE_ACCOUNT_UID", invalid)
monkeypatch.setenv("DIGEST_TARGET_ID", "fake-target")
assert "DIGEST_OPAQUE_ACCOUNT_UID" in render()


def test_other_targets_identity_is_never_displayed(monkeypatch):
import json
monkeypatch.setenv("DIGEST_OPAQUE_ACCOUNT_UID", "fake-other-99")
monkeypatch.setenv("DIGEST_TARGET_ID", "fake-other-target")
monkeypatch.setenv("RUNTIME_TARGET_JSON", json.dumps({"service_name": "fake-other", "strategy_profile": "global_etf_rotation", "account_scope": "fake-other"}))
body = render(report={"runtime_target": {"service_name": "fake-current", "strategy_profile": "global_etf_rotation", "account_scope": "fake-current"}})
assert "账户未绑定" in body and "••99" not in body


def test_invalid_first_link_uses_later_explicit_evidence():
body = render(report={"console_run_url": "javascript:fake", "actions_run_url": "https://runs.example.invalid/fake"})
assert "https://runs.example.invalid/fake" in body and "RUN_LINK_MISSING" not in body
Loading