Add external chart adapter with 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 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
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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()
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user