Add external chart adapter with fallbacks

This commit is contained in:
Codex
2026-05-14 07:52:58 +08:00
parent d4232501ae
commit ab031b50fe
6 changed files with 419 additions and 13 deletions
+6 -3
View File
@@ -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:<urlopen error [Errno 111] Connection refused>` 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
+8 -1
View File
@@ -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:<urlopen error [Errno 111] Connection refused>` 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
@@ -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)
@@ -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."
+100
View File
@@ -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()
+24 -2
View File
@@ -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 {