diff --git a/SESSION.md b/SESSION.md index 7a59f83..4e4b16a 100644 --- a/SESSION.md +++ b/SESSION.md @@ -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 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. +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 implemented E9 as a real external-chart batch: a new Binance public chart reference provider now supplies symbol/interval/chart URL metadata, persists provider snapshots for cache fallback, and upgrades the former optional placeholder into a true external read-only adapter with `live_reference` / `cached_fallback` / `empty_fallback` modes. Local validation passed and direct remote field smoke confirmed the adapter fields, but the full remote ops closure is still blocked by an unrelated remote `broker_sync` environment error. ## Current Branch @@ -135,6 +135,7 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - 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_external_chart_adapter_slice: binance_public_chart_reference - latest_decision_assistant_summary_status: implemented - latest_decision_assistant_change_summary_status: implemented - latest_decision_assistant_evidence_status: implemented @@ -254,10 +255,11 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_decision_assistant_workflow_archive_status: implemented - latest_decision_assistant_focus_workspace_status: implemented - latest_decision_assistant_exchange_hardening_status: implemented +- latest_decision_assistant_external_chart_adapter_status: implemented_remote_field_smoke_passed - 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: exchange_context_fallback_visible +- latest_remote_decision_assistant_review_packet_status: external_chart_adapter_field_smoke_passed - latest_p7_selector_choice: continue_periodic_analysis_decision_monitoring_with_maturity_rollup - latest_p14_slice: webhook_transport_retry - latest_p15_pxx_tasklist_status: ready @@ -676,11 +678,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - 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`. +- Latest E9 implementation passed local validation: 72 related unit/API/static tests, 720 full unit tests, 73 script static checks, `node --check src/web/app.js`, and direct remote field smoke confirmed `chart_url`, `symbol=BTC-USDT`, `interval=1m`, and adapter mode `empty_fallback`. - Real-live order submission remains intentionally disabled because the OKX order adapter is still not a real-live submitter. ## Exact Next Step -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. +E9 external-chart adapter is implemented, but this turn stopped at a real remote blocker outside the adapter itself: `broker_sync_degraded:BTC-USDT:okx_read_transport_error:` is still causing `unattended_harness` to fail even though direct field smoke for the chart adapter passes. PXX remains closed and you still must not create P25/P26 automatically or enable real trading. ## Validation Commands Used Recently diff --git a/TASKS.md b/TASKS.md index 2670726..9538db2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -165,6 +165,7 @@ - [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] E9 external chart public adapter / fallback mode slice - [x] P8 local closure complete - [x] P9 local closure complete - [x] P10 local closure complete @@ -293,6 +294,7 @@ - [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_external_chart_adapter_slice: binance_public_chart_reference - [x] latest_decision_assistant_summary_status: implemented - [x] latest_decision_assistant_change_summary_status: implemented - [x] latest_decision_assistant_evidence_status: implemented @@ -409,10 +411,11 @@ - [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_external_chart_adapter_status: implemented_remote_field_smoke_passed - [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: exchange_context_fallback_visible +- [x] latest_remote_decision_assistant_review_packet_status: external_chart_adapter_field_smoke_passed - [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 @@ -730,9 +733,13 @@ - [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` +- [x] Completed E9 local validation: external chart provider tests, 72 related unit/API/static tests, 720 full unit tests, and 73 script static checks +- [x] Deployed E9 current tree and passed direct remote field smoke for `chart_url`, `symbol`, `interval`, and adapter fallback mode ## Blocked +- [ ] Remote unattended harness is still blocked by `broker_sync_degraded:BTC-USDT:okx_read_transport_error:` after E9 deploy; this is outside the chart adapter logic itself. + - [x] External chart adapter is optional in the current analysis-only phase and no external chart URL is required for the main loop. ## Standing Guardrails diff --git a/src/services/market_state/external_chart_provider.py b/src/services/market_state/external_chart_provider.py new file mode 100644 index 0000000..4ca452e --- /dev/null +++ b/src/services/market_state/external_chart_provider.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from os import getenv +from typing import Optional + +import httpx + +from src.config.settings import settings +from src.repositories.market_state_repository import MarketStateRepository + + +DEFAULT_EXTERNAL_CHART_API_URL = "https://api.binance.com/api/v3/klines" +DEFAULT_EXTERNAL_CHART_WEB_BASE_URL = "https://www.binance.com/en/trade" + + +class BinanceChartReferenceProvider: + provider_name = "binance_public_chart_reference" + + def __init__( + self, + *, + api_url: Optional[str] = None, + web_base_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.api_url = api_url or DEFAULT_EXTERNAL_CHART_API_URL + self.web_base_url = (web_base_url or DEFAULT_EXTERNAL_CHART_WEB_BASE_URL).rstrip("/") + 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, + *, + instrument_id: int, + timeframe: str, + symbol_hint: Optional[str] = None, + now: Optional[datetime] = None, + payload: Optional[list] = None, + ) -> dict: + observed_now = now or datetime.now(timezone.utc) + symbol = symbol_hint or self._resolve_symbol(instrument_id) or "BTC-USDT" + api_symbol = _normalize_symbol(symbol) + interval = _normalize_interval(timeframe) + chart_url = f"{self.web_base_url}/{api_symbol.replace('USDT', '_USDT')}?type=spot" + provider_key = f"{self.provider_name}:{api_symbol}:{interval}" + try: + data = list(payload or self._fetch_payload(api_symbol=api_symbol, interval=interval)) + last_candle = list(data[-1] or []) if data else [] + snapshot = { + "provider_name": self.provider_name, + "provider_key": provider_key, + "fetched_ts": observed_now.isoformat(), + "status": "active" if data else "idle", + "freshness_code": "provider_fresh", + "cache_state": "fresh", + "symbol": symbol, + "api_symbol": api_symbol, + "interval": interval, + "chart_url": chart_url, + "candle_count": len(data), + "reference_price": _to_float(last_candle[4] if len(last_candle) > 4 else None), + "reference_ts": _to_ts(last_candle[6] if len(last_candle) > 6 else None), + "error": None, + "warning": None, + "raw_payload": {"data": data[:2]}, + } + self._persist_snapshot(snapshot) + return snapshot + except Exception as exc: + cached = self._cached_snapshot(provider_key=provider_key, observed_now=observed_now, warning=str(exc)) + if cached is not None: + return cached + return { + "provider_name": self.provider_name, + "provider_key": provider_key, + "fetched_ts": observed_now.isoformat(), + "status": "idle", + "freshness_code": "provider_fallback_empty", + "cache_state": "empty_fallback", + "symbol": symbol, + "api_symbol": api_symbol, + "interval": interval, + "chart_url": chart_url, + "candle_count": 0, + "reference_price": None, + "reference_ts": None, + "error": None, + "warning": str(exc), + "raw_payload": {}, + } + + def _resolve_symbol(self, instrument_id: int) -> str | None: + instrument = self.repository.fetch_instrument_by_id(instrument_id) + return str((instrument or {}).get("symbol") or "").strip() or None + + def _fetch_payload(self, *, api_symbol: str, interval: str) -> list: + attempt_timeout = max(1, min(int(self.timeout or 1), 4)) + attempts = [] + if self.proxy_url: + attempts.append({"timeout": attempt_timeout, "follow_redirects": True, "proxy": self.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( + self.api_url, + params={"symbol": api_symbol, "interval": interval, "limit": 2}, + headers={"User-Agent": "ai-ict/external-chart"}, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, list): + raise ValueError("unsupported_external_chart_payload") + return payload + except Exception as exc: + errors.append(str(exc)) + raise RuntimeError(" | ".join(errors)) + + def _persist_snapshot(self, snapshot: dict) -> None: + try: + self.repository.upsert_news_provider_snapshot( + provider_name=snapshot.get("provider_key") or self.provider_name, + fetched_ts=snapshot.get("fetched_ts"), + freshness_code=snapshot.get("freshness_code") or "provider_fresh", + event_count=int(snapshot.get("candle_count") or 0), + is_active=bool(snapshot.get("status") == "active"), + code=str(snapshot.get("status") or "idle"), + active_events=[ + { + "symbol": snapshot.get("symbol"), + "interval": snapshot.get("interval"), + "chart_url": snapshot.get("chart_url"), + "reference_price": snapshot.get("reference_price"), + "reference_ts": snapshot.get("reference_ts"), + } + ], + error=snapshot.get("error"), + raw_payload=dict(snapshot.get("raw_payload") or {}), + ) + except Exception: + return + + def _cached_snapshot(self, *, provider_key: str, observed_now: datetime, warning: str) -> dict | None: + try: + row = self.repository.fetch_news_provider_snapshot(provider_key) + 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 + event = dict((row.get("active_events") or [{}])[0] or {}) + return { + "provider_name": self.provider_name, + "provider_key": provider_key, + "fetched_ts": fetched_ts.isoformat(), + "status": str(row.get("code") or "idle"), + "freshness_code": "provider_cached", + "cache_state": "cached_fallback", + "symbol": event.get("symbol"), + "api_symbol": _normalize_symbol(str(event.get("symbol") or "")) if event.get("symbol") else None, + "interval": event.get("interval"), + "chart_url": event.get("chart_url"), + "candle_count": int(row.get("event_count") or 0), + "reference_price": event.get("reference_price"), + "reference_ts": event.get("reference_ts"), + "error": None, + "warning": warning, + "raw_payload": dict(row.get("raw_payload") or {}), + } + + +def _normalize_symbol(symbol: str) -> str: + return str(symbol or "BTC-USDT").replace("-", "").replace("/", "").upper() + + +def _normalize_interval(timeframe: str) -> str: + text = str(timeframe or "1h").strip().lower() + mapping = { + "1m": "1m", + "5m": "5m", + "15m": "15m", + "1h": "1h", + "4h": "4h", + "1d": "1d", + } + return mapping.get(text, "1h") + + +def _to_float(value) -> float | None: + try: + return float(value) + except Exception: + return None + + +def _to_ts(value) -> str | None: + try: + if value in (None, ""): + return None + return datetime.fromtimestamp(int(value) / 1000, tz=timezone.utc).isoformat() + except Exception: + return None + + +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) diff --git a/src/services/operator/operator_workspace_service.py b/src/services/operator/operator_workspace_service.py index 66f9ffe..cdb0d92 100644 --- a/src/services/operator/operator_workspace_service.py +++ b/src/services/operator/operator_workspace_service.py @@ -14,6 +14,7 @@ from src.services.decision import DecisionPacketService from src.services.execution import ExecutionService from src.services.journal import WeeklyReviewService from src.services.alerts import AlertQualitySummaryService, OperatorDigestService +from src.services.market_state.external_chart_provider import BinanceChartReferenceProvider from src.services.market_state.exchange_status_provider import OkxAnnouncementsProvider, OkxSystemStatusProvider from src.services.readiness import LaunchReadinessService from src.services.research_council import CouncilOptimizationReviewService, ResearchCouncilService @@ -130,6 +131,7 @@ class DecisionAssistantSummaryService: research_council_service: Optional[ResearchCouncilService] = None, exchange_status_provider: Optional[OkxSystemStatusProvider] = None, announcements_provider: Optional[OkxAnnouncementsProvider] = None, + external_chart_provider: Optional[BinanceChartReferenceProvider] = None, decision_assistant_repository: Optional[DecisionAssistantRepository] = None, ) -> None: self.launch_readiness_service = launch_readiness_service or LaunchReadinessService() @@ -140,6 +142,7 @@ class DecisionAssistantSummaryService: self.research_council_service = research_council_service or ResearchCouncilService() self.exchange_status_provider = exchange_status_provider or OkxSystemStatusProvider() self.announcements_provider = announcements_provider or OkxAnnouncementsProvider() + self.external_chart_provider = external_chart_provider or BinanceChartReferenceProvider() self.decision_assistant_repository = decision_assistant_repository or DecisionAssistantRepository() def build_summary( @@ -160,6 +163,16 @@ class DecisionAssistantSummaryService: recent_candidates = self.candidate_repository.fetch_recent_candidates(instrument_id=instrument_id, limit=20) exchange_status = self.exchange_status_provider.fetch_snapshot() announcements_snapshot = self.announcements_provider.fetch_snapshot() + symbol_hint = _decision_assistant_symbol_hint( + decision_packet=dict(decision_packet or {}), + previous_packet=dict(previous_packet or {}), + recent_candidates=list(recent_candidates or []), + ) + external_chart_snapshot = self.external_chart_provider.fetch_snapshot( + instrument_id=instrument_id, + timeframe=timeframe, + symbol_hint=symbol_hint, + ) fallback_session_code = str( (((launch_readiness.get("market_state") or {}).get("session") or {}).get("session_code") or "") ) @@ -194,6 +207,7 @@ class DecisionAssistantSummaryService: recent_candidates=list(recent_candidates or []), exchange_status=dict(exchange_status or {}), announcements_snapshot=dict(announcements_snapshot or {}), + external_chart_snapshot=dict(external_chart_snapshot or {}), setup_learning_rollup=dict(setup_learning_rollup or {}), recent_alerts=list(recent_alerts or []), recent_notes=list(recent_notes or []), @@ -416,6 +430,22 @@ def _build_research_council_summary(run: Optional[dict]) -> dict: } +def _decision_assistant_symbol_hint(*, decision_packet: dict, previous_packet: dict, recent_candidates: list[dict]) -> str | None: + current_symbol = str((decision_packet.get("symbol") or (decision_packet.get("evidence") or {}).get("symbol") or "")).strip() + if current_symbol: + return current_symbol + previous_payload = dict(previous_packet.get("payload") or {}) + previous_symbol = str((previous_payload.get("symbol") or (previous_payload.get("evidence") or {}).get("symbol") or "")).strip() + if previous_symbol: + return previous_symbol + for candidate in recent_candidates: + evidence = dict(candidate.get("evidence") or {}) + candidate_symbol = str(evidence.get("symbol") or "").strip() + if candidate_symbol: + return candidate_symbol + return None + + def build_decision_assistant_summary( *, instrument_id: int, @@ -429,6 +459,7 @@ def build_decision_assistant_summary( recent_candidates: list[dict], exchange_status: dict, announcements_snapshot: dict, + external_chart_snapshot: dict, setup_learning_rollup: dict, recent_alerts: list[dict], recent_notes: list[dict], @@ -552,6 +583,7 @@ def build_decision_assistant_summary( decision_packet=decision_packet, exchange_status=exchange_status, announcements_snapshot=announcements_snapshot, + external_chart_snapshot=external_chart_snapshot, ) assistant_top_opportunities = [item for item in top_priority_items if str(item.get("kind") or "") in {"decision_packet", "session_focus"}][:3] review_entrypoints = _build_decision_assistant_review_entrypoints( @@ -1859,6 +1891,7 @@ def _build_decision_assistant_external_context_summary( decision_packet: dict, exchange_status: dict, announcements_snapshot: dict, + external_chart_snapshot: dict, ) -> dict: market_state = dict(launch_readiness.get("market_state") or {}) news_window = dict(market_state.get("news_window") or market_state.get("news_state") or {}) @@ -1885,6 +1918,7 @@ def _build_decision_assistant_external_context_summary( news_window=news_window, exchange_status=exchange_status, announcements_snapshot=announcements_snapshot, + external_chart_snapshot=external_chart_snapshot, ) chart_stale_source_evidence = _build_external_context_chart_stale_source_evidence(chart_readonly_sources) chart_adapter_mode = _external_context_chart_adapter_mode(chart_readonly_sources) @@ -3313,6 +3347,7 @@ def _build_external_context_chart_readonly_sources( news_window: dict, exchange_status: dict, announcements_snapshot: dict, + external_chart_snapshot: dict, ) -> list[dict]: sources = [ { @@ -3342,10 +3377,21 @@ def _build_external_context_chart_readonly_sources( { "source_id": "external_chart_adapter", "source_type": "external_chart_readonly", - "status": "optional_reference", - "freshness_status": "reference_optional_not_configured", - "stale_source_evidence": None, - "prompt": "Optional external chart reference is not configured; internal review packet chart remains primary.", + "status": external_chart_snapshot.get("status") or "idle", + "freshness_status": external_chart_snapshot.get("freshness_code") or "provider_fallback_empty", + "stale_source_evidence": external_chart_snapshot.get("warning"), + "provider_name": external_chart_snapshot.get("provider_name"), + "chart_url": external_chart_snapshot.get("chart_url"), + "symbol": external_chart_snapshot.get("symbol"), + "interval": external_chart_snapshot.get("interval"), + "reference_price": external_chart_snapshot.get("reference_price"), + "reference_ts": external_chart_snapshot.get("reference_ts"), + "cache_state": external_chart_snapshot.get("cache_state"), + "prompt": ( + "Use the external chart adapter as a secondary read-only chart reference." + if external_chart_snapshot.get("chart_url") + else "External chart reference is unavailable; internal review packet chart remains primary." + ), }, ] return sources @@ -3383,19 +3429,25 @@ def _external_context_chart_adapter_mode(chart_readonly_sources: list[dict]) -> ), {}, ) - return str(external_source.get("status") or "not_present") + status = str(external_source.get("status") or "not_present") + cache_state = str(external_source.get("cache_state") or "") + if status == "active" and cache_state == "fresh": + return "live_reference" + if status in {"active", "idle"} and cache_state in {"cached_fallback", "empty_fallback"}: + return cache_state + return status def _external_context_chart_context_prompt(chart_readonly_sources: list[dict]) -> str: stale_count = len(_build_external_context_chart_stale_source_evidence(chart_readonly_sources)) if any( str(source.get("source_type") or "") == "external_chart_readonly" - and str(source.get("status") or "") == "optional_reference" + and str(source.get("status") or "") in {"active", "idle"} for source in chart_readonly_sources ): if stale_count: return f"Primary internal chart context is available; {stale_count} supporting chart context source(s) still need freshness review." - return "Primary internal chart context is available; external chart reference remains optional and is not required for analysis." + return "Primary internal chart context is available; external chart reference is live and remains optional for analysis." if stale_count: return f"{stale_count} chart context source(s) need freshness review before use." return "All chart context sources are available for read-only analysis." diff --git a/tests/unit/test_external_chart_provider.py b/tests/unit/test_external_chart_provider.py new file mode 100644 index 0000000..95b8902 --- /dev/null +++ b/tests/unit/test_external_chart_provider.py @@ -0,0 +1,100 @@ +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.external_chart_provider import BinanceChartReferenceProvider + + +class ExternalChartProviderTests(unittest.TestCase): + def test_provider_builds_live_snapshot_from_payload(self) -> None: + provider = BinanceChartReferenceProvider(repository=_ChartSnapshotRepo()) + + snapshot = provider.fetch_snapshot( + instrument_id=1, + timeframe="1h", + symbol_hint="BTC-USDT", + now=datetime(2026, 5, 14, 4, 0, tzinfo=timezone.utc), + payload=[ + [0, "100", "110", "90", "105", "10", 1715659200000], + [1, "105", "115", "95", "111", "12", 1715662800000], + ], + ) + + self.assertEqual(snapshot["status"], "active") + self.assertEqual(snapshot["cache_state"], "fresh") + self.assertEqual(snapshot["symbol"], "BTC-USDT") + self.assertEqual(snapshot["interval"], "1h") + self.assertIn("binance.com", snapshot["chart_url"]) + self.assertEqual(snapshot["reference_price"], 111.0) + + def test_provider_uses_cached_snapshot_when_fetch_fails(self) -> None: + repository = _ChartSnapshotRepo( + { + "provider_name": "binance_public_chart_reference:BTCUSDT:1h", + "fetched_ts": "2026-05-14T02:00:00+00:00", + "event_count": 1, + "code": "active", + "active_events": [ + { + "symbol": "BTC-USDT", + "interval": "1h", + "chart_url": "https://www.binance.com/en/trade/BTC_USDT?type=spot", + "reference_price": 111.0, + "reference_ts": "2026-05-14T02:00:00+00:00", + } + ], + "raw_payload": {"data": []}, + } + ) + provider = BinanceChartReferenceProvider(repository=repository, cache_max_age_seconds=21600) + provider._fetch_payload = lambda **kwargs: (_ for _ in ()).throw(RuntimeError("api down")) # type: ignore[attr-defined] + + snapshot = provider.fetch_snapshot( + instrument_id=1, + timeframe="1h", + symbol_hint="BTC-USDT", + now=datetime(2026, 5, 14, 2, 30, tzinfo=timezone.utc), + ) + + self.assertEqual(snapshot["status"], "active") + self.assertEqual(snapshot["cache_state"], "cached_fallback") + self.assertIn("api down", snapshot["warning"]) + + def test_provider_returns_empty_fallback_without_cache(self) -> None: + provider = BinanceChartReferenceProvider(repository=_ChartSnapshotRepo()) + provider._fetch_payload = lambda **kwargs: (_ for _ in ()).throw(RuntimeError("api down")) # type: ignore[attr-defined] + + snapshot = provider.fetch_snapshot( + instrument_id=1, + timeframe="1h", + symbol_hint="BTC-USDT", + now=datetime(2026, 5, 14, 2, 30, tzinfo=timezone.utc), + ) + + self.assertEqual(snapshot["status"], "idle") + self.assertEqual(snapshot["cache_state"], "empty_fallback") + self.assertIn("binance.com", snapshot["chart_url"]) + self.assertIsNone(snapshot["error"]) + + +class _ChartSnapshotRepo: + def __init__(self, row: dict | None = None) -> None: + self.row = row or {} + + def fetch_instrument_by_id(self, instrument_id: int): + return {"id": instrument_id, "symbol": "BTC-USDT", "active": True} + + def fetch_news_provider_snapshot(self, provider_name: str): + if self.row and 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() diff --git a/tests/unit/test_operator_workspace_service.py b/tests/unit/test_operator_workspace_service.py index eb0866c..585a109 100644 --- a/tests/unit/test_operator_workspace_service.py +++ b/tests/unit/test_operator_workspace_service.py @@ -98,6 +98,7 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): research_council_service=_ResearchCouncilSummaryService(), exchange_status_provider=_ExchangeStatusProvider(), announcements_provider=_AnnouncementsProvider(), + external_chart_provider=_ExternalChartProvider(), decision_assistant_repository=_DecisionAssistantRepo(), ) @@ -589,9 +590,9 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("chart_stale_source_evidence", summary["external_context_summary"]) self.assertGreaterEqual(summary["external_context_summary"]["chart_readonly_source_count"], 1) self.assertEqual(summary["external_context_summary"]["chart_stale_source_count"], 0) - self.assertEqual(summary["external_context_summary"]["chart_adapter_mode"], "optional_reference") + self.assertEqual(summary["external_context_summary"]["chart_adapter_mode"], "live_reference") self.assertEqual(summary["external_context_summary"]["chart_primary_mode"], "internal_review_packet_html") - self.assertIn("optional", summary["external_context_summary"]["chart_context_prompt"].lower()) + self.assertIn("live", summary["external_context_summary"]["chart_context_prompt"].lower()) self.assertIn("CPI", summary["external_context_summary"]["headline"]) self.assertTrue(summary["no_trade_reason_items"]) self.assertIn("watch_london_open", summary["next_actions"]) @@ -860,6 +861,27 @@ class _AnnouncementsProvider: } +class _ExternalChartProvider: + def fetch_snapshot(self, **kwargs): + return { + "provider_name": "binance_public_chart_reference", + "provider_key": "binance_public_chart_reference:BTCUSDT:5m", + "status": "active", + "freshness_code": "provider_fresh", + "cache_state": "fresh", + "symbol": "BTC-USDT", + "api_symbol": "BTCUSDT", + "interval": "5m", + "chart_url": "https://www.binance.com/en/trade/BTC_USDT?type=spot", + "candle_count": 2, + "reference_price": 111.0, + "reference_ts": "2026-05-12T02:00:00+00:00", + "error": None, + "warning": None, + "raw_payload": {"data": []}, + } + + class _DecisionAssistantRepo: def fetch_preference(self, **kwargs): return {