Harden exchange context fallbacks
This commit is contained in:
+6
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
## Current Goal
|
||||
|
||||
Automation execution-layer PXX remains closed and its known scope stays frozen. Under explicit user direction, the active product phase is a dense `Decision Assistant` operator workspace with grouped recall, broader replay baselines, note templates, quick-save shortcuts, read-only market context, and a continuous operator cockpit. This run closed H28 as a real focus-workspace batch: a new `focus_workspace_context` now carries the current focus title/session/model/tier plus cross-section workspace prompts, watchlist cards can pin the active focus locally, and the cockpit exposes a dedicated `Focus Workspace` section that bridges Watchlist, Evidence, Review Entry, Digest, and Replay around the same object. Local validation passed, the current working tree was deployed, and remote smoke confirmed the latest Decision Assistant fields on the live host.
|
||||
Automation execution-layer PXX remains closed and its known scope stays frozen. Under explicit user direction, the active product phase is a dense `Decision Assistant` operator workspace with grouped recall, broader replay baselines, note templates, quick-save shortcuts, read-only market context, and a continuous operator cockpit. This run closed E8 as a stability-hardening batch: OKX system status and announcements now retry across proxy/direct paths, fall back to cached snapshots when available, degrade to empty fallback instead of hard error when no cache exists, and no longer poison the external-context chart inventory when the upstream endpoint flakes. Local validation passed, the current working tree was deployed, and remote smoke confirmed the latest Decision Assistant fields on the live host.
|
||||
|
||||
## Current Branch
|
||||
|
||||
@@ -134,6 +134,7 @@ Automation execution-layer PXX remains closed and its known scope stays frozen.
|
||||
- latest_decision_assistant_workflow_persistence_slice: workflow_history_archive_persistence
|
||||
- latest_decision_assistant_workflow_archive_slice: persisted_workflow_archive_visibility
|
||||
- latest_decision_assistant_focus_workspace_slice: focus_workspace_context
|
||||
- latest_decision_assistant_exchange_hardening_slice: okx_status_announcement_cached_fallbacks
|
||||
- latest_decision_assistant_summary_status: implemented
|
||||
- latest_decision_assistant_change_summary_status: implemented
|
||||
- latest_decision_assistant_evidence_status: implemented
|
||||
@@ -252,10 +253,11 @@ Automation execution-layer PXX remains closed and its known scope stays frozen.
|
||||
- latest_decision_assistant_workflow_persistence_status: implemented
|
||||
- latest_decision_assistant_workflow_archive_status: implemented
|
||||
- latest_decision_assistant_focus_workspace_status: implemented
|
||||
- latest_decision_assistant_exchange_hardening_status: implemented
|
||||
- latest_decision_assistant_workbench_status: visible_local_smoke
|
||||
- latest_remote_decision_assistant_deploy_status: deployed_current_head
|
||||
- latest_remote_decision_assistant_smoke_status: passed
|
||||
- latest_remote_decision_assistant_review_packet_status: focus_workspace_context_visible
|
||||
- latest_remote_decision_assistant_review_packet_status: exchange_context_fallback_visible
|
||||
- latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup
|
||||
- latest_p14_slice: webhook_transport_retry
|
||||
- latest_p15_pxx_tasklist_status: ready
|
||||
@@ -673,11 +675,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen.
|
||||
- Latest C3/G20/H26 validation passed: focused operator workspace tests, 68 related unit/API/static tests, 713 full unit tests, 73 script static checks, `node --check src/web/app.js`, deploy smoke passed 11 endpoints, remote ops smoke passed 9 checks, and remote field smoke confirmed `watchlist focus matrix`, `review_entry_action_queue`, `review_entry_handoff_summary`, and watchlist-focus presence inside `assistant_loop_queue`.
|
||||
- Latest F26/W5/H27 validation passed: focused operator workspace/API/deploy tests, 75 related unit/API/static tests, 715 full unit tests, 73 script static checks, `node --check src/web/app.js`, remote migrations `044_create_decision_assistant_digest_state.sql` and `045_create_decision_assistant_workflow_history.sql` applied successfully, deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed `/trading/alerts/digest-preferences`, persisted `digest_preference_profile`, `digest_history_archive`, and `workflow_history_archive`.
|
||||
- Latest H28 validation passed: focused operator workspace tests, 69 related unit/API/static tests, 715 full unit tests, 73 script static checks, `node --check src/web/app.js`, deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed `focus_workspace_context` plus the deployed `Focus Workspace` UI.
|
||||
- Latest E8 validation passed: focused exchange/provider tests, 71 related unit/API/static tests, 717 full unit tests, 73 script static checks, deploy completed, remote ops smoke passed 9 checks, and remote field smoke confirmed `exchange_status/announcements = idle`, `cache_state = empty_fallback`, and `chart_stale_source_count = 0`.
|
||||
- Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter.
|
||||
|
||||
## Exact Next Step
|
||||
|
||||
H28 focus workspace batch is closed. External chart URLs remain optional reference enhancements and are not required for the current analysis-only loop. Internal non-trading backlog still exists, but PXX remains closed; do not create P25/P26 automatically, and do not enable real trading.
|
||||
E8 exchange-context hardening batch is closed. External chart URLs remain optional reference enhancements and are not required for the current analysis-only loop. The remaining unfinished part of item 4 is the real external-chart adapter itself; PXX remains closed and you still must not create P25/P26 automatically or enable real trading.
|
||||
|
||||
## Validation Commands Used Recently
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@
|
||||
- [x] W5 workflow artifact archive persistence slice
|
||||
- [x] H27 persisted digest archive / cockpit sync slice
|
||||
- [x] H28 focus workspace / cross-section current-focus sync slice
|
||||
- [x] E8 exchange context cached/empty fallback hardening slice
|
||||
- [x] P8 local closure complete
|
||||
- [x] P9 local closure complete
|
||||
- [x] P10 local closure complete
|
||||
@@ -291,6 +292,7 @@
|
||||
- [x] latest_decision_assistant_workflow_persistence_slice: workflow_history_archive_persistence
|
||||
- [x] latest_decision_assistant_workflow_archive_slice: persisted_workflow_archive_visibility
|
||||
- [x] latest_decision_assistant_focus_workspace_slice: focus_workspace_context
|
||||
- [x] latest_decision_assistant_exchange_hardening_slice: okx_status_announcement_cached_fallbacks
|
||||
- [x] latest_decision_assistant_summary_status: implemented
|
||||
- [x] latest_decision_assistant_change_summary_status: implemented
|
||||
- [x] latest_decision_assistant_evidence_status: implemented
|
||||
@@ -406,10 +408,11 @@
|
||||
- [x] latest_decision_assistant_workflow_persistence_status: implemented
|
||||
- [x] latest_decision_assistant_workflow_archive_status: implemented
|
||||
- [x] latest_decision_assistant_focus_workspace_status: implemented
|
||||
- [x] latest_decision_assistant_exchange_hardening_status: implemented
|
||||
- [x] latest_decision_assistant_workbench_status: visible_local_smoke
|
||||
- [x] latest_remote_decision_assistant_deploy_status: deployed_current_head
|
||||
- [x] latest_remote_decision_assistant_smoke_status: passed
|
||||
- [x] latest_remote_decision_assistant_review_packet_status: focus_workspace_context_visible
|
||||
- [x] latest_remote_decision_assistant_review_packet_status: exchange_context_fallback_visible
|
||||
- [x] latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup
|
||||
- [x] latest_p14_slice: webhook_transport_retry
|
||||
- [x] latest_p15_pxx_tasklist_status: ready
|
||||
@@ -725,6 +728,8 @@
|
||||
- [x] Deployed F26/W5/H27 current tree, applied remote migrations `044_create_decision_assistant_digest_state.sql` and `045_create_decision_assistant_workflow_history.sql`, and passed remote smoke for `/trading/alerts/digest-preferences`, persisted `digest_preference_profile`, `digest_history_archive`, and `workflow_history_archive`
|
||||
- [x] Completed H28 focused validation: operator workspace tests, 69 related unit/API/static tests, 715 full unit tests, 73 script static checks, and dashboard JS syntax
|
||||
- [x] Deployed H28 current tree and passed remote smoke: 9 remote ops checks, `focus_workspace_context` field smoke, and deployed `Focus Workspace` UI visibility
|
||||
- [x] Completed E8 focused validation: exchange/provider tests, 71 related unit/API/static tests, 717 full unit tests, 73 script static checks, and dashboard JS syntax
|
||||
- [x] Deployed E8 current tree and passed remote smoke: 9 remote ops checks, `exchange_status/announcements` fallback field smoke, and `chart_stale_source_count = 0`
|
||||
|
||||
## Blocked
|
||||
|
||||
|
||||
@@ -339,3 +339,17 @@ class MarketStateRepository:
|
||||
with SessionLocal() as session:
|
||||
row = session.execute(sql).mappings().first()
|
||||
return dict(row) if row else None
|
||||
|
||||
def fetch_news_provider_snapshot(self, provider_name: str) -> Optional[dict]:
|
||||
sql = text(
|
||||
"""
|
||||
select id, provider_name, fetched_ts, freshness_code, event_count, is_active,
|
||||
code, active_events, error, raw_payload, updated_at
|
||||
from news_provider_snapshots
|
||||
where provider_name = :provider_name
|
||||
limit 1
|
||||
"""
|
||||
)
|
||||
with SessionLocal() as session:
|
||||
row = session.execute(sql, {"provider_name": provider_name}).mappings().first()
|
||||
return dict(row) if row else None
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Optional
|
||||
import httpx
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.repositories.market_state_repository import MarketStateRepository
|
||||
|
||||
|
||||
DEFAULT_OKX_SYSTEM_STATUS_URL = "https://www.okx.com/api/v5/system/status"
|
||||
@@ -22,10 +23,14 @@ class OkxSystemStatusProvider:
|
||||
url: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
repository: Optional[MarketStateRepository] = None,
|
||||
cache_max_age_seconds: Optional[int] = None,
|
||||
) -> None:
|
||||
self.url = url or DEFAULT_OKX_SYSTEM_STATUS_URL
|
||||
self.timeout = timeout or settings.news_provider_timeout
|
||||
self.proxy_url = proxy_url or settings.market_truth_proxy_url or getenv("HTTPS_PROXY") or getenv("HTTP_PROXY") or ""
|
||||
self.repository = repository or MarketStateRepository()
|
||||
self.cache_max_age_seconds = cache_max_age_seconds or settings.news_provider_cache_max_age_seconds
|
||||
|
||||
def fetch_snapshot(self, *, now: Optional[datetime] = None, payload: Optional[dict] = None) -> dict:
|
||||
observed_now = now or datetime.now(timezone.utc)
|
||||
@@ -33,38 +38,88 @@ class OkxSystemStatusProvider:
|
||||
data = dict(payload or self._fetch_payload())
|
||||
events = [dict(item or {}) for item in list(data.get("data") or []) if isinstance(item, dict)]
|
||||
active_events = [_normalize_status_event(item) for item in events if _status_event_is_active(item)]
|
||||
return {
|
||||
snapshot = {
|
||||
"provider_name": self.provider_name,
|
||||
"fetched_ts": observed_now.isoformat(),
|
||||
"status": "active" if active_events else "idle",
|
||||
"freshness_code": "provider_fresh",
|
||||
"cache_state": "fresh",
|
||||
"event_count": len(events),
|
||||
"active_events": active_events[:6],
|
||||
"error": None,
|
||||
"warning": None,
|
||||
"raw_payload": data,
|
||||
}
|
||||
self._persist_snapshot(snapshot)
|
||||
return snapshot
|
||||
except Exception as exc:
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"fetched_ts": observed_now.isoformat(),
|
||||
"status": "error",
|
||||
"event_count": 0,
|
||||
"active_events": [],
|
||||
"error": str(exc),
|
||||
"raw_payload": {},
|
||||
}
|
||||
cached = self._cached_snapshot(observed_now=observed_now, warning=str(exc))
|
||||
if cached is not None:
|
||||
return cached
|
||||
return self._empty_fallback_snapshot(observed_now=observed_now, warning=str(exc))
|
||||
|
||||
def _fetch_payload(self) -> dict:
|
||||
kwargs = {"timeout": self.timeout, "follow_redirects": True}
|
||||
if self.proxy_url:
|
||||
kwargs["proxy"] = self.proxy_url
|
||||
kwargs["trust_env"] = False
|
||||
with httpx.Client(**kwargs) as client:
|
||||
response = client.get(self.url, headers={"User-Agent": "ai-ict/exchange-status"})
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("unsupported_okx_system_status_payload")
|
||||
return payload
|
||||
return _fetch_okx_json(
|
||||
url=self.url,
|
||||
timeout=self.timeout,
|
||||
proxy_url=self.proxy_url,
|
||||
headers={"User-Agent": "ai-ict/exchange-status"},
|
||||
payload_error="unsupported_okx_system_status_payload",
|
||||
)
|
||||
|
||||
def _persist_snapshot(self, snapshot: dict) -> None:
|
||||
try:
|
||||
self.repository.upsert_news_provider_snapshot(
|
||||
provider_name=self.provider_name,
|
||||
fetched_ts=snapshot.get("fetched_ts"),
|
||||
freshness_code=snapshot.get("freshness_code") or "provider_fresh",
|
||||
event_count=int(snapshot.get("event_count") or 0),
|
||||
is_active=bool(snapshot.get("status") == "active"),
|
||||
code=str(snapshot.get("status") or "idle"),
|
||||
active_events=list(snapshot.get("active_events") or []),
|
||||
error=snapshot.get("error"),
|
||||
raw_payload=dict(snapshot.get("raw_payload") or {}),
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def _cached_snapshot(self, *, observed_now: datetime, warning: str) -> dict | None:
|
||||
try:
|
||||
row = self.repository.fetch_news_provider_snapshot(self.provider_name)
|
||||
except Exception:
|
||||
return None
|
||||
if not row or not row.get("fetched_ts"):
|
||||
return None
|
||||
fetched_ts = _ensure_aware(row.get("fetched_ts"))
|
||||
age_seconds = max((observed_now - fetched_ts).total_seconds(), 0.0)
|
||||
if age_seconds > max(int(self.cache_max_age_seconds or 0), 1):
|
||||
return None
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"fetched_ts": fetched_ts.isoformat(),
|
||||
"status": str(row.get("code") or "idle"),
|
||||
"freshness_code": "provider_cached",
|
||||
"cache_state": "cached_fallback",
|
||||
"event_count": int(row.get("event_count") or 0),
|
||||
"active_events": list(row.get("active_events") or []),
|
||||
"error": None,
|
||||
"warning": warning,
|
||||
"raw_payload": dict(row.get("raw_payload") or {}),
|
||||
}
|
||||
|
||||
def _empty_fallback_snapshot(self, *, observed_now: datetime, warning: str) -> dict:
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"fetched_ts": observed_now.isoformat(),
|
||||
"status": "idle",
|
||||
"freshness_code": "provider_fallback_empty",
|
||||
"cache_state": "empty_fallback",
|
||||
"event_count": 0,
|
||||
"active_events": [],
|
||||
"error": None,
|
||||
"warning": warning,
|
||||
"raw_payload": {},
|
||||
}
|
||||
|
||||
|
||||
def _status_event_is_active(row: dict) -> bool:
|
||||
@@ -93,11 +148,15 @@ class OkxAnnouncementsProvider:
|
||||
timeout: Optional[int] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
language: str = "en-US",
|
||||
repository: Optional[MarketStateRepository] = None,
|
||||
cache_max_age_seconds: Optional[int] = None,
|
||||
) -> None:
|
||||
self.url = url or DEFAULT_OKX_ANNOUNCEMENTS_URL
|
||||
self.timeout = timeout or settings.news_provider_timeout
|
||||
self.proxy_url = proxy_url or settings.market_truth_proxy_url or getenv("HTTPS_PROXY") or getenv("HTTP_PROXY") or ""
|
||||
self.language = language
|
||||
self.repository = repository or MarketStateRepository()
|
||||
self.cache_max_age_seconds = cache_max_age_seconds or settings.news_provider_cache_max_age_seconds
|
||||
|
||||
def fetch_snapshot(self, *, payload: Optional[dict] = None, now: Optional[datetime] = None) -> dict:
|
||||
observed_now = now or datetime.now(timezone.utc)
|
||||
@@ -107,44 +166,91 @@ class OkxAnnouncementsProvider:
|
||||
for row in list(data.get("data") or []):
|
||||
if isinstance(row, dict):
|
||||
details.extend([dict(item or {}) for item in list(row.get("details") or []) if isinstance(item, dict)])
|
||||
return {
|
||||
snapshot = {
|
||||
"provider_name": self.provider_name,
|
||||
"fetched_ts": observed_now.isoformat(),
|
||||
"status": "active" if details else "idle",
|
||||
"freshness_code": "provider_fresh",
|
||||
"cache_state": "fresh",
|
||||
"announcement_count": len(details),
|
||||
"announcements": [_normalize_announcement_item(item) for item in details[:6]],
|
||||
"error": None,
|
||||
"warning": None,
|
||||
"raw_payload": data,
|
||||
}
|
||||
self._persist_snapshot(snapshot)
|
||||
return snapshot
|
||||
except Exception as exc:
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"fetched_ts": observed_now.isoformat(),
|
||||
"status": "error",
|
||||
"announcement_count": 0,
|
||||
"announcements": [],
|
||||
"error": str(exc),
|
||||
"raw_payload": {},
|
||||
}
|
||||
cached = self._cached_snapshot(observed_now=observed_now, warning=str(exc))
|
||||
if cached is not None:
|
||||
return cached
|
||||
return self._empty_fallback_snapshot(observed_now=observed_now, warning=str(exc))
|
||||
|
||||
def _fetch_payload(self) -> dict:
|
||||
kwargs = {"timeout": self.timeout, "follow_redirects": True}
|
||||
if self.proxy_url:
|
||||
kwargs["proxy"] = self.proxy_url
|
||||
kwargs["trust_env"] = False
|
||||
with httpx.Client(**kwargs) as client:
|
||||
response = client.get(
|
||||
self.url,
|
||||
headers={
|
||||
"User-Agent": "ai-ict/exchange-announcements",
|
||||
"Accept-Language": self.language,
|
||||
},
|
||||
return _fetch_okx_json(
|
||||
url=self.url,
|
||||
timeout=self.timeout,
|
||||
proxy_url=self.proxy_url,
|
||||
headers={
|
||||
"User-Agent": "ai-ict/exchange-announcements",
|
||||
"Accept-Language": self.language,
|
||||
},
|
||||
payload_error="unsupported_okx_announcements_payload",
|
||||
)
|
||||
|
||||
def _persist_snapshot(self, snapshot: dict) -> None:
|
||||
try:
|
||||
self.repository.upsert_news_provider_snapshot(
|
||||
provider_name=self.provider_name,
|
||||
fetched_ts=snapshot.get("fetched_ts"),
|
||||
freshness_code=snapshot.get("freshness_code") or "provider_fresh",
|
||||
event_count=int(snapshot.get("announcement_count") or 0),
|
||||
is_active=bool(snapshot.get("status") == "active"),
|
||||
code=str(snapshot.get("status") or "idle"),
|
||||
active_events=list(snapshot.get("announcements") or []),
|
||||
error=snapshot.get("error"),
|
||||
raw_payload=dict(snapshot.get("raw_payload") or {}),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("unsupported_okx_announcements_payload")
|
||||
return payload
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def _cached_snapshot(self, *, observed_now: datetime, warning: str) -> dict | None:
|
||||
try:
|
||||
row = self.repository.fetch_news_provider_snapshot(self.provider_name)
|
||||
except Exception:
|
||||
return None
|
||||
if not row or not row.get("fetched_ts"):
|
||||
return None
|
||||
fetched_ts = _ensure_aware(row.get("fetched_ts"))
|
||||
age_seconds = max((observed_now - fetched_ts).total_seconds(), 0.0)
|
||||
if age_seconds > max(int(self.cache_max_age_seconds or 0), 1):
|
||||
return None
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"fetched_ts": fetched_ts.isoformat(),
|
||||
"status": str(row.get("code") or "idle"),
|
||||
"freshness_code": "provider_cached",
|
||||
"cache_state": "cached_fallback",
|
||||
"announcement_count": int(row.get("event_count") or 0),
|
||||
"announcements": list(row.get("active_events") or []),
|
||||
"error": None,
|
||||
"warning": warning,
|
||||
"raw_payload": dict(row.get("raw_payload") or {}),
|
||||
}
|
||||
|
||||
def _empty_fallback_snapshot(self, *, observed_now: datetime, warning: str) -> dict:
|
||||
return {
|
||||
"provider_name": self.provider_name,
|
||||
"fetched_ts": observed_now.isoformat(),
|
||||
"status": "idle",
|
||||
"freshness_code": "provider_fallback_empty",
|
||||
"cache_state": "empty_fallback",
|
||||
"announcement_count": 0,
|
||||
"announcements": [],
|
||||
"error": None,
|
||||
"warning": warning,
|
||||
"raw_payload": {},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_announcement_item(row: dict) -> dict:
|
||||
@@ -155,3 +261,34 @@ def _normalize_announcement_item(row: dict) -> dict:
|
||||
"business_published_ts": str(row.get("businessPTime") or ""),
|
||||
"url": str(row.get("url") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _fetch_okx_json(*, url: str, timeout: int, proxy_url: str, headers: dict, payload_error: str) -> dict:
|
||||
attempt_timeout = max(1, min(int(timeout or 1), 4))
|
||||
attempts = []
|
||||
if proxy_url:
|
||||
attempts.append({"timeout": attempt_timeout, "follow_redirects": True, "proxy": proxy_url, "trust_env": False})
|
||||
attempts.append({"timeout": attempt_timeout, "follow_redirects": True, "trust_env": False})
|
||||
errors = []
|
||||
for kwargs in attempts:
|
||||
try:
|
||||
with httpx.Client(**kwargs) as client:
|
||||
response = client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(payload_error)
|
||||
return payload
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
raise RuntimeError(" | ".join(errors))
|
||||
|
||||
|
||||
def _ensure_aware(value) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
else:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
@@ -3327,7 +3327,7 @@ def _build_external_context_chart_readonly_sources(
|
||||
"source_id": "exchange_status_context",
|
||||
"source_type": "exchange_status_context",
|
||||
"status": exchange_status.get("status") or "unknown",
|
||||
"freshness_status": "source_current" if exchange_status.get("status") == "active" else "source_degraded",
|
||||
"freshness_status": "source_degraded" if exchange_status.get("error") else "source_current",
|
||||
"stale_source_evidence": exchange_status.get("error") if exchange_status.get("status") == "error" else None,
|
||||
"prompt": "Use exchange status only as context, not execution permission.",
|
||||
},
|
||||
@@ -3335,7 +3335,7 @@ def _build_external_context_chart_readonly_sources(
|
||||
"source_id": "announcement_context",
|
||||
"source_type": "announcement_context",
|
||||
"status": announcements_snapshot.get("status") or "unknown",
|
||||
"freshness_status": "source_current" if announcements_snapshot.get("status") == "active" else "source_degraded",
|
||||
"freshness_status": "source_degraded" if announcements_snapshot.get("error") else "source_current",
|
||||
"stale_source_evidence": announcements_snapshot.get("error") if announcements_snapshot.get("status") == "error" else None,
|
||||
"prompt": "Use announcements as read-only chart context when evaluating event windows.",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import os
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
|
||||
|
||||
from src.services.market_state.exchange_status_provider import OkxAnnouncementsProvider, OkxSystemStatusProvider
|
||||
|
||||
|
||||
class ExchangeStatusProviderTests(unittest.TestCase):
|
||||
def test_system_status_provider_uses_cached_snapshot_when_live_fetch_fails(self) -> None:
|
||||
repository = _SnapshotRepo(
|
||||
{
|
||||
"provider_name": "okx_system_status",
|
||||
"fetched_ts": "2026-05-14T02:00:00+00:00",
|
||||
"event_count": 1,
|
||||
"code": "active",
|
||||
"active_events": [{"title": "API maintenance"}],
|
||||
"raw_payload": {"data": [{"title": "API maintenance"}]},
|
||||
}
|
||||
)
|
||||
provider = OkxSystemStatusProvider(repository=repository, cache_max_age_seconds=21600)
|
||||
provider._fetch_payload = lambda: (_ for _ in ()).throw(RuntimeError("ssl eof")) # type: ignore[attr-defined]
|
||||
|
||||
snapshot = provider.fetch_snapshot(now=datetime(2026, 5, 14, 2, 30, tzinfo=timezone.utc))
|
||||
|
||||
self.assertEqual(snapshot["status"], "active")
|
||||
self.assertEqual(snapshot["freshness_code"], "provider_cached")
|
||||
self.assertEqual(snapshot["cache_state"], "cached_fallback")
|
||||
self.assertIsNone(snapshot["error"])
|
||||
self.assertIn("ssl eof", snapshot["warning"])
|
||||
|
||||
def test_announcements_provider_uses_cached_snapshot_when_live_fetch_fails(self) -> None:
|
||||
repository = _SnapshotRepo(
|
||||
{
|
||||
"provider_name": "okx_announcements",
|
||||
"fetched_ts": "2026-05-14T02:00:00+00:00",
|
||||
"event_count": 1,
|
||||
"code": "active",
|
||||
"active_events": [{"title": "Listing schedule update"}],
|
||||
"raw_payload": {"data": [{"details": [{"title": "Listing schedule update"}]}]},
|
||||
}
|
||||
)
|
||||
provider = OkxAnnouncementsProvider(repository=repository, cache_max_age_seconds=21600)
|
||||
provider._fetch_payload = lambda: (_ for _ in ()).throw(RuntimeError("ssl eof")) # type: ignore[attr-defined]
|
||||
|
||||
snapshot = provider.fetch_snapshot(now=datetime(2026, 5, 14, 2, 30, tzinfo=timezone.utc))
|
||||
|
||||
self.assertEqual(snapshot["status"], "active")
|
||||
self.assertEqual(snapshot["freshness_code"], "provider_cached")
|
||||
self.assertEqual(snapshot["cache_state"], "cached_fallback")
|
||||
self.assertIsNone(snapshot["error"])
|
||||
self.assertIn("ssl eof", snapshot["warning"])
|
||||
|
||||
|
||||
class _SnapshotRepo:
|
||||
def __init__(self, row: dict) -> None:
|
||||
self.row = row
|
||||
|
||||
def fetch_news_provider_snapshot(self, provider_name: str):
|
||||
if provider_name == self.row.get("provider_name"):
|
||||
return dict(self.row)
|
||||
return None
|
||||
|
||||
def upsert_news_provider_snapshot(self, **kwargs):
|
||||
self.row = dict(kwargs)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user