Close P12 alert silent window loop
This commit is contained in:
+22
-12
@@ -2,7 +2,7 @@
|
||||
|
||||
## Current Goal
|
||||
|
||||
Automation execution-layer P11 is closed locally. P11 adds runtime stuck-task detection as an auditable operations hardening slice so stalled runtime tasks can be surfaced through status, API, script, and workbench views while keeping the system analysis/restricted decision-support only.
|
||||
Automation execution-layer P12 is closed locally. P12 adds alert silent-window noise control as an auditable operations hardening slice so outbound alert suppression can be enforced and surfaced through dispatcher results, summary APIs, scripts, runtime details, and workbench views while keeping the system analysis/restricted decision-support only.
|
||||
|
||||
## Current Branch
|
||||
|
||||
@@ -14,15 +14,15 @@ Automation execution-layer P11 is closed locally. P11 adds runtime stuck-task de
|
||||
|
||||
## Automation Status
|
||||
|
||||
- state: automation_execution_layer_p11_runtime_task_watchdog_closed
|
||||
- current_phase: P11 Runtime Task Watchdog
|
||||
- state: automation_execution_layer_p12_alert_silent_window_noise_control_closed
|
||||
- current_phase: P12 Alert Silent Window Noise Control
|
||||
- latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup
|
||||
- latest_p11_slice: runtime_task_watchdog
|
||||
- latest_p12_slice: alert_silent_window_noise_control
|
||||
- latest_visual_review_script_status: idle
|
||||
- latest_runtime_visual_watch_task_status: ok
|
||||
- latest_visual_capture_cleanup_task_status: ok
|
||||
- latest_runtime_watchdog_task_status: implemented
|
||||
- latest_runtime_visual_watch_state: idle
|
||||
- latest_runtime_watchdog_task_status: implemented
|
||||
- latest_runtime_visual_watch_state: idle
|
||||
- latest_visual_snapshot_capture_status: implemented
|
||||
- latest_visual_reviews_panel_status: visible
|
||||
- latest_visual_feedback_status: implemented
|
||||
@@ -34,6 +34,8 @@ Automation execution-layer P11 is closed locally. P11 adds runtime stuck-task de
|
||||
- latest_visual_capture_cleanup_route_status: ok
|
||||
- latest_runtime_watchdog_status: implemented
|
||||
- latest_runtime_watchdog_route_status: ok
|
||||
- latest_alert_silent_window_status: implemented
|
||||
- latest_alert_silent_window_route_status: ok
|
||||
- latest_runtime_blockers: none
|
||||
- guards_unchanged: execution_allowed, real_live_ready, risk_governor, trust_bucket, operator_confirmation_protocol
|
||||
|
||||
@@ -60,6 +62,9 @@ Automation execution-layer P11 is closed locally. P11 adds runtime stuck-task de
|
||||
- Added `RuntimeTaskWatchdogService` to detect runtime tasks that are overdue or running too long.
|
||||
- Added `scripts/run_runtime_task_watchdog.py` and `GET /trading/runtime-watchdog`.
|
||||
- Extended `runtime_status` and the runtime workbench panel with `runtime_watchdog`, stalled task names, and per-task stall details.
|
||||
- Added `AlertSilentWindowService` for configurable quiet-hour suppression of outbound non-workbench alerts.
|
||||
- Added `scripts/run_alert_silent_window_status.py` and `GET /trading/alerts/silent-window-status`.
|
||||
- Extended alert dispatch, alert quality summary, runtime `alert_dispatch` task details, and the `Alert Quality` panel with silent-window visibility and suppression counts.
|
||||
- Added focused unit tests for the service, script, API routing, and runtime task.
|
||||
|
||||
## Current Working State
|
||||
@@ -68,17 +73,20 @@ Automation execution-layer P11 is closed locally. P11 adds runtime stuck-task de
|
||||
- P8 review/capture/feedback surfaces remain implemented and unchanged in their execution guards.
|
||||
- P9 quality rollup remains implemented and visible in both the dashboard and review packet.
|
||||
- P10 retention cleanup remains implemented and visible in runtime plus workbench flows.
|
||||
- P11 runtime watchdog is implemented: `runtime_status.runtime_watchdog` now reports `checked_ts`, stalled task names, warning codes, and detailed stall reasons.
|
||||
- P11 runtime panel visibility is implemented: the workbench runtime panel now shows stalled task count and per-task stalled/threshold detail.
|
||||
- P11 script/API visibility is implemented: `scripts/run_runtime_task_watchdog.py` and `/trading/runtime-watchdog` both expose the watchdog summary directly.
|
||||
- P11 runtime watchdog remains implemented and visible in runtime status, API, script, and workbench flows.
|
||||
- P12 silent-window noise control is implemented: `AlertDispatcherService` can now suppress outbound webhook-style delivery during configured quiet hours with `reason=alert_silent_window_active`.
|
||||
- P12 quality visibility is implemented: `alert_quality_summary.silent_window`, `delivery_summary.silent_window_skip_count`, and `quality_flags` now expose the current quiet-window state.
|
||||
- P12 runtime visibility is implemented: `runtime_status.tasks.alert_dispatch.details` now includes `silent_window_active`, `silent_window_label`, `silent_window_next_allowed_ts`, and `suppressed_silent_window`.
|
||||
- P12 script/API visibility is implemented: `scripts/run_alert_silent_window_status.py`, `/trading/alerts/silent-window-status`, and the `Alert Quality` panel now surface quiet-window state directly.
|
||||
- Latest `scripts/run_visual_review.py` smoke found no pending reviews: attempted=0, reviewed=0, status=idle.
|
||||
- Latest capture smoke returned `status=ignored` when no watch run was supplied, which is the expected safe no-op.
|
||||
- Latest cleanup smoke deleted one expired temporary artifact through `scripts/run_visual_capture_cleanup.py`.
|
||||
- Latest watchdog smoke returned `stalled_task_count=0` through `scripts/run_runtime_task_watchdog.py`.
|
||||
- Latest silent-window smoke returned `active=true` through `scripts/run_alert_silent_window_status.py` and exposed the same state through `/trading/alerts/silent-window-status`.
|
||||
- Latest `scripts/run_trading_runtime.py` smoke returned blockers=none and includes a healthy `visual_watch` runtime task.
|
||||
- Latest local API smoke confirmed `/trading/runtime-watchdog`, `/trading/runtime-status`, and dashboard JS all expose the P11 watchdog path successfully.
|
||||
- Latest local API smoke confirmed `/trading/alerts/silent-window-status`, `/trading/alerts/quality-summary`, and dashboard JS all expose the P12 silent-window path successfully.
|
||||
|
||||
## P11 Blockers
|
||||
## P12 Blockers
|
||||
|
||||
- none
|
||||
|
||||
@@ -89,11 +97,12 @@ Automation execution-layer P11 is closed locally. P11 adds runtime stuck-task de
|
||||
- Alert-quality statistics remain advisory and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation.
|
||||
- Capture cleanup remains operational only and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation.
|
||||
- Runtime watchdog remains operational only and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation.
|
||||
- Silent-window suppression remains operational only and must not change execution permission, live readiness, trust bucket, risk governor, or operator confirmation.
|
||||
- Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter.
|
||||
|
||||
## Exact Next Step
|
||||
|
||||
P11 is locally closed. Default next action is to continue periodic analysis-decision monitoring with maturity rollup and, when opening the next bounded product slice, move to the next P12-style automation-operations hardening candidate such as silent windows / noise control.
|
||||
P12 is locally closed. Default next action is to continue periodic analysis-decision monitoring with maturity rollup and, when opening the next bounded product slice, move to the next P13-style automation-operations hardening candidate such as broader noise-control policy refinement or other unattended-operations guardrails.
|
||||
|
||||
## Validation Commands Used Recently
|
||||
|
||||
@@ -105,6 +114,7 @@ D:\ai-exchange\.venv\Scripts\python.exe scripts\run_visual_review.py
|
||||
D:\ai-exchange\.venv\Scripts\python.exe scripts\run_visual_snapshot_capture.py
|
||||
D:\ai-exchange\.venv\Scripts\python.exe scripts\run_visual_capture_cleanup.py
|
||||
D:\ai-exchange\.venv\Scripts\python.exe scripts\run_runtime_task_watchdog.py
|
||||
D:\ai-exchange\.venv\Scripts\python.exe scripts\run_alert_silent_window_status.py
|
||||
D:\ai-exchange\.venv\Scripts\python.exe scripts\run_trading_runtime.py
|
||||
D:\ai-exchange\.venv\Scripts\python.exe -m unittest tests.unit.test_visual_snapshot_capture_service tests.unit.test_run_visual_snapshot_capture tests.unit.test_api_static_server
|
||||
```
|
||||
|
||||
@@ -20,16 +20,17 @@
|
||||
- [x] P9 local closure complete
|
||||
- [x] P10 local closure complete
|
||||
- [x] P11 local closure complete
|
||||
- [x] P12 local closure complete
|
||||
- [ ] Continue periodic analysis-decision monitoring with maturity rollup in parallel
|
||||
- [ ] Open the next bounded P12-style automation-operations hardening slice
|
||||
- [ ] Open the next bounded P13-style automation-operations hardening slice
|
||||
- [ ] Treat browser screenshot capture and additional external media adapters as future enhancements
|
||||
|
||||
## Automation Status
|
||||
|
||||
- [x] state: automation_execution_layer_p11_runtime_task_watchdog_closed
|
||||
- [x] current_phase: P11 Runtime Task Watchdog
|
||||
- [x] state: automation_execution_layer_p12_alert_silent_window_noise_control_closed
|
||||
- [x] current_phase: P12 Alert Silent Window Noise Control
|
||||
- [x] latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup
|
||||
- [x] latest_p11_slice: runtime_task_watchdog
|
||||
- [x] latest_p12_slice: alert_silent_window_noise_control
|
||||
- [x] latest_visual_review_script_status: idle
|
||||
- [x] latest_runtime_visual_watch_task_status: ok
|
||||
- [x] latest_visual_capture_cleanup_task_status: ok
|
||||
@@ -45,6 +46,8 @@
|
||||
- [x] latest_visual_capture_cleanup_route_status: ok
|
||||
- [x] latest_runtime_watchdog_status: implemented
|
||||
- [x] latest_runtime_watchdog_route_status: ok
|
||||
- [x] latest_alert_silent_window_status: implemented
|
||||
- [x] latest_alert_silent_window_route_status: ok
|
||||
- [x] latest_runtime_blockers: none
|
||||
- [x] no_new_research_council_phase_created: true
|
||||
|
||||
@@ -75,12 +78,18 @@
|
||||
- [x] Added `GET /trading/runtime-watchdog`
|
||||
- [x] Added `runtime_status.runtime_watchdog`
|
||||
- [x] Added runtime panel stalled-task visibility
|
||||
- [x] Added `AlertSilentWindowService`
|
||||
- [x] Added `scripts/run_alert_silent_window_status.py`
|
||||
- [x] Added `GET /trading/alerts/silent-window-status`
|
||||
- [x] Added silent-window suppression to dispatcher and alert quality summary
|
||||
- [x] Added `Alert Quality` panel silent-window visibility
|
||||
- [x] Added runtime `visual_watch` task
|
||||
- [x] Added focused service, script, API, and runtime tests
|
||||
- [x] Smoked the visual review script with no pending reviews
|
||||
- [x] Smoked the capture script with no watch run provided
|
||||
- [x] Smoked the cleanup script with expired temporary artifacts
|
||||
- [x] Smoked the runtime watchdog script with `stalled_task_count=0`
|
||||
- [x] Smoked the silent-window script with `active=true`
|
||||
- [x] Smoked the trading runtime with blockers=none
|
||||
|
||||
## Blocked
|
||||
@@ -124,6 +133,7 @@
|
||||
- [x] `automation_execution_layer_p9_alert_quality_rollup`
|
||||
- [x] `automation_execution_layer_p10_visual_capture_retention_cleanup`
|
||||
- [x] `automation_execution_layer_p11_runtime_task_watchdog`
|
||||
- [x] `automation_execution_layer_p12_alert_silent_window_noise_control`
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
@@ -397,6 +397,8 @@ candidate -> decision alert -> dispatcher -> 外部消息 -> delivery record
|
||||
- 2026-05-12 P10 本地闭环已验证:cleanup 脚本可删除过期临时文件,runtime smoke 无 blocker,本地 API/workbench smoke 可见 cleanup 路径。
|
||||
- 2026-05-12 P11 已完成 `stuck task` 检测闭环:新增 `RuntimeTaskWatchdogService`、`scripts/run_runtime_task_watchdog.py`、`GET /trading/runtime-watchdog`、`runtime_status.runtime_watchdog`,以及 runtime 面板 stalled-task 呈现。
|
||||
- 2026-05-12 P11 本地闭环已验证:watchdog 脚本返回 `stalled_task_count=0`,runtime/API/workbench smoke 可见 watchdog 路径与状态输出。
|
||||
- 2026-05-12 P12 已完成 `silent window / noise control` 基础闭环:新增 `AlertSilentWindowService`、`scripts/run_alert_silent_window_status.py`、`GET /trading/alerts/silent-window-status`,并将静默窗口状态接入 dispatcher、alert quality summary、runtime `alert_dispatch` 细节和 workbench 面板。
|
||||
- 2026-05-12 P12 本地闭环已验证:silent-window 脚本与 API 可返回 `active=true`,`quality-summary` 与 dashboard 可见静默窗口状态与 suppression 统计。
|
||||
|
||||
### 验收
|
||||
- 非重大错误不打断主循环
|
||||
|
||||
@@ -649,6 +649,10 @@
|
||||
- `VISUAL_CAPTURE_CLEANUP_MAX_DELETE_FILES`
|
||||
- `RUNTIME_TASK_STALE_MULTIPLIER`
|
||||
- `RUNTIME_TASK_STUCK_MIN_SECONDS`
|
||||
- `ALERT_SILENT_WINDOW_ENABLED`
|
||||
- `ALERT_SILENT_WINDOW_START_HOUR`
|
||||
- `ALERT_SILENT_WINDOW_END_HOUR`
|
||||
- `ALERT_SILENT_WINDOW_TIMEZONE`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ def main(env=None) -> int:
|
||||
delivered = sum(1 for item in items if item.get("status") == "delivered")
|
||||
failed = sum(1 for item in items if item.get("status") == "failed")
|
||||
skipped = sum(1 for item in items if item.get("status") == "skipped")
|
||||
silent_window = dict(payload.get("silent_window") or {})
|
||||
print(f"Delivered: {delivered} / Failed: {failed} / Skipped: {skipped}")
|
||||
print(f"Silent window: {'active' if silent_window.get('active') else 'inactive'}")
|
||||
print(f"Suppressed by silent window: {payload.get('suppressed_silent_window', 0)}")
|
||||
items = payload.get("items") or []
|
||||
return 0 if all(item.get("status") != "failed" for item in items) else 2
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.api.alerts import get_alert_silent_window_status
|
||||
|
||||
|
||||
def main(env=None) -> int:
|
||||
env = os.environ if env is None else env
|
||||
output_json = _get_bool(env, "ALERT_SILENT_WINDOW_OUTPUT_JSON", False)
|
||||
payload = get_alert_silent_window_status()
|
||||
if output_json:
|
||||
print(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str))
|
||||
else:
|
||||
print(f"Alert silent window: {'active' if payload.get('active') else 'inactive'}")
|
||||
print(f"Window: {payload.get('window_label')}")
|
||||
print(f"Timezone: {payload.get('timezone')}")
|
||||
if payload.get("next_allowed_ts"):
|
||||
print(f"Next allowed: {payload.get('next_allowed_ts')}")
|
||||
return 0
|
||||
|
||||
|
||||
def _get_bool(env, key: str, default: bool = False) -> bool:
|
||||
value = env.get(key)
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+2
-1
@@ -48,7 +48,7 @@ from src.api.trading_runtime import (
|
||||
)
|
||||
from src.api.trading_journal import get_recent_trading_journal, get_trading_journal_summary
|
||||
from src.api.trading_plans import get_recent_trading_plans
|
||||
from src.api.alerts import dispatch_pending_alerts, get_alert_deliveries, get_alert_quality_summary
|
||||
from src.api.alerts import dispatch_pending_alerts, get_alert_deliveries, get_alert_quality_summary, get_alert_silent_window_status
|
||||
from src.api.feedback import (
|
||||
create_alert_feedback,
|
||||
create_visual_review_feedback,
|
||||
@@ -124,6 +124,7 @@ __all__ = [
|
||||
"dispatch_pending_alerts",
|
||||
"get_alert_deliveries",
|
||||
"get_alert_quality_summary",
|
||||
"get_alert_silent_window_status",
|
||||
"create_alert_feedback",
|
||||
"create_visual_review_feedback",
|
||||
"get_alert_feedback",
|
||||
|
||||
+5
-1
@@ -1,5 +1,5 @@
|
||||
from src.repositories.alert_delivery_repository import AlertDeliveryRepository
|
||||
from src.services.alerts import AlertDispatcherService, AlertQualitySummaryService
|
||||
from src.services.alerts import AlertDispatcherService, AlertQualitySummaryService, AlertSilentWindowService
|
||||
|
||||
|
||||
|
||||
@@ -23,3 +23,7 @@ def get_alert_quality_summary(
|
||||
alert_limit=limit,
|
||||
visual_review_limit=visual_limit,
|
||||
)
|
||||
|
||||
|
||||
def get_alert_silent_window_status() -> dict:
|
||||
return AlertSilentWindowService().build_status()
|
||||
|
||||
+4
-1
@@ -10,7 +10,7 @@ from src.api.candles import get_recent_candles
|
||||
from src.api.execution_tickets import get_recent_execution_tickets
|
||||
from src.api.health import get_health
|
||||
from src.api.live_operation_audits import get_recent_live_operation_audits
|
||||
from src.api.alerts import get_alert_deliveries, get_alert_quality_summary
|
||||
from src.api.alerts import get_alert_deliveries, get_alert_quality_summary, get_alert_silent_window_status
|
||||
from src.api.feedback import (
|
||||
create_alert_feedback,
|
||||
create_visual_review_feedback,
|
||||
@@ -113,6 +113,7 @@ def build_api_dependencies() -> dict:
|
||||
"trading_decision_alerts": get_trading_decision_alerts,
|
||||
"trading_alert_dispatch": run_trading_alert_dispatch,
|
||||
"trading_alert_quality_summary": get_alert_quality_summary,
|
||||
"trading_alert_silent_window_status": get_alert_silent_window_status,
|
||||
"trading_reconciliation_run": run_trading_reconciliation,
|
||||
"trading_runtime_status": get_trading_runtime_status,
|
||||
"trading_runtime_watchdog": get_trading_runtime_watchdog,
|
||||
@@ -257,6 +258,8 @@ def route_request(
|
||||
instrument_id = _require_int(query, "instrument_id")
|
||||
limit = _get_int(query, "limit", 20)
|
||||
return 200, {"items": deps["trading_decision_alerts"](instrument_id=instrument_id, limit=limit)}
|
||||
if method == "GET" and path == "/trading/alerts/silent-window-status":
|
||||
return 200, deps["trading_alert_silent_window_status"]()
|
||||
if method == "GET" and path == "/trading/alerts/quality-summary":
|
||||
instrument_id = _require_int(query, "instrument_id")
|
||||
limit = _get_int(query, "limit", 50)
|
||||
|
||||
@@ -100,6 +100,10 @@ class Settings(BaseSettings):
|
||||
alert_dispatch_interval_seconds: int = 20
|
||||
alert_dispatch_max_per_run: int = 25
|
||||
alert_resend_cooldown_seconds: int = 300
|
||||
alert_silent_window_enabled: bool = False
|
||||
alert_silent_window_start_hour: int = 0
|
||||
alert_silent_window_end_hour: int = 6
|
||||
alert_silent_window_timezone: str = "America/New_York"
|
||||
alert_default_channel: str = "workbench"
|
||||
alert_webhook_url: str = ""
|
||||
default_timezone: str = "UTC"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from src.services.alerts.alert_dispatcher_service import AlertDispatcherService
|
||||
from src.services.alerts.alert_quality_summary_service import AlertQualitySummaryService
|
||||
from src.services.alerts.alert_silent_window_service import AlertSilentWindowService
|
||||
|
||||
__all__ = ["AlertDispatcherService", "AlertQualitySummaryService"]
|
||||
__all__ = ["AlertDispatcherService", "AlertQualitySummaryService", "AlertSilentWindowService"]
|
||||
|
||||
@@ -10,6 +10,7 @@ from src.repositories.decision_packet_repository import DecisionPacketRepository
|
||||
from src.services.feedback import AlertFeedbackService
|
||||
from src.services.alerts.channel_router_service import select_alert_channel
|
||||
from src.services.alerts.delivery_policy_service import should_skip_alert
|
||||
from src.services.alerts.alert_silent_window_service import AlertSilentWindowService
|
||||
from src.services.alerts.message_template_service import build_alert_message
|
||||
from src.services.notifications import WebhookNotifier
|
||||
from src.services.visual_watch import VisualWatchService
|
||||
@@ -23,11 +24,13 @@ class AlertDispatcherService:
|
||||
delivery_repository: AlertDeliveryRepository | None = None,
|
||||
visual_watch_service: VisualWatchService | None = None,
|
||||
feedback_service: AlertFeedbackService | None = None,
|
||||
silent_window_service: AlertSilentWindowService | None = None,
|
||||
) -> None:
|
||||
self.decision_repository = decision_repository or DecisionPacketRepository()
|
||||
self.delivery_repository = delivery_repository or AlertDeliveryRepository()
|
||||
self.visual_watch_service = visual_watch_service or VisualWatchService()
|
||||
self.feedback_service = feedback_service or AlertFeedbackService()
|
||||
self.silent_window_service = silent_window_service or AlertSilentWindowService()
|
||||
|
||||
def dispatch_pending(self, limit: int = 50) -> dict:
|
||||
effective_limit = max(1, min(limit, settings.alert_dispatch_max_per_run))
|
||||
@@ -35,11 +38,14 @@ class AlertDispatcherService:
|
||||
items = []
|
||||
for alert in alerts:
|
||||
items.append(self.dispatch_alert(int(alert["id"])))
|
||||
silent_window = self.silent_window_service.build_status()
|
||||
return {
|
||||
"attempted": len(alerts),
|
||||
"requested_limit": limit,
|
||||
"effective_limit": effective_limit,
|
||||
"items": items,
|
||||
"silent_window": silent_window,
|
||||
"suppressed_silent_window": sum(1 for item in items if item.get("reason") == "alert_silent_window_active"),
|
||||
}
|
||||
|
||||
def dispatch_alert(self, alert_id: int) -> dict:
|
||||
@@ -64,6 +70,8 @@ class AlertDispatcherService:
|
||||
alert,
|
||||
latest_delivery=latest_delivery,
|
||||
latest_equivalent_delivery=latest_equivalent_delivery,
|
||||
channel=channel,
|
||||
silent_window_service=self.silent_window_service,
|
||||
)
|
||||
attempted_ts = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from src.repositories.alert_delivery_repository import AlertDeliveryRepository
|
||||
from src.repositories.alert_feedback_repository import AlertFeedbackRepository
|
||||
from src.repositories.decision_packet_repository import DecisionPacketRepository
|
||||
from src.services.alerts.alert_silent_window_service import AlertSilentWindowService
|
||||
from src.repositories.visual_snapshot_repository import VisualSnapshotRepository
|
||||
|
||||
|
||||
@@ -19,11 +20,13 @@ class AlertQualitySummaryService:
|
||||
delivery_repository: Optional[AlertDeliveryRepository] = None,
|
||||
feedback_repository: Optional[AlertFeedbackRepository] = None,
|
||||
visual_repository: Optional[VisualSnapshotRepository] = None,
|
||||
silent_window_service: Optional[AlertSilentWindowService] = None,
|
||||
) -> None:
|
||||
self.decision_repository = decision_repository or DecisionPacketRepository()
|
||||
self.delivery_repository = delivery_repository or AlertDeliveryRepository()
|
||||
self.feedback_repository = feedback_repository or AlertFeedbackRepository()
|
||||
self.visual_repository = visual_repository or VisualSnapshotRepository()
|
||||
self.silent_window_service = silent_window_service or AlertSilentWindowService()
|
||||
|
||||
def build_summary(
|
||||
self,
|
||||
@@ -79,11 +82,13 @@ class AlertQualitySummaryService:
|
||||
negative_labels={"visual_misleading"},
|
||||
)
|
||||
visual_review_summary = _build_visual_review_summary(visual_reviews)
|
||||
silent_window = self.silent_window_service.build_status()
|
||||
quality_flags = _build_quality_flags(
|
||||
delivery_summary=delivery_summary,
|
||||
alert_feedback_summary=alert_feedback_summary,
|
||||
visual_feedback_summary=visual_feedback_summary,
|
||||
visual_review_summary=visual_review_summary,
|
||||
silent_window=silent_window,
|
||||
)
|
||||
return {
|
||||
"instrument_id": instrument_id,
|
||||
@@ -94,6 +99,7 @@ class AlertQualitySummaryService:
|
||||
"alert_feedback_summary": alert_feedback_summary,
|
||||
"visual_feedback_summary": visual_feedback_summary,
|
||||
"visual_review_summary": visual_review_summary,
|
||||
"silent_window": silent_window,
|
||||
"quality_flags": quality_flags,
|
||||
"warnings": warnings,
|
||||
"operator_summary": _build_operator_summary(
|
||||
@@ -101,6 +107,7 @@ class AlertQualitySummaryService:
|
||||
delivery_summary=delivery_summary,
|
||||
alert_feedback_summary=alert_feedback_summary,
|
||||
visual_feedback_summary=visual_feedback_summary,
|
||||
silent_window=silent_window,
|
||||
warnings=warnings,
|
||||
),
|
||||
}
|
||||
@@ -118,6 +125,7 @@ def _build_delivery_summary(deliveries: list[dict]) -> dict:
|
||||
"delivered_count": delivered,
|
||||
"failed_count": failed,
|
||||
"skipped_count": skipped,
|
||||
"silent_window_skip_count": sum(1 for item in items if str((item.get("payload") or {}).get("reason") or item.get("error") or "") == "alert_silent_window_active"),
|
||||
"external_attempt_count": external_attempts,
|
||||
"success_rate": _rate(delivered, external_attempts),
|
||||
"latest_status": latest.get("status"),
|
||||
@@ -174,8 +182,11 @@ def _build_quality_flags(
|
||||
alert_feedback_summary: dict,
|
||||
visual_feedback_summary: dict,
|
||||
visual_review_summary: dict,
|
||||
silent_window: dict,
|
||||
) -> list[str]:
|
||||
flags = []
|
||||
if silent_window.get("active"):
|
||||
flags.append("alert_silent_window_active")
|
||||
if int(delivery_summary.get("failed_count") or 0) > 0:
|
||||
flags.append("delivery_failures_present")
|
||||
if int(alert_feedback_summary.get("negative_count") or 0) > 0:
|
||||
@@ -195,13 +206,16 @@ def _build_operator_summary(
|
||||
delivery_summary: dict,
|
||||
alert_feedback_summary: dict,
|
||||
visual_feedback_summary: dict,
|
||||
silent_window: dict,
|
||||
warnings: list[str],
|
||||
) -> str:
|
||||
delivery_rate = _format_rate(delivery_summary.get("success_rate"))
|
||||
alert_rate = _format_rate(alert_feedback_summary.get("positive_rate"))
|
||||
visual_rate = _format_rate(visual_feedback_summary.get("positive_rate"))
|
||||
silent = "active" if silent_window.get("active") else "inactive"
|
||||
summary = (
|
||||
f"Recent alerts={alert_count}; "
|
||||
f"silent window={silent}; "
|
||||
f"delivery success={delivery_rate}; "
|
||||
f"alert usefulness={alert_rate}; "
|
||||
f"visual helpfulness={visual_rate}."
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from src.config.settings import settings
|
||||
|
||||
|
||||
class AlertSilentWindowService:
|
||||
def build_status(
|
||||
self,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
enabled: bool | None = None,
|
||||
start_hour: int | None = None,
|
||||
end_hour: int | None = None,
|
||||
timezone_name: str | None = None,
|
||||
) -> dict:
|
||||
enabled_value = settings.alert_silent_window_enabled if enabled is None else bool(enabled)
|
||||
timezone_value = str(timezone_name or settings.alert_silent_window_timezone or "UTC")
|
||||
tz = ZoneInfo(timezone_value)
|
||||
utc_now = now.astimezone(timezone.utc) if isinstance(now, datetime) else datetime.now(timezone.utc)
|
||||
local_now = utc_now.astimezone(tz)
|
||||
start = int(settings.alert_silent_window_start_hour if start_hour is None else start_hour)
|
||||
end = int(settings.alert_silent_window_end_hour if end_hour is None else end_hour)
|
||||
if not enabled_value:
|
||||
return _status_payload(
|
||||
active=False,
|
||||
reason="disabled",
|
||||
timezone_name=timezone_value,
|
||||
local_now=local_now,
|
||||
start_hour=start,
|
||||
end_hour=end,
|
||||
next_allowed_ts=None,
|
||||
current_window_start_ts=None,
|
||||
current_window_end_ts=None,
|
||||
)
|
||||
if start == end:
|
||||
return _status_payload(
|
||||
active=False,
|
||||
reason="inactive",
|
||||
timezone_name=timezone_value,
|
||||
local_now=local_now,
|
||||
start_hour=start,
|
||||
end_hour=end,
|
||||
next_allowed_ts=None,
|
||||
current_window_start_ts=None,
|
||||
current_window_end_ts=None,
|
||||
)
|
||||
|
||||
window = _resolve_window(local_now=local_now, start_hour=start, end_hour=end)
|
||||
active = window["active"]
|
||||
return _status_payload(
|
||||
active=active,
|
||||
reason="active" if active else "inactive",
|
||||
timezone_name=timezone_value,
|
||||
local_now=local_now,
|
||||
start_hour=start,
|
||||
end_hour=end,
|
||||
next_allowed_ts=window["end"] if active else None,
|
||||
current_window_start_ts=window["start"] if active else None,
|
||||
current_window_end_ts=window["end"] if active else None,
|
||||
)
|
||||
|
||||
def is_active(self, *, now: datetime | None = None) -> bool:
|
||||
return bool(self.build_status(now=now).get("active"))
|
||||
|
||||
|
||||
def _resolve_window(*, local_now: datetime, start_hour: int, end_hour: int) -> dict:
|
||||
today = local_now.date()
|
||||
if start_hour < end_hour:
|
||||
start_dt = datetime.combine(today, time(hour=start_hour), tzinfo=local_now.tzinfo)
|
||||
end_dt = datetime.combine(today, time(hour=end_hour), tzinfo=local_now.tzinfo)
|
||||
active = start_dt <= local_now < end_dt
|
||||
return {"active": active, "start": start_dt, "end": end_dt}
|
||||
|
||||
# Overnight window, for example 22 -> 6.
|
||||
if local_now.hour >= start_hour:
|
||||
start_day = today
|
||||
end_day = today + timedelta(days=1)
|
||||
else:
|
||||
start_day = today - timedelta(days=1)
|
||||
end_day = today
|
||||
start_dt = datetime.combine(start_day, time(hour=start_hour), tzinfo=local_now.tzinfo)
|
||||
end_dt = datetime.combine(end_day, time(hour=end_hour), tzinfo=local_now.tzinfo)
|
||||
active = start_dt <= local_now < end_dt
|
||||
return {"active": active, "start": start_dt, "end": end_dt}
|
||||
|
||||
|
||||
def _status_payload(
|
||||
*,
|
||||
active: bool,
|
||||
reason: str,
|
||||
timezone_name: str,
|
||||
local_now: datetime,
|
||||
start_hour: int,
|
||||
end_hour: int,
|
||||
next_allowed_ts: datetime | None,
|
||||
current_window_start_ts: datetime | None,
|
||||
current_window_end_ts: datetime | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"enabled": reason != "disabled",
|
||||
"active": active,
|
||||
"reason": reason,
|
||||
"timezone": timezone_name,
|
||||
"local_now": local_now.isoformat(),
|
||||
"start_hour": start_hour,
|
||||
"end_hour": end_hour,
|
||||
"window_label": f"{start_hour:02d}:00-{end_hour:02d}:00",
|
||||
"next_allowed_ts": next_allowed_ts.astimezone(timezone.utc).isoformat() if next_allowed_ts else None,
|
||||
"current_window_start_ts": current_window_start_ts.astimezone(timezone.utc).isoformat() if current_window_start_ts else None,
|
||||
"current_window_end_ts": current_window_end_ts.astimezone(timezone.utc).isoformat() if current_window_end_ts else None,
|
||||
}
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.services.alerts.alert_silent_window_service import AlertSilentWindowService
|
||||
|
||||
|
||||
def should_skip_alert(
|
||||
@@ -10,19 +11,26 @@ def should_skip_alert(
|
||||
*,
|
||||
latest_delivery: dict | None = None,
|
||||
latest_equivalent_delivery: dict | None = None,
|
||||
channel: str | None = None,
|
||||
now: datetime | None = None,
|
||||
silent_window_service: AlertSilentWindowService | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
status = str(alert.get("status") or "")
|
||||
if status != "queued":
|
||||
return True, "alert_not_queued"
|
||||
if channel and channel != "workbench":
|
||||
service = silent_window_service or AlertSilentWindowService()
|
||||
if service.is_active(now=now):
|
||||
return True, "alert_silent_window_active"
|
||||
cooldown_seconds = max(settings.alert_resend_cooldown_seconds, 0)
|
||||
if _delivery_in_cooldown(latest_delivery, cooldown_seconds=cooldown_seconds):
|
||||
if _delivery_in_cooldown(latest_delivery, cooldown_seconds=cooldown_seconds, now=now):
|
||||
return True, "alert_cooldown_active"
|
||||
if _delivery_in_cooldown(latest_equivalent_delivery, cooldown_seconds=cooldown_seconds):
|
||||
if _delivery_in_cooldown(latest_equivalent_delivery, cooldown_seconds=cooldown_seconds, now=now):
|
||||
return True, "equivalent_alert_cooldown_active"
|
||||
return False, ""
|
||||
|
||||
|
||||
def _delivery_in_cooldown(latest_delivery: dict | None, *, cooldown_seconds: int) -> bool:
|
||||
def _delivery_in_cooldown(latest_delivery: dict | None, *, cooldown_seconds: int, now: datetime | None = None) -> bool:
|
||||
if not latest_delivery:
|
||||
return False
|
||||
latest_status = str(latest_delivery.get("status") or "")
|
||||
@@ -31,7 +39,8 @@ def _delivery_in_cooldown(latest_delivery: dict | None, *, cooldown_seconds: int
|
||||
attempted_ts = _parse_ts(latest_delivery.get("attempted_ts"))
|
||||
if attempted_ts is None:
|
||||
return False
|
||||
age_seconds = (datetime.now(timezone.utc) - attempted_ts).total_seconds()
|
||||
current = now.astimezone(timezone.utc) if isinstance(now, datetime) else datetime.now(timezone.utc)
|
||||
age_seconds = (current - attempted_ts).total_seconds()
|
||||
return age_seconds < cooldown_seconds
|
||||
|
||||
|
||||
|
||||
@@ -505,11 +505,16 @@ class TradingRuntimeScheduler:
|
||||
payload = self.alert_dispatcher_service.dispatch_pending(limit=settings.alert_dispatch_max_per_run)
|
||||
items = list(payload.get("items") or [])
|
||||
failed = [item for item in items if item.get("status") == "failed"]
|
||||
silent_window = dict(payload.get("silent_window") or {})
|
||||
task.details = {
|
||||
"attempted": int(payload.get("attempted") or 0),
|
||||
"delivered": sum(1 for item in items if item.get("status") == "delivered"),
|
||||
"skipped": sum(1 for item in items if item.get("status") == "skipped"),
|
||||
"failed": len(failed),
|
||||
"silent_window_active": bool(silent_window.get("active")),
|
||||
"silent_window_label": silent_window.get("window_label"),
|
||||
"silent_window_next_allowed_ts": silent_window.get("next_allowed_ts"),
|
||||
"suppressed_silent_window": int(payload.get("suppressed_silent_window") or 0),
|
||||
}
|
||||
if failed:
|
||||
code = "alert_dispatch_failed"
|
||||
|
||||
@@ -559,10 +559,12 @@ function renderAlertQuality(summary) {
|
||||
const alertFeedback = summary?.alert_feedback_summary || {};
|
||||
const visualFeedback = summary?.visual_feedback_summary || {};
|
||||
const reviewSummary = summary?.visual_review_summary || {};
|
||||
const silentWindow = summary?.silent_window || {};
|
||||
alertQualityPanel.appendChild(
|
||||
buildInfoGrid([
|
||||
['recent alerts', String(summary?.recent_alert_count ?? 0)],
|
||||
['recent visual reviews', String(summary?.recent_visual_review_count ?? 0)],
|
||||
['silent window', `${silentWindow.active ? 'active' : 'inactive'} | ${silentWindow.window_label || 'n/a'}`],
|
||||
['delivery success', formatPercent(delivery.success_rate)],
|
||||
['alert usefulness', formatPercent(alertFeedback.positive_rate)],
|
||||
['visual helpfulness', formatPercent(visualFeedback.positive_rate)],
|
||||
@@ -578,12 +580,25 @@ function renderAlertQuality(summary) {
|
||||
[
|
||||
['attempts', String(delivery.count ?? 0)],
|
||||
['delivered / failed / skipped', `${delivery.delivered_count ?? 0} / ${delivery.failed_count ?? 0} / ${delivery.skipped_count ?? 0}`],
|
||||
['silent-window skips', String(delivery.silent_window_skip_count ?? 0)],
|
||||
['external send attempts', String(delivery.external_attempt_count ?? 0)],
|
||||
['latest delivery', `${delivery.latest_status || 'n/a'} @ ${delivery.latest_channel || 'n/a'}`],
|
||||
['latest attempted', formatTimestamp(delivery.latest_attempted_ts)],
|
||||
]
|
||||
)
|
||||
);
|
||||
grid.appendChild(
|
||||
buildItemCard(
|
||||
'Silent Window',
|
||||
silentWindow.active ? 'warning' : 'ok',
|
||||
[
|
||||
['status', silentWindow.active ? 'active' : (silentWindow.enabled ? 'inactive' : 'disabled')],
|
||||
['window', silentWindow.window_label || 'n/a'],
|
||||
['timezone', silentWindow.timezone || 'n/a'],
|
||||
['next allowed', formatTimestamp(silentWindow.next_allowed_ts)],
|
||||
]
|
||||
)
|
||||
);
|
||||
grid.appendChild(
|
||||
buildItemCard(
|
||||
'Alert Feedback',
|
||||
|
||||
@@ -210,6 +210,40 @@ class AlertVisualDispatchTests(unittest.TestCase):
|
||||
self.assertEqual(delivery_repository.delivery_calls[0]["payload"]["visual"]["visual_state"], "confirm")
|
||||
self.assertIn("visual=confirm", delivery_repository.delivery_calls[0]["payload"]["message"])
|
||||
|
||||
def test_dispatcher_skips_webhook_when_silent_window_is_active(self) -> None:
|
||||
decision_repository = StubDecisionRepository()
|
||||
decision_repository.alerts[10] = {
|
||||
"id": 10,
|
||||
"decision_packet_id": 55,
|
||||
"alert_type": "candidate_actionable",
|
||||
"status": "queued",
|
||||
"payload": {"action_recommendation": "confirm_ticket", "status": "actionable"},
|
||||
}
|
||||
decision_repository.packets[55] = {"id": 55, "payload": {"symbol": "BTC-USDT"}}
|
||||
delivery_repository = StubDeliveryRepository()
|
||||
silent_window_service = type(
|
||||
"SilentWindowService",
|
||||
(),
|
||||
{
|
||||
"build_status": lambda self, **kwargs: {"active": True, "window_label": "00:00-06:00"},
|
||||
"is_active": lambda self, **kwargs: True,
|
||||
},
|
||||
)()
|
||||
dispatcher = AlertDispatcherService(
|
||||
decision_repository=decision_repository,
|
||||
delivery_repository=delivery_repository,
|
||||
visual_watch_service=StubVisualWatchService(),
|
||||
feedback_service=StubFeedbackService(),
|
||||
silent_window_service=silent_window_service,
|
||||
)
|
||||
|
||||
with patch("src.services.alerts.channel_router_service.settings.alert_webhook_url", "https://example.test/hook"):
|
||||
result = dispatcher.dispatch_alert(10)
|
||||
|
||||
self.assertEqual(result["status"], "skipped")
|
||||
self.assertEqual(result["reason"], "alert_silent_window_active")
|
||||
self.assertEqual(delivery_repository.delivery_calls[0]["error"], "alert_silent_window_active")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -43,6 +43,20 @@ class _VisualRepository:
|
||||
][:limit]
|
||||
|
||||
|
||||
class _SilentWindowService:
|
||||
def __init__(self, active: bool = False) -> None:
|
||||
self.active = active
|
||||
|
||||
def build_status(self):
|
||||
return {
|
||||
"active": self.active,
|
||||
"enabled": True,
|
||||
"window_label": "00:00-06:00",
|
||||
"timezone": "UTC",
|
||||
"next_allowed_ts": "2026-05-12T06:00:00+00:00" if self.active else None,
|
||||
}
|
||||
|
||||
|
||||
class AlertQualitySummaryServiceTests(unittest.TestCase):
|
||||
def test_build_summary_aggregates_delivery_and_feedback_rates(self) -> None:
|
||||
service = AlertQualitySummaryService(
|
||||
@@ -50,6 +64,7 @@ class AlertQualitySummaryServiceTests(unittest.TestCase):
|
||||
delivery_repository=_DeliveryRepository(),
|
||||
feedback_repository=_FeedbackRepository(),
|
||||
visual_repository=_VisualRepository(),
|
||||
silent_window_service=_SilentWindowService(active=True),
|
||||
)
|
||||
|
||||
summary = service.build_summary(instrument_id=1, alert_limit=10, visual_review_limit=10)
|
||||
@@ -58,6 +73,7 @@ class AlertQualitySummaryServiceTests(unittest.TestCase):
|
||||
self.assertEqual(summary["delivery_summary"]["delivered_count"], 1)
|
||||
self.assertEqual(summary["delivery_summary"]["failed_count"], 1)
|
||||
self.assertEqual(summary["delivery_summary"]["skipped_count"], 1)
|
||||
self.assertTrue(summary["silent_window"]["active"])
|
||||
self.assertEqual(summary["delivery_summary"]["success_rate"], 0.5)
|
||||
self.assertEqual(summary["alert_feedback_summary"]["positive_count"], 1)
|
||||
self.assertEqual(summary["alert_feedback_summary"]["negative_count"], 1)
|
||||
@@ -68,6 +84,7 @@ class AlertQualitySummaryServiceTests(unittest.TestCase):
|
||||
self.assertEqual(summary["visual_review_summary"]["state_counts"]["confirm"], 1)
|
||||
self.assertEqual(summary["visual_review_summary"]["state_counts"]["mismatch"], 1)
|
||||
self.assertIn("delivery_failures_present", summary["quality_flags"])
|
||||
self.assertIn("alert_silent_window_active", summary["quality_flags"])
|
||||
self.assertIn("visual_negative_feedback_present", summary["quality_flags"])
|
||||
self.assertIn("visual_mismatch_present", summary["quality_flags"])
|
||||
|
||||
@@ -92,6 +109,7 @@ class AlertQualitySummaryServiceTests(unittest.TestCase):
|
||||
delivery_repository=_EmptyDeliveryRepository(),
|
||||
feedback_repository=_EmptyFeedbackRepository(),
|
||||
visual_repository=_EmptyVisualRepository(),
|
||||
silent_window_service=_SilentWindowService(active=False),
|
||||
)
|
||||
|
||||
summary = service.build_summary(instrument_id=1)
|
||||
@@ -128,6 +146,7 @@ class AlertQualitySummaryServiceTests(unittest.TestCase):
|
||||
delivery_repository=_DeliveryRepository(),
|
||||
feedback_repository=_BrokenFeedbackRepository(),
|
||||
visual_repository=_BrokenVisualRepository(),
|
||||
silent_window_service=_SilentWindowService(active=False),
|
||||
)
|
||||
|
||||
summary = service.build_summary(instrument_id=1)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.services.alerts.alert_silent_window_service import AlertSilentWindowService
|
||||
|
||||
|
||||
class AlertSilentWindowServiceTests(unittest.TestCase):
|
||||
def test_build_status_reports_active_for_same_day_window(self) -> None:
|
||||
service = AlertSilentWindowService()
|
||||
now = datetime(2026, 5, 12, 3, 0, tzinfo=timezone.utc)
|
||||
|
||||
payload = service.build_status(
|
||||
now=now,
|
||||
enabled=True,
|
||||
start_hour=0,
|
||||
end_hour=6,
|
||||
timezone_name="UTC",
|
||||
)
|
||||
|
||||
self.assertTrue(payload["active"])
|
||||
self.assertEqual(payload["reason"], "active")
|
||||
self.assertEqual(payload["window_label"], "00:00-06:00")
|
||||
self.assertIsNotNone(payload["next_allowed_ts"])
|
||||
|
||||
def test_build_status_reports_active_for_overnight_window(self) -> None:
|
||||
service = AlertSilentWindowService()
|
||||
now = datetime(2026, 5, 12, 2, 0, tzinfo=timezone.utc)
|
||||
|
||||
payload = service.build_status(
|
||||
now=now,
|
||||
enabled=True,
|
||||
start_hour=22,
|
||||
end_hour=6,
|
||||
timezone_name="UTC",
|
||||
)
|
||||
|
||||
self.assertTrue(payload["active"])
|
||||
self.assertEqual(payload["window_label"], "22:00-06:00")
|
||||
|
||||
def test_build_status_reports_disabled(self) -> None:
|
||||
service = AlertSilentWindowService()
|
||||
payload = service.build_status(enabled=False, timezone_name="UTC")
|
||||
|
||||
self.assertFalse(payload["active"])
|
||||
self.assertEqual(payload["reason"], "disabled")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -86,6 +86,17 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
self.assertEqual(payload["recent_alert_count"], 25)
|
||||
self.assertEqual(payload["recent_visual_review_count"], 15)
|
||||
|
||||
def test_routes_trading_alert_silent_window_status(self) -> None:
|
||||
status, payload = route_request(
|
||||
"/trading/alerts/silent-window-status",
|
||||
{},
|
||||
dependencies={"trading_alert_silent_window_status": lambda: {"active": True, "window_label": "00:00-06:00"}},
|
||||
)
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(payload["active"])
|
||||
self.assertEqual(payload["window_label"], "00:00-06:00")
|
||||
|
||||
def test_routes_trading_reconcile(self) -> None:
|
||||
status, payload = route_request(
|
||||
"/trading/reconcile",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from scripts import run_alert_silent_window_status
|
||||
|
||||
|
||||
class RunAlertSilentWindowStatusScriptTests(unittest.TestCase):
|
||||
def test_main_outputs_json(self) -> None:
|
||||
with patch("scripts.run_alert_silent_window_status.get_alert_silent_window_status") as status_mock:
|
||||
status_mock.return_value = {
|
||||
"active": True,
|
||||
"window_label": "00:00-06:00",
|
||||
"timezone": "UTC",
|
||||
"next_allowed_ts": "2026-05-12T06:00:00+00:00",
|
||||
}
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
exit_code = run_alert_silent_window_status.main(env={"ALERT_SILENT_WINDOW_OUTPUT_JSON": "1"})
|
||||
|
||||
payload = json.loads(output.getvalue())
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertTrue(payload["active"])
|
||||
status_mock.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -116,7 +116,16 @@ class FakeReconciliationService:
|
||||
|
||||
class FakeAlertDispatcherService:
|
||||
def dispatch_pending(self, **kwargs):
|
||||
return {"attempted": 0, "items": []}
|
||||
return {
|
||||
"attempted": 0,
|
||||
"items": [],
|
||||
"silent_window": {
|
||||
"active": False,
|
||||
"window_label": "00:00-06:00",
|
||||
"next_allowed_ts": None,
|
||||
},
|
||||
"suppressed_silent_window": 0,
|
||||
}
|
||||
|
||||
|
||||
class FakeVisualReviewService:
|
||||
@@ -215,6 +224,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
|
||||
self.assertEqual(payload["tasks"]["visual_watch"]["details"]["visual_reviews_created"], 1)
|
||||
self.assertEqual(payload["tasks"]["visual_watch"]["details"]["last_visual_state"], "confirm")
|
||||
self.assertEqual(payload["tasks"]["visual_capture_cleanup"]["details"]["deleted_file_count"], 2)
|
||||
self.assertFalse(payload["tasks"]["alert_dispatch"]["details"]["silent_window_active"])
|
||||
self.assertEqual(payload["runtime_watchdog"]["stalled_task_count"], 0)
|
||||
self.assertEqual(payload["latest_broker_state_by_symbol"]["BTC-USDT"]["account"]["equity"], 1000.0)
|
||||
self.assertTrue(payload["tasks"]["watch"]["next_run_ts"])
|
||||
|
||||
Reference in New Issue
Block a user