Add event-triggered monitoring and durable private history
Browse files- .env.example +11 -0
- app/agents/market_research_agent.py +68 -7
- app/agents/orchestrator.py +19 -4
- app/agents/sentiment_agent.py +2 -0
- app/api.py +20 -4
- app/monitor_worker.py +2 -48
- app/monitoring_service.py +87 -12
- app/monitoring_store.py +34 -3
- app/schemas/agent_models.py +2 -0
- app/services/agent_config.py +15 -3
- app/services/market_monitoring.py +82 -0
- app/services/monitor_schedule.py +10 -0
- app/services/monitor_worker_runtime.py +76 -0
- app/services/monitoring_backup.py +91 -0
- app/services/monitoring_events_store.py +111 -0
- app/services/research_triggers.py +100 -0
- app/space_server.py +1 -1
- app/static/monitor/analyst.css +2 -0
- app/static/monitor/analyst.js +1 -1
- app/static/monitor/dashboard.js +10 -0
- app/static/monitor/index.html +6 -0
- app/static/monitor/monitoring-events.js +45 -0
- requirements-agents.txt +1 -0
- scripts/check_space_bundle.py +4 -0
- tests/test_event_monitoring.py +282 -0
- tests/test_monitoring_persistence.py +131 -0
.env.example
CHANGED
|
@@ -15,3 +15,14 @@ ALPHA_VANTAGE_API_KEY=
|
|
| 15 |
PORT=7860
|
| 16 |
|
| 17 |
API_PORT=8000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
PORT=7860
|
| 16 |
|
| 17 |
API_PORT=8000
|
| 18 |
+
|
| 19 |
+
# Market scans are scheduled; paid research is event-triggered.
|
| 20 |
+
MONITOR_INTERVAL_MINUTES=60
|
| 21 |
+
# Optional: private dataset repository for Space history backups. Keep the token
|
| 22 |
+
# in local .env or Hugging Face Settings > Secrets, never in source control.
|
| 23 |
+
MONITOR_BACKUP_REPO=
|
| 24 |
+
MONITOR_BACKUP_TOKEN=
|
| 25 |
+
PRICE_MOVE_TRIGGER_PCT=1.0
|
| 26 |
+
VOLATILITY_TRIGGER_MULTIPLIER=1.5
|
| 27 |
+
SENTIMENT_SHIFT_THRESHOLD=0.25
|
| 28 |
+
RESEARCH_COOLDOWN_MINUTES=30
|
app/agents/market_research_agent.py
CHANGED
|
@@ -11,6 +11,7 @@ import math
|
|
| 11 |
import re
|
| 12 |
import threading
|
| 13 |
import time
|
|
|
|
| 14 |
import warnings
|
| 15 |
from urllib.parse import parse_qsl, unquote, urlencode, urlsplit, urlunsplit
|
| 16 |
|
|
@@ -18,6 +19,7 @@ from dateutil import parser as date_parser
|
|
| 18 |
|
| 19 |
from app.schemas.agent_models import EvidenceItem, ResearchResult
|
| 20 |
from app.services.tavily_service import TavilyService, prediction_cutoff, unavailable
|
|
|
|
| 21 |
|
| 22 |
PRIMARY = {
|
| 23 |
"federalreserve.gov": "Federal Reserve", "sec.gov": "SEC", "treasury.gov": "US Treasury",
|
|
@@ -161,15 +163,30 @@ def evidence_category(headline, snippet):
|
|
| 161 |
|
| 162 |
|
| 163 |
class MarketResearchAgent:
|
| 164 |
-
def __init__(self, settings, tavily=None):
|
| 165 |
self.settings = settings
|
| 166 |
self.tavily = tavily if tavily is not None else TavilyService(settings)
|
| 167 |
self._cache = OrderedDict()
|
| 168 |
self._inflight = {}
|
| 169 |
self._guard = threading.Lock()
|
|
|
|
| 170 |
|
| 171 |
@staticmethod
|
| 172 |
-
def query_plan(question, mode="live"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
text = str(question).lower()[:2000]
|
| 174 |
chosen = [query for pattern, query in TOPICS if re.search(pattern, text)][:2]
|
| 175 |
for _, query in TOPICS[:2]:
|
|
@@ -180,11 +197,19 @@ class MarketResearchAgent:
|
|
| 180 |
prefix = "Bitcoin BTC latest market news price drivers" if mode == "live" else "Bitcoin BTC market news price drivers"
|
| 181 |
return [prefix] + chosen
|
| 182 |
|
| 183 |
-
def _search(self, query, mode, cutoff):
|
| 184 |
key = (query, mode, cutoff.isoformat() if cutoff else None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
with self._guard:
|
| 186 |
entry = self._cache.get(key)
|
| 187 |
-
if entry and entry[0] > time.monotonic():
|
| 188 |
self._cache.move_to_end(key)
|
| 189 |
return deepcopy(entry[1]), True
|
| 190 |
future = self._inflight.get(key)
|
|
@@ -197,6 +222,27 @@ class MarketResearchAgent:
|
|
| 197 |
return deepcopy(future.result(timeout=self.settings.tavily_timeout_seconds * 2 + 5)), True
|
| 198 |
except FutureTimeout:
|
| 199 |
return {**unavailable("The shared research request is still in progress."), "retrieved_at": _now()}, False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
try:
|
| 201 |
payload = self.tavily.search(query, mode=mode, prediction_timestamp=cutoff)
|
| 202 |
if not isinstance(payload, dict) or payload.get("status") not in ("ok", "empty", "unavailable"):
|
|
@@ -207,6 +253,21 @@ class MarketResearchAgent:
|
|
| 207 |
ttl = max(0., self.settings.news_cache_minutes * 60)
|
| 208 |
if payload["status"] == "unavailable":
|
| 209 |
ttl = min(ttl, 30.)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
with self._guard:
|
| 211 |
self._cache[key] = (time.monotonic() + ttl, deepcopy(payload))
|
| 212 |
self._cache.move_to_end(key)
|
|
@@ -248,17 +309,17 @@ class MarketResearchAgent:
|
|
| 248 |
direction=headline_direction(title), category=evidence_category(title, content),
|
| 249 |
source_quality=quality, supports_model_direction=None, recency_verified=False)
|
| 250 |
|
| 251 |
-
def run(self, question, mode="live", prediction_timestamp=None):
|
| 252 |
if mode not in ("live", "historical"):
|
| 253 |
return ResearchResult(status="unavailable", reason="Unsupported research mode.")
|
| 254 |
cutoff = prediction_cutoff(prediction_timestamp) if mode == "historical" else None
|
| 255 |
if mode == "historical" and cutoff is None:
|
| 256 |
return ResearchResult(status="unavailable", reason="Historical research requires a timezone-aware prediction timestamp.")
|
| 257 |
-
queries = self.query_plan(question, mode)
|
| 258 |
payloads, cached_flags, evidence = [], [], []
|
| 259 |
rejected = 0
|
| 260 |
for query in queries:
|
| 261 |
-
payload, cached = self._search(query, mode, cutoff)
|
| 262 |
payloads.append(payload)
|
| 263 |
cached_flags.append(cached)
|
| 264 |
results = payload.get("results", [])
|
|
|
|
| 11 |
import re
|
| 12 |
import threading
|
| 13 |
import time
|
| 14 |
+
import uuid
|
| 15 |
import warnings
|
| 16 |
from urllib.parse import parse_qsl, unquote, urlencode, urlsplit, urlunsplit
|
| 17 |
|
|
|
|
| 19 |
|
| 20 |
from app.schemas.agent_models import EvidenceItem, ResearchResult
|
| 21 |
from app.services.tavily_service import TavilyService, prediction_cutoff, unavailable
|
| 22 |
+
from app.services.agent_config import redact_values
|
| 23 |
|
| 24 |
PRIMARY = {
|
| 25 |
"federalreserve.gov": "Federal Reserve", "sec.gov": "SEC", "treasury.gov": "US Treasury",
|
|
|
|
| 163 |
|
| 164 |
|
| 165 |
class MarketResearchAgent:
|
| 166 |
+
def __init__(self, settings, tavily=None, store=None):
|
| 167 |
self.settings = settings
|
| 168 |
self.tavily = tavily if tavily is not None else TavilyService(settings)
|
| 169 |
self._cache = OrderedDict()
|
| 170 |
self._inflight = {}
|
| 171 |
self._guard = threading.Lock()
|
| 172 |
+
self.store = store
|
| 173 |
|
| 174 |
@staticmethod
|
| 175 |
+
def query_plan(question, mode="live", trigger_types=None):
|
| 176 |
+
if trigger_types:
|
| 177 |
+
topics = []
|
| 178 |
+
rules = {
|
| 179 |
+
"large_price_move": [TOPICS[2][1], TOPICS[0][1]],
|
| 180 |
+
"unusual_volatility": [TOPICS[2][1], TOPICS[1][1]],
|
| 181 |
+
"regime_change": [TOPICS[1][1], TOPICS[2][1]],
|
| 182 |
+
"sentiment_shift": [TOPICS[0][1], TOPICS[3][1]],
|
| 183 |
+
"forecast_state_change": [TOPICS[0][1], TOPICS[1][1]],
|
| 184 |
+
}
|
| 185 |
+
for reason in trigger_types:
|
| 186 |
+
for query in rules.get(reason, []):
|
| 187 |
+
if query not in topics:
|
| 188 |
+
topics.append(query)
|
| 189 |
+
return ["Bitcoin BTC latest market news price drivers"] + topics[:2]
|
| 190 |
text = str(question).lower()[:2000]
|
| 191 |
chosen = [query for pattern, query in TOPICS if re.search(pattern, text)][:2]
|
| 192 |
for _, query in TOPICS[:2]:
|
|
|
|
| 197 |
prefix = "Bitcoin BTC latest market news price drivers" if mode == "live" else "Bitcoin BTC market news price drivers"
|
| 198 |
return [prefix] + chosen
|
| 199 |
|
| 200 |
+
def _search(self, query, mode, cutoff, fresh=False):
|
| 201 |
key = (query, mode, cutoff.isoformat() if cutoff else None)
|
| 202 |
+
disk_key = hashlib.sha256(repr((key, self.settings.news_max_age_hours)).encode()).hexdigest()
|
| 203 |
+
if self.store and not fresh:
|
| 204 |
+
try:
|
| 205 |
+
disk = self.store.cache_get(disk_key)
|
| 206 |
+
except Exception:
|
| 207 |
+
return {**unavailable("The shared research cache is unavailable."), "retrieved_at": _now()}, False
|
| 208 |
+
if disk is not None:
|
| 209 |
+
return disk, True
|
| 210 |
with self._guard:
|
| 211 |
entry = self._cache.get(key)
|
| 212 |
+
if not fresh and entry and entry[0] > time.monotonic():
|
| 213 |
self._cache.move_to_end(key)
|
| 214 |
return deepcopy(entry[1]), True
|
| 215 |
future = self._inflight.get(key)
|
|
|
|
| 222 |
return deepcopy(future.result(timeout=self.settings.tavily_timeout_seconds * 2 + 5)), True
|
| 223 |
except FutureTimeout:
|
| 224 |
return {**unavailable("The shared research request is still in progress."), "retrieved_at": _now()}, False
|
| 225 |
+
lease_owner = uuid.uuid4().hex
|
| 226 |
+
if self.store:
|
| 227 |
+
acquired, disk = False, None
|
| 228 |
+
try:
|
| 229 |
+
acquired = self.store.acquire("query:" + disk_key, lease_owner, ttl=self.settings.tavily_timeout_seconds + 30)
|
| 230 |
+
# Another process may have filled the cache after our first read.
|
| 231 |
+
if not fresh or not acquired:
|
| 232 |
+
disk = self.store.cache_get(disk_key)
|
| 233 |
+
except Exception:
|
| 234 |
+
acquired = False
|
| 235 |
+
if disk is not None or not acquired:
|
| 236 |
+
payload = disk or {**unavailable("The shared research request is in progress or unavailable."), "retrieved_at": _now()}
|
| 237 |
+
if acquired:
|
| 238 |
+
try:
|
| 239 |
+
self.store.release("query:" + disk_key, lease_owner)
|
| 240 |
+
except Exception:
|
| 241 |
+
pass
|
| 242 |
+
with self._guard:
|
| 243 |
+
self._inflight.pop(key, None)
|
| 244 |
+
future.set_result(deepcopy(payload))
|
| 245 |
+
return payload, True
|
| 246 |
try:
|
| 247 |
payload = self.tavily.search(query, mode=mode, prediction_timestamp=cutoff)
|
| 248 |
if not isinstance(payload, dict) or payload.get("status") not in ("ok", "empty", "unavailable"):
|
|
|
|
| 253 |
ttl = max(0., self.settings.news_cache_minutes * 60)
|
| 254 |
if payload["status"] == "unavailable":
|
| 255 |
ttl = min(ttl, 30.)
|
| 256 |
+
if self.store:
|
| 257 |
+
try:
|
| 258 |
+
# Retain bounded snippets only; do not persist whole article bodies.
|
| 259 |
+
disk_payload = deepcopy(payload)
|
| 260 |
+
disk_payload["results"] = [{k: (v[:1000] if isinstance(v, str) else v)
|
| 261 |
+
for k, v in item.items() if k in {"title", "url", "content", "score", "published_at", "published_date"}}
|
| 262 |
+
for item in payload.get("results", [])[:5] if isinstance(item, dict)]
|
| 263 |
+
self.store.cache_put(disk_key, redact_values(disk_payload), ttl)
|
| 264 |
+
except Exception:
|
| 265 |
+
pass # A cache write failure must not strand in-flight waiters.
|
| 266 |
+
finally:
|
| 267 |
+
try:
|
| 268 |
+
self.store.release("query:" + disk_key, lease_owner)
|
| 269 |
+
except Exception:
|
| 270 |
+
pass # The lease expires; waiting callers still receive a result.
|
| 271 |
with self._guard:
|
| 272 |
self._cache[key] = (time.monotonic() + ttl, deepcopy(payload))
|
| 273 |
self._cache.move_to_end(key)
|
|
|
|
| 309 |
direction=headline_direction(title), category=evidence_category(title, content),
|
| 310 |
source_quality=quality, supports_model_direction=None, recency_verified=False)
|
| 311 |
|
| 312 |
+
def run(self, question, mode="live", prediction_timestamp=None, fresh=False, trigger_types=None):
|
| 313 |
if mode not in ("live", "historical"):
|
| 314 |
return ResearchResult(status="unavailable", reason="Unsupported research mode.")
|
| 315 |
cutoff = prediction_cutoff(prediction_timestamp) if mode == "historical" else None
|
| 316 |
if mode == "historical" and cutoff is None:
|
| 317 |
return ResearchResult(status="unavailable", reason="Historical research requires a timezone-aware prediction timestamp.")
|
| 318 |
+
queries = self.query_plan(question, mode, trigger_types)
|
| 319 |
payloads, cached_flags, evidence = [], [], []
|
| 320 |
rejected = 0
|
| 321 |
for query in queries:
|
| 322 |
+
payload, cached = self._search(query, mode, cutoff, fresh=fresh)
|
| 323 |
payloads.append(payload)
|
| 324 |
cached_flags.append(cached)
|
| 325 |
results = payload.get("results", [])
|
app/agents/orchestrator.py
CHANGED
|
@@ -20,6 +20,8 @@ def needs_research(question):
|
|
| 20 |
if (re.search(r"(?:what|which).*(?:model|feature|input)|(?:does|is|are).*(?:model|feature|input).*(?:use|include|support)", text)
|
| 21 |
and not re.search(r"(?:latest|current).*(?:news|headline)|search|research", text)):
|
| 22 |
return False
|
|
|
|
|
|
|
| 23 |
if re.search(r"news|headlines|moving|market.moving|etf|fed\b|macro|inflation|cpi\b|employment|jobs report|regulat|liquidat|funding|open interest|whale|institution|bullish.*bearish|bearish.*bullish|factors|geopolit|hack", text):
|
| 24 |
return True
|
| 25 |
if (re.search(r"\b(?:btc|bitcoin)\b", text) and re.search(r"\b(?:why|today|now|current)\b", text)
|
|
@@ -32,7 +34,7 @@ class AgentOrchestrator:
|
|
| 32 |
self.monitoring = monitoring
|
| 33 |
self.settings = settings or AgentSettings.from_env()
|
| 34 |
self.forecast_agent = ForecastContextAgent(self.settings)
|
| 35 |
-
self.research_agent = research_agent or MarketResearchAgent(self.settings)
|
| 36 |
self.validation_agent = ValidationAgent(self.settings)
|
| 37 |
self.decision_agent = DecisionAgent(self.settings, self.validation_agent, llm=llm)
|
| 38 |
self._lock = threading.RLock()
|
|
@@ -68,6 +70,13 @@ class AgentOrchestrator:
|
|
| 68 |
def evidence(self):
|
| 69 |
with self._lock:
|
| 70 |
latest = self._evidence.model_copy(deep=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
# Revalidate cached evidence at read time; a source can age out while the
|
| 72 |
# dashboard stays open. No new provider calls are made by GET requests.
|
| 73 |
snapshot = copy.deepcopy(self.monitoring.snapshot())
|
|
@@ -92,12 +101,15 @@ class AgentOrchestrator:
|
|
| 92 |
snapshot = copy.deepcopy(self.monitoring.snapshot())
|
| 93 |
context = self.forecast_agent.run(snapshot)
|
| 94 |
self._set(request_id, forecast="done", research="running")
|
| 95 |
-
|
|
|
|
| 96 |
self._set(request_id, research="unavailable" if research.status == "unavailable" else "done", validation="running")
|
| 97 |
_, research, validation = self.validation_agent.validate(context, snapshot, research, mode=request.mode, prediction_timestamp=request.prediction_timestamp)
|
| 98 |
if request.mode == "live":
|
| 99 |
with self._lock:
|
| 100 |
self._evidence = research.model_copy(deep=True)
|
|
|
|
|
|
|
| 101 |
self._set(request_id, validation="done")
|
| 102 |
return {"research": research.model_dump(), "validation": validation.model_dump(), "request_id": request_id,
|
| 103 |
"agent_status": self.activity(request_id)["agent_status"]}
|
|
@@ -113,9 +125,10 @@ class AgentOrchestrator:
|
|
| 113 |
snapshot = copy.deepcopy(self.monitoring.snapshot())
|
| 114 |
context = self.forecast_agent.run(snapshot)
|
| 115 |
self._set(request_id, forecast="done")
|
| 116 |
-
if needs_research(question):
|
| 117 |
self._set(request_id, research="running")
|
| 118 |
-
|
|
|
|
| 119 |
self._set(request_id, research="unavailable" if research.status == "unavailable" else "done")
|
| 120 |
else:
|
| 121 |
research = ResearchResult()
|
|
@@ -125,6 +138,8 @@ class AgentOrchestrator:
|
|
| 125 |
if research.status != "not_requested":
|
| 126 |
with self._lock:
|
| 127 |
self._evidence = research.model_copy(deep=True)
|
|
|
|
|
|
|
| 128 |
self._set(request_id, validation="done", decision="running")
|
| 129 |
answer, llm_status, sources = self.decision_agent.run(question, history, context, research, validation)
|
| 130 |
self._set(request_id, decision="done" if llm_status == "ok" else "unavailable")
|
|
|
|
| 20 |
if (re.search(r"(?:what|which).*(?:model|feature|input)|(?:does|is|are).*(?:model|feature|input).*(?:use|include|support)", text)
|
| 21 |
and not re.search(r"(?:latest|current).*(?:news|headline)|search|research", text)):
|
| 22 |
return False
|
| 23 |
+
if re.search(r"why.*(?:model|predict|forecast).*(?:up|down)|what happened.*market|explain.*(?:latest|current).*mov|current events|external.*evidence|fresh.*research", text):
|
| 24 |
+
return True
|
| 25 |
if re.search(r"news|headlines|moving|market.moving|etf|fed\b|macro|inflation|cpi\b|employment|jobs report|regulat|liquidat|funding|open interest|whale|institution|bullish.*bearish|bearish.*bullish|factors|geopolit|hack", text):
|
| 26 |
return True
|
| 27 |
if (re.search(r"\b(?:btc|bitcoin)\b", text) and re.search(r"\b(?:why|today|now|current)\b", text)
|
|
|
|
| 34 |
self.monitoring = monitoring
|
| 35 |
self.settings = settings or AgentSettings.from_env()
|
| 36 |
self.forecast_agent = ForecastContextAgent(self.settings)
|
| 37 |
+
self.research_agent = research_agent or MarketResearchAgent(self.settings, store=getattr(monitoring, "event_store", None))
|
| 38 |
self.validation_agent = ValidationAgent(self.settings)
|
| 39 |
self.decision_agent = DecisionAgent(self.settings, self.validation_agent, llm=llm)
|
| 40 |
self._lock = threading.RLock()
|
|
|
|
| 70 |
def evidence(self):
|
| 71 |
with self._lock:
|
| 72 |
latest = self._evidence.model_copy(deep=True)
|
| 73 |
+
store = getattr(self.monitoring, "event_store", None)
|
| 74 |
+
if store:
|
| 75 |
+
saved = store.latest_event(with_evidence=True)
|
| 76 |
+
candidates = [saved["result"] if saved else None, self.monitoring.store.get_state("analyst_evidence")]
|
| 77 |
+
for candidate in candidates:
|
| 78 |
+
if candidate and (candidate.get("retrieved_at") or "") > (latest.retrieved_at or ""):
|
| 79 |
+
latest = ResearchResult.model_validate(candidate)
|
| 80 |
# Revalidate cached evidence at read time; a source can age out while the
|
| 81 |
# dashboard stays open. No new provider calls are made by GET requests.
|
| 82 |
snapshot = copy.deepcopy(self.monitoring.snapshot())
|
|
|
|
| 101 |
snapshot = copy.deepcopy(self.monitoring.snapshot())
|
| 102 |
context = self.forecast_agent.run(snapshot)
|
| 103 |
self._set(request_id, forecast="done", research="running")
|
| 104 |
+
options = {"fresh": True} if request.fresh_research else {}
|
| 105 |
+
research = self.research_agent.run(redact(request.question), mode=request.mode, prediction_timestamp=request.prediction_timestamp, **options)
|
| 106 |
self._set(request_id, research="unavailable" if research.status == "unavailable" else "done", validation="running")
|
| 107 |
_, research, validation = self.validation_agent.validate(context, snapshot, research, mode=request.mode, prediction_timestamp=request.prediction_timestamp)
|
| 108 |
if request.mode == "live":
|
| 109 |
with self._lock:
|
| 110 |
self._evidence = research.model_copy(deep=True)
|
| 111 |
+
if getattr(self.monitoring, "event_store", None):
|
| 112 |
+
self.monitoring.store.set_state("analyst_evidence", research.model_dump())
|
| 113 |
self._set(request_id, validation="done")
|
| 114 |
return {"research": research.model_dump(), "validation": validation.model_dump(), "request_id": request_id,
|
| 115 |
"agent_status": self.activity(request_id)["agent_status"]}
|
|
|
|
| 125 |
snapshot = copy.deepcopy(self.monitoring.snapshot())
|
| 126 |
context = self.forecast_agent.run(snapshot)
|
| 127 |
self._set(request_id, forecast="done")
|
| 128 |
+
if needs_research(question) or request.fresh_research:
|
| 129 |
self._set(request_id, research="running")
|
| 130 |
+
fresh = request.fresh_research or bool(re.search(r"\b(?:fresh|new)\s+(?:web\s+)?(?:search|research)|search again|ignore.*cache", question, re.I))
|
| 131 |
+
research = self.research_agent.run(question, **({"fresh": True} if fresh else {}))
|
| 132 |
self._set(request_id, research="unavailable" if research.status == "unavailable" else "done")
|
| 133 |
else:
|
| 134 |
research = ResearchResult()
|
|
|
|
| 138 |
if research.status != "not_requested":
|
| 139 |
with self._lock:
|
| 140 |
self._evidence = research.model_copy(deep=True)
|
| 141 |
+
if getattr(self.monitoring, "event_store", None):
|
| 142 |
+
self.monitoring.store.set_state("analyst_evidence", research.model_dump())
|
| 143 |
self._set(request_id, validation="done", decision="running")
|
| 144 |
answer, llm_status, sources = self.decision_agent.run(question, history, context, research, validation)
|
| 145 |
self._set(request_id, decision="done" if llm_status == "ok" else "unavailable")
|
app/agents/sentiment_agent.py
CHANGED
|
@@ -30,6 +30,8 @@ class SentimentAgent:
|
|
| 30 |
cmd.append("--skip-gdelt")
|
| 31 |
|
| 32 |
env = os.environ.copy()
|
|
|
|
|
|
|
| 33 |
if self.alpha_vantage_key:
|
| 34 |
env["ALPHA_VANTAGE_API_KEY"] = self.alpha_vantage_key
|
| 35 |
|
|
|
|
| 30 |
cmd.append("--skip-gdelt")
|
| 31 |
|
| 32 |
env = os.environ.copy()
|
| 33 |
+
for key in ("MONITOR_BACKUP_TOKEN", "HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN"):
|
| 34 |
+
env.pop(key, None)
|
| 35 |
if self.alpha_vantage_key:
|
| 36 |
env["ALPHA_VANTAGE_API_KEY"] = self.alpha_vantage_key
|
| 37 |
|
app/api.py
CHANGED
|
@@ -6,7 +6,7 @@ from typing import Literal
|
|
| 6 |
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
|
| 7 |
from fastapi.responses import FileResponse, JSONResponse
|
| 8 |
from fastapi.staticfiles import StaticFiles
|
| 9 |
-
from pydantic import BaseModel
|
| 10 |
from .monitoring_service import MonitoringService
|
| 11 |
|
| 12 |
PROJECT = Path(os.getenv("BTC_PROJECT_ROOT", str(Path(__file__).resolve().parents[1])))
|
|
@@ -47,9 +47,11 @@ class RefreshRequest(BaseModel):
|
|
| 47 |
|
| 48 |
class MonitoringRunRequest(RefreshRequest):
|
| 49 |
trigger: Literal["manual", "hourly"] = "manual"
|
|
|
|
| 50 |
|
| 51 |
class HeartbeatRequest(BaseModel):
|
| 52 |
next_run_at: str | None = None
|
|
|
|
| 53 |
|
| 54 |
@app.get("/monitor", include_in_schema=False)
|
| 55 |
@app.get("/monitor/", include_in_schema=False)
|
|
@@ -132,10 +134,24 @@ def monitoring_run(req: MonitoringRunRequest, tasks: BackgroundTasks, background
|
|
| 132 |
if background:
|
| 133 |
if service.system_status()["active_run"]:
|
| 134 |
return JSONResponse({"status": "busy", "detail": "A refresh is already running."}, status_code=202)
|
| 135 |
-
tasks.add_task(service.run, req.trigger, req.use_gdelt)
|
| 136 |
return JSONResponse({"status": "queued"}, status_code=202)
|
| 137 |
-
return service.run(req.trigger, req.use_gdelt)
|
| 138 |
|
| 139 |
@app.post("/monitoring/heartbeat", include_in_schema=False)
|
| 140 |
def monitoring_heartbeat(req: HeartbeatRequest):
|
| 141 |
-
return get_service().heartbeat(req.next_run_at)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
|
| 7 |
from fastapi.responses import FileResponse, JSONResponse
|
| 8 |
from fastapi.staticfiles import StaticFiles
|
| 9 |
+
from pydantic import BaseModel, Field
|
| 10 |
from .monitoring_service import MonitoringService
|
| 11 |
|
| 12 |
PROJECT = Path(os.getenv("BTC_PROJECT_ROOT", str(Path(__file__).resolve().parents[1])))
|
|
|
|
| 47 |
|
| 48 |
class MonitoringRunRequest(RefreshRequest):
|
| 49 |
trigger: Literal["manual", "hourly"] = "manual"
|
| 50 |
+
worker_id: str | None = Field(None, pattern=r"^[a-f0-9]{32}$")
|
| 51 |
|
| 52 |
class HeartbeatRequest(BaseModel):
|
| 53 |
next_run_at: str | None = None
|
| 54 |
+
worker_id: str | None = Field(None, pattern=r"^[a-f0-9]{32}$")
|
| 55 |
|
| 56 |
@app.get("/monitor", include_in_schema=False)
|
| 57 |
@app.get("/monitor/", include_in_schema=False)
|
|
|
|
| 134 |
if background:
|
| 135 |
if service.system_status()["active_run"]:
|
| 136 |
return JSONResponse({"status": "busy", "detail": "A refresh is already running."}, status_code=202)
|
| 137 |
+
tasks.add_task(service.run, req.trigger, req.use_gdelt, req.worker_id)
|
| 138 |
return JSONResponse({"status": "queued"}, status_code=202)
|
| 139 |
+
return service.run(req.trigger, req.use_gdelt, req.worker_id)
|
| 140 |
|
| 141 |
@app.post("/monitoring/heartbeat", include_in_schema=False)
|
| 142 |
def monitoring_heartbeat(req: HeartbeatRequest):
|
| 143 |
+
return get_service().heartbeat(req.next_run_at, req.worker_id)
|
| 144 |
+
|
| 145 |
+
@app.post("/monitoring/worker/release", include_in_schema=False)
|
| 146 |
+
def release_worker(req: HeartbeatRequest):
|
| 147 |
+
if req.worker_id:
|
| 148 |
+
get_service().event_store.release("scheduler", req.worker_id)
|
| 149 |
+
return {"released": True}
|
| 150 |
+
|
| 151 |
+
@app.get("/monitoring/activity")
|
| 152 |
+
def monitoring_activity():
|
| 153 |
+
return get_service().monitoring_activity()
|
| 154 |
+
|
| 155 |
+
@app.get("/monitoring/scans")
|
| 156 |
+
def monitoring_scans(limit: int = Query(50, ge=1, le=200)):
|
| 157 |
+
return {"items": get_service().event_store.scans(limit)}
|
app/monitor_worker.py
CHANGED
|
@@ -1,51 +1,5 @@
|
|
| 1 |
-
"""
|
| 2 |
-
from
|
| 3 |
-
import os
|
| 4 |
-
import signal
|
| 5 |
-
import threading
|
| 6 |
-
from datetime import datetime, timedelta, timezone
|
| 7 |
-
import requests
|
| 8 |
-
|
| 9 |
-
UTC = timezone.utc
|
| 10 |
-
|
| 11 |
-
def next_hourly_run(now):
|
| 12 |
-
slot = now.replace(minute=2, second=0, microsecond=0)
|
| 13 |
-
return slot if now < slot else slot + timedelta(hours=1)
|
| 14 |
-
|
| 15 |
-
def main():
|
| 16 |
-
api = os.getenv("BTC_API_URL", "http://btc-api:8000").rstrip("/")
|
| 17 |
-
stop = threading.Event()
|
| 18 |
-
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
| 19 |
-
signal.signal(signal.SIGINT, lambda *_: stop.set())
|
| 20 |
-
next_run = [datetime.now(UTC)]
|
| 21 |
-
|
| 22 |
-
def heartbeat():
|
| 23 |
-
while not stop.is_set():
|
| 24 |
-
try:
|
| 25 |
-
requests.post(f"{api}/monitoring/heartbeat", json={"next_run_at": next_run[0].isoformat()}, timeout=10).raise_for_status()
|
| 26 |
-
except requests.RequestException:
|
| 27 |
-
pass
|
| 28 |
-
stop.wait(25)
|
| 29 |
-
|
| 30 |
-
threading.Thread(target=heartbeat, daemon=True).start()
|
| 31 |
-
while not stop.is_set():
|
| 32 |
-
now = datetime.now(UTC)
|
| 33 |
-
if now < next_run[0]:
|
| 34 |
-
stop.wait(min(30, (next_run[0] - now).total_seconds()))
|
| 35 |
-
continue
|
| 36 |
-
try:
|
| 37 |
-
response = requests.post(f"{api}/monitoring/run", json={"use_gdelt": os.getenv("MONITOR_USE_GDELT", "true").lower() == "true", "trigger": "hourly"}, timeout=1500)
|
| 38 |
-
response.raise_for_status()
|
| 39 |
-
status = response.json()["status"]
|
| 40 |
-
print(f"Monitoring cycle: {status}", flush=True)
|
| 41 |
-
if status in {"error", "busy"}:
|
| 42 |
-
next_run[0] = datetime.now(UTC) + timedelta(minutes=5)
|
| 43 |
-
else:
|
| 44 |
-
next_run[0] = next_hourly_run(datetime.now(UTC))
|
| 45 |
-
except (requests.RequestException, ValueError, KeyError) as exc:
|
| 46 |
-
print(f"Monitoring API unavailable ({type(exc).__name__}); retry in 60s.", flush=True)
|
| 47 |
-
next_run[0] = datetime.now(UTC) + timedelta(seconds=60)
|
| 48 |
-
print("Monitoring scheduler stopped.", flush=True)
|
| 49 |
|
| 50 |
if __name__ == "__main__":
|
| 51 |
main()
|
|
|
|
| 1 |
+
"""Stable entry point for the browser-independent monitoring worker."""
|
| 2 |
+
from .services.monitor_worker_runtime import MonitoringWorker, main, next_hourly_run
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
if __name__ == "__main__":
|
| 5 |
main()
|
app/monitoring_service.py
CHANGED
|
@@ -15,6 +15,10 @@ import pandas as pd
|
|
| 15 |
import yaml
|
| 16 |
|
| 17 |
from .monitoring_store import MonitoringStore
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
UTC = timezone.utc
|
| 20 |
|
|
@@ -24,19 +28,23 @@ def utcnow():
|
|
| 24 |
|
| 25 |
|
| 26 |
def safe_error(exc):
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
secret = os.getenv(name)
|
| 30 |
-
if secret:
|
| 31 |
-
message = message.replace(secret, "[redacted]")
|
| 32 |
-
return re.sub(r"(?i)(apikey|api_key|token)=([^&\s]+)", r"\1=[redacted]", message)[-600:]
|
| 33 |
|
| 34 |
|
| 35 |
class MonitoringService:
|
| 36 |
def __init__(self, project_root, db_path=None):
|
| 37 |
self.root = Path(project_root)
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
self.work_dir = Path(self.store.path).parent
|
|
|
|
|
|
|
|
|
|
| 40 |
self._pipeline = None
|
| 41 |
self._pipeline_lock = threading.Lock()
|
| 42 |
self._run_lock = threading.Lock()
|
|
@@ -70,10 +78,54 @@ class MonitoringService:
|
|
| 70 |
def snapshot(self):
|
| 71 |
return self.store.latest_snapshot() or {}
|
| 72 |
|
| 73 |
-
def heartbeat(self, next_run_at=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
state = {"at": utcnow().isoformat(), "next_run_at": next_run_at}
|
|
|
|
|
|
|
| 75 |
self.store.set_state("worker", state)
|
| 76 |
-
return state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
def system_status(self):
|
| 79 |
worker = self.store.get_state("worker", {}) or {}
|
|
@@ -124,7 +176,7 @@ class MonitoringService:
|
|
| 124 |
targets = [t for t in targets if pd.Timestamp(t) >= boundary]
|
| 125 |
return result
|
| 126 |
|
| 127 |
-
def run(self, trigger="manual", use_gdelt=True):
|
| 128 |
owner = uuid.uuid4().hex
|
| 129 |
if not self._run_lock.acquire(blocking=False):
|
| 130 |
return {"status": "busy", "detail": "A refresh is already running."}
|
|
@@ -136,6 +188,12 @@ class MonitoringService:
|
|
| 136 |
if not acquired:
|
| 137 |
self._run_lock.release()
|
| 138 |
return {"status": "busy", "detail": "A refresh is already running."}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
started = utcnow()
|
| 140 |
clock_start = time.monotonic()
|
| 141 |
renew_stop, lease_lost = threading.Event(), threading.Event()
|
|
@@ -158,6 +216,9 @@ class MonitoringService:
|
|
| 158 |
renewal = threading.Thread(target=renew, daemon=True)
|
| 159 |
try:
|
| 160 |
run_id = self.store.start_run(trigger)
|
|
|
|
|
|
|
|
|
|
| 161 |
old_active = self.store.get_state("active_run")
|
| 162 |
if old_active and old_active.get("id"):
|
| 163 |
self.store.finish_run(old_active["id"], "failed", detail="Previous process stopped; retrying with saved history.")
|
|
@@ -189,7 +250,10 @@ class MonitoringService:
|
|
| 189 |
for h, forecast in data["forecasts"].items():
|
| 190 |
forecast["model_version"] = self.models[int(h)]["model_version"]
|
| 191 |
forecast["issued_at"] = issued_at.isoformat()
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
| 193 |
# Recover the first-issued values after a crash between record
|
| 194 |
# insertion and snapshot saving, without rewriting predictions.
|
| 195 |
for h, forecast in data["forecasts"].items():
|
|
@@ -206,18 +270,28 @@ class MonitoringService:
|
|
| 206 |
self.store.save_snapshot(json.loads(json.dumps(data, default=str)))
|
| 207 |
self.store.set_state("last_success", issued_at.isoformat())
|
| 208 |
status = "success"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
self.store.set_state("last_error", None)
|
|
|
|
| 210 |
duration = round((time.monotonic() - clock_start) * 1000)
|
| 211 |
self.store.finish_run(run_id, "skipped" if status == "up_to_date" else status, duration_ms=duration)
|
| 212 |
-
return {"status": status, "recorded": recorded, "settled": settled, "duration_ms": duration}
|
| 213 |
except Exception as exc:
|
| 214 |
detail = safe_error(exc)
|
| 215 |
self.store.set_state("last_error", detail)
|
|
|
|
|
|
|
|
|
|
| 216 |
if run_id is not None:
|
| 217 |
self.store.finish_run(run_id, "error", detail=detail, duration_ms=round((time.monotonic() - clock_start) * 1000))
|
| 218 |
return {"status": "error", "detail": detail}
|
| 219 |
finally:
|
| 220 |
try:
|
|
|
|
| 221 |
renew_stop.set()
|
| 222 |
if renewal.is_alive():
|
| 223 |
renewal.join(timeout=2)
|
|
@@ -227,3 +301,4 @@ class MonitoringService:
|
|
| 227 |
self.store.release_lock(owner)
|
| 228 |
finally:
|
| 229 |
self._run_lock.release()
|
|
|
|
|
|
| 15 |
import yaml
|
| 16 |
|
| 17 |
from .monitoring_store import MonitoringStore
|
| 18 |
+
from .services.agent_config import AgentSettings
|
| 19 |
+
from .services.monitoring_events_store import EventStore
|
| 20 |
+
from .services.monitor_schedule import next_cycle
|
| 21 |
+
from .services.monitoring_backup import MonitoringBackup
|
| 22 |
|
| 23 |
UTC = timezone.utc
|
| 24 |
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def safe_error(exc):
|
| 31 |
+
from .services.agent_config import redact
|
| 32 |
+
return redact(str(exc))[-600:]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
class MonitoringService:
|
| 36 |
def __init__(self, project_root, db_path=None):
|
| 37 |
self.root = Path(project_root)
|
| 38 |
+
path = db_path or os.getenv("BTC_MONITOR_DB", str(self.root / "runtime/monitoring.sqlite3"))
|
| 39 |
+
self.backup = MonitoringBackup()
|
| 40 |
+
restored = self.backup.restore(path)
|
| 41 |
+
self.store = MonitoringStore(path)
|
| 42 |
+
if restored:
|
| 43 |
+
self.store.set_state("backup", {"status": "RESTORED", "restored_at": utcnow().isoformat()})
|
| 44 |
self.work_dir = Path(self.store.path).parent
|
| 45 |
+
self.settings = AgentSettings.from_env()
|
| 46 |
+
self.event_store = EventStore(self.store)
|
| 47 |
+
self._market_monitor = None
|
| 48 |
self._pipeline = None
|
| 49 |
self._pipeline_lock = threading.Lock()
|
| 50 |
self._run_lock = threading.Lock()
|
|
|
|
| 78 |
def snapshot(self):
|
| 79 |
return self.store.latest_snapshot() or {}
|
| 80 |
|
| 81 |
+
def heartbeat(self, next_run_at=None, worker_id=None):
|
| 82 |
+
if worker_id and not self.event_store.acquire("scheduler", worker_id, ttl=90):
|
| 83 |
+
return {"accepted": False, "next_cycle_due": self.store.get_state("next_cycle_due")}
|
| 84 |
+
due = self.store.get_state("next_cycle_due")
|
| 85 |
+
started = self.store.get_state("last_cycle_started")
|
| 86 |
+
completed = self.store.get_state("last_cycle_finished") or self.store.get_state("last_cycle_completed")
|
| 87 |
+
active = self.store.get_state("active_run") or {}
|
| 88 |
+
if started and (not completed or started > completed):
|
| 89 |
+
last_beat = active.get("heartbeat_at", active.get("started_at", started))
|
| 90 |
+
if (utcnow() - datetime.fromisoformat(last_beat)).total_seconds() > 120:
|
| 91 |
+
due = utcnow().isoformat()
|
| 92 |
+
self.store.set_state("next_cycle_due", due)
|
| 93 |
+
if due is None:
|
| 94 |
+
last = self.store.get_state("last_cycle_completed") or self.store.get_state("last_success")
|
| 95 |
+
due = next_cycle(datetime.fromisoformat(last), self.settings.monitor_interval_minutes).isoformat() if last else utcnow().isoformat()
|
| 96 |
+
self.store.set_state("next_cycle_due", due)
|
| 97 |
+
if worker_id:
|
| 98 |
+
next_run_at = due
|
| 99 |
state = {"at": utcnow().isoformat(), "next_run_at": next_run_at}
|
| 100 |
+
if worker_id:
|
| 101 |
+
state["worker_id"] = worker_id
|
| 102 |
self.store.set_state("worker", state)
|
| 103 |
+
return {**state, "accepted": True, "next_cycle_due": due, "monitor_interval_minutes": self.settings.monitor_interval_minutes,
|
| 104 |
+
"last_cycle_started": self.store.get_state("last_cycle_started"),
|
| 105 |
+
"last_cycle_completed": self.store.get_state("last_cycle_completed"),
|
| 106 |
+
"last_research_trigger": self.store.get_state("last_research_trigger"),
|
| 107 |
+
"last_error": self.store.get_state("last_error")}
|
| 108 |
+
|
| 109 |
+
def market_monitor(self):
|
| 110 |
+
if self._market_monitor is None:
|
| 111 |
+
from .services.market_monitoring import MarketMonitoringAgent
|
| 112 |
+
self._market_monitor = MarketMonitoringAgent(self)
|
| 113 |
+
return self._market_monitor
|
| 114 |
+
|
| 115 |
+
def monitoring_activity(self):
|
| 116 |
+
system = self.system_status()
|
| 117 |
+
event = self.event_store.latest_event()
|
| 118 |
+
return {"worker_alive": system["worker_alive"],
|
| 119 |
+
"last_cycle_started": self.store.get_state("last_cycle_started"),
|
| 120 |
+
"last_cycle_completed": self.store.get_state("last_cycle_completed"),
|
| 121 |
+
"last_market_scan": self.store.get_state("last_market_scan"),
|
| 122 |
+
"last_successful_prediction_cycle": self.store.get_state("last_success"),
|
| 123 |
+
"next_cycle_due": self.store.get_state("next_cycle_due"),
|
| 124 |
+
"last_research_trigger": self.store.get_state("last_research_trigger"),
|
| 125 |
+
"research": self.store.get_state("research_operation", {"status": "IDLE"}),
|
| 126 |
+
"last_research": event, "last_error": system["last_error"],
|
| 127 |
+
"backup": self.store.get_state("backup", {"status": "CONFIGURED" if self.backup.repo else "LOCAL_ONLY"}),
|
| 128 |
+
"monitor_interval_minutes": self.settings.monitor_interval_minutes}
|
| 129 |
|
| 130 |
def system_status(self):
|
| 131 |
worker = self.store.get_state("worker", {}) or {}
|
|
|
|
| 176 |
targets = [t for t in targets if pd.Timestamp(t) >= boundary]
|
| 177 |
return result
|
| 178 |
|
| 179 |
+
def run(self, trigger="manual", use_gdelt=True, worker_id=None):
|
| 180 |
owner = uuid.uuid4().hex
|
| 181 |
if not self._run_lock.acquire(blocking=False):
|
| 182 |
return {"status": "busy", "detail": "A refresh is already running."}
|
|
|
|
| 188 |
if not acquired:
|
| 189 |
self._run_lock.release()
|
| 190 |
return {"status": "busy", "detail": "A refresh is already running."}
|
| 191 |
+
if trigger == "hourly":
|
| 192 |
+
due = self.store.get_state("next_cycle_due")
|
| 193 |
+
if (worker_id and not self.event_store.owns("scheduler", worker_id)) or (due and utcnow() < datetime.fromisoformat(due)):
|
| 194 |
+
self.store.release_lock(owner)
|
| 195 |
+
self._run_lock.release()
|
| 196 |
+
return {"status": "not_due", "next_cycle_due": due}
|
| 197 |
started = utcnow()
|
| 198 |
clock_start = time.monotonic()
|
| 199 |
renew_stop, lease_lost = threading.Event(), threading.Event()
|
|
|
|
| 216 |
renewal = threading.Thread(target=renew, daemon=True)
|
| 217 |
try:
|
| 218 |
run_id = self.store.start_run(trigger)
|
| 219 |
+
self.store.set_state("last_cycle_started", started.isoformat())
|
| 220 |
+
self.store.set_state("next_cycle_due", next_cycle(started, self.settings.monitor_interval_minutes).isoformat())
|
| 221 |
+
self.store.set_state("research_operation", {"status": "IDLE", "monitor": "running", "forecast": "running", "trigger": "idle", "validation": "idle", "decision": "idle"})
|
| 222 |
old_active = self.store.get_state("active_run")
|
| 223 |
if old_active and old_active.get("id"):
|
| 224 |
self.store.finish_run(old_active["id"], "failed", detail="Previous process stopped; retrying with saved history.")
|
|
|
|
| 250 |
for h, forecast in data["forecasts"].items():
|
| 251 |
forecast["model_version"] = self.models[int(h)]["model_version"]
|
| 252 |
forecast["issued_at"] = issued_at.isoformat()
|
| 253 |
+
quantitative = self.market_monitor().prepare(data, market, now=issued_at)
|
| 254 |
+
data["market"]["regime"] = quantitative["regime"]
|
| 255 |
+
recorded = self.store.record_forecasts(data["forecasts"], issued_at=issued_at,
|
| 256 |
+
context_by_horizon=quantitative["forecasts"], regime=quantitative["regime"])
|
| 257 |
# Recover the first-issued values after a crash between record
|
| 258 |
# insertion and snapshot saving, without rewriting predictions.
|
| 259 |
for h, forecast in data["forecasts"].items():
|
|
|
|
| 270 |
self.store.save_snapshot(json.loads(json.dumps(data, default=str)))
|
| 271 |
self.store.set_state("last_success", issued_at.isoformat())
|
| 272 |
status = "success"
|
| 273 |
+
try:
|
| 274 |
+
research_trigger = self.market_monitor().process(run_id, self.snapshot(), market)
|
| 275 |
+
except Exception:
|
| 276 |
+
research_trigger = {"should_research": False, "triggered_by": [], "error": "Monitoring context processing failed; forecasts remain saved."}
|
| 277 |
+
self.store.set_state("research_operation", {"status": "ERROR", "error": research_trigger["error"]})
|
| 278 |
self.store.set_state("last_error", None)
|
| 279 |
+
self.store.set_state("last_cycle_completed", utcnow().isoformat())
|
| 280 |
duration = round((time.monotonic() - clock_start) * 1000)
|
| 281 |
self.store.finish_run(run_id, "skipped" if status == "up_to_date" else status, duration_ms=duration)
|
| 282 |
+
return {"status": status, "recorded": recorded, "settled": settled, "duration_ms": duration, "research_trigger": research_trigger}
|
| 283 |
except Exception as exc:
|
| 284 |
detail = safe_error(exc)
|
| 285 |
self.store.set_state("last_error", detail)
|
| 286 |
+
from datetime import timedelta
|
| 287 |
+
self.store.set_state("next_cycle_due", (utcnow() + timedelta(minutes=min(5, self.settings.monitor_interval_minutes))).isoformat())
|
| 288 |
+
self.store.set_state("research_operation", {"status": "IDLE", "monitor": "error", "forecast": "error", "trigger": "idle", "validation": "idle", "decision": "idle"})
|
| 289 |
if run_id is not None:
|
| 290 |
self.store.finish_run(run_id, "error", detail=detail, duration_ms=round((time.monotonic() - clock_start) * 1000))
|
| 291 |
return {"status": "error", "detail": detail}
|
| 292 |
finally:
|
| 293 |
try:
|
| 294 |
+
self.store.set_state("last_cycle_finished", utcnow().isoformat())
|
| 295 |
renew_stop.set()
|
| 296 |
if renewal.is_alive():
|
| 297 |
renewal.join(timeout=2)
|
|
|
|
| 301 |
self.store.release_lock(owner)
|
| 302 |
finally:
|
| 303 |
self._run_lock.release()
|
| 304 |
+
self.backup.save(self.store)
|
app/monitoring_store.py
CHANGED
|
@@ -93,6 +93,11 @@ class MonitoringStore:
|
|
| 93 |
CREATE TABLE IF NOT EXISTS worker_lock (
|
| 94 |
id INTEGER PRIMARY KEY CHECK(id = 1), owner TEXT NOT NULL, expires_at TEXT NOT NULL
|
| 95 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
""")
|
| 97 |
|
| 98 |
@contextmanager
|
|
@@ -126,7 +131,7 @@ class MonitoringStore:
|
|
| 126 |
def latest_snapshot(self):
|
| 127 |
return self.get_state("latest_snapshot")
|
| 128 |
|
| 129 |
-
def record_forecasts(self, forecasts, issued_at=None):
|
| 130 |
issued = _utc(issued_at)
|
| 131 |
rows = []
|
| 132 |
for key, forecast in forecasts.items():
|
|
@@ -163,6 +168,10 @@ class MonitoringStore:
|
|
| 163 |
model,feature_set,status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
| 164 |
ON CONFLICT(asset,model_version,horizon,origin_open) DO NOTHING""", row)
|
| 165 |
if cursor.rowcount:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
result["inserted"] += 1
|
| 167 |
result[row[-1]] += 1
|
| 168 |
else:
|
|
@@ -311,7 +320,7 @@ class MonitoringStore:
|
|
| 311 |
"AND issued_at<=? ORDER BY issued_at DESC,id DESC LIMIT 1",
|
| 312 |
(horizon, _iso(current))).fetchone()
|
| 313 |
model_version = latest[0] if latest else None
|
| 314 |
-
rows = db.execute("SELECT * FROM forecasts WHERE horizon=? AND model_version=? "
|
| 315 |
"AND issued_at>=? AND issued_at<=? ORDER BY issued_at",
|
| 316 |
(horizon, model_version, _iso(cutoff - timedelta(days=days)),
|
| 317 |
_iso(current))).fetchall()
|
|
@@ -327,7 +336,29 @@ class MonitoringStore:
|
|
| 327 |
**self._metrics(subset, end)})
|
| 328 |
return {"horizon": horizon, "days": days, "model_version": model_version,
|
| 329 |
"as_of": _iso(current), "window_start": _iso(cutoff),
|
| 330 |
-
**self._metrics(in_window, current), "series": series
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
|
| 332 |
def latest_forecasts(self):
|
| 333 |
snapshot = self.latest_snapshot()
|
|
|
|
| 93 |
CREATE TABLE IF NOT EXISTS worker_lock (
|
| 94 |
id INTEGER PRIMARY KEY CHECK(id = 1), owner TEXT NOT NULL, expires_at TEXT NOT NULL
|
| 95 |
);
|
| 96 |
+
CREATE TABLE IF NOT EXISTS forecast_annotations (
|
| 97 |
+
forecast_id INTEGER PRIMARY KEY, reliability REAL,
|
| 98 |
+
signal_state TEXT NOT NULL, regime TEXT,
|
| 99 |
+
FOREIGN KEY(forecast_id) REFERENCES forecasts(id)
|
| 100 |
+
);
|
| 101 |
""")
|
| 102 |
|
| 103 |
@contextmanager
|
|
|
|
| 131 |
def latest_snapshot(self):
|
| 132 |
return self.get_state("latest_snapshot")
|
| 133 |
|
| 134 |
+
def record_forecasts(self, forecasts, issued_at=None, context_by_horizon=None, regime=None):
|
| 135 |
issued = _utc(issued_at)
|
| 136 |
rows = []
|
| 137 |
for key, forecast in forecasts.items():
|
|
|
|
| 168 |
model,feature_set,status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
| 169 |
ON CONFLICT(asset,model_version,horizon,origin_open) DO NOTHING""", row)
|
| 170 |
if cursor.rowcount:
|
| 171 |
+
context = (context_by_horizon or {}).get(str(row[2]))
|
| 172 |
+
if context is not None:
|
| 173 |
+
db.execute("INSERT INTO forecast_annotations VALUES(?,?,?,?)",
|
| 174 |
+
(cursor.lastrowid, context.get("reliability"), context.get("signal_state", "UNKNOWN"), regime))
|
| 175 |
result["inserted"] += 1
|
| 176 |
result[row[-1]] += 1
|
| 177 |
else:
|
|
|
|
| 320 |
"AND issued_at<=? ORDER BY issued_at DESC,id DESC LIMIT 1",
|
| 321 |
(horizon, _iso(current))).fetchone()
|
| 322 |
model_version = latest[0] if latest else None
|
| 323 |
+
rows = db.execute("SELECT f.*,a.reliability,a.signal_state,a.regime FROM forecasts f LEFT JOIN forecast_annotations a ON a.forecast_id=f.id WHERE horizon=? AND model_version=? "
|
| 324 |
"AND issued_at>=? AND issued_at<=? ORDER BY issued_at",
|
| 325 |
(horizon, model_version, _iso(cutoff - timedelta(days=days)),
|
| 326 |
_iso(current))).fetchall()
|
|
|
|
| 336 |
**self._metrics(subset, end)})
|
| 337 |
return {"horizon": horizon, "days": days, "model_version": model_version,
|
| 338 |
"as_of": _iso(current), "window_start": _iso(cutoff),
|
| 339 |
+
**self._metrics(in_window, current), "series": series,
|
| 340 |
+
"qualification": self._qualification(in_window, current)}
|
| 341 |
+
|
| 342 |
+
@classmethod
|
| 343 |
+
def _qualification(cls, rows, now):
|
| 344 |
+
# Legacy forecasts have NULL annotations; never backfill issue-time gates.
|
| 345 |
+
annotated = [r for r in rows if r["signal_state"] is not None and r["status"] != "excluded_late"]
|
| 346 |
+
qualified = [r for r in annotated if r["signal_state"] in {"UP", "DOWN"}]
|
| 347 |
+
def grouped(field):
|
| 348 |
+
groups = {}
|
| 349 |
+
for row in annotated:
|
| 350 |
+
value = row[field]
|
| 351 |
+
if field == "reliability":
|
| 352 |
+
value = "unavailable" if value is None else "0.00-0.49" if value < .5 else "0.50-0.74" if value < .75 else "0.75-1.00"
|
| 353 |
+
groups.setdefault(value or "unavailable", []).append(row)
|
| 354 |
+
return {key: cls._metrics(items, now) for key, items in groups.items()}
|
| 355 |
+
n = len(annotated)
|
| 356 |
+
return {"annotated_forecasts": n, "legacy_unannotated": sum(r["signal_state"] is None for r in rows),
|
| 357 |
+
"signal_coverage": len(qualified) / n if n else None,
|
| 358 |
+
"no_signal_frequency": sum(r["signal_state"] == "NO_SIGNAL" for r in annotated) / n if n else None,
|
| 359 |
+
"unknown_frequency": sum(r["signal_state"] == "UNKNOWN" for r in annotated) / n if n else None,
|
| 360 |
+
"qualified_performance": cls._metrics(qualified, now),
|
| 361 |
+
"reliability_buckets": grouped("reliability"), "regimes": grouped("regime")}
|
| 362 |
|
| 363 |
def latest_forecasts(self):
|
| 364 |
snapshot = self.latest_snapshot()
|
app/schemas/agent_models.py
CHANGED
|
@@ -101,11 +101,13 @@ class SafeChatMessage(Contract):
|
|
| 101 |
content: str = Field(max_length=6000)
|
| 102 |
|
| 103 |
class ChatRequest(Contract):
|
|
|
|
| 104 |
message: str = Field(min_length=1, max_length=2000)
|
| 105 |
history: list[SafeChatMessage] = Field(default_factory=list, max_length=12)
|
| 106 |
request_id: str | None = Field(None, pattern=r"^[a-zA-Z0-9_-]{1,64}$")
|
| 107 |
|
| 108 |
class ResearchRequest(Contract):
|
|
|
|
| 109 |
question: str = Field(default="Bitcoin latest market news today", min_length=1, max_length=2000)
|
| 110 |
mode: Literal["live", "historical"] = "live"
|
| 111 |
prediction_timestamp: datetime | None = None
|
|
|
|
| 101 |
content: str = Field(max_length=6000)
|
| 102 |
|
| 103 |
class ChatRequest(Contract):
|
| 104 |
+
fresh_research: bool = False
|
| 105 |
message: str = Field(min_length=1, max_length=2000)
|
| 106 |
history: list[SafeChatMessage] = Field(default_factory=list, max_length=12)
|
| 107 |
request_id: str | None = Field(None, pattern=r"^[a-zA-Z0-9_-]{1,64}$")
|
| 108 |
|
| 109 |
class ResearchRequest(Contract):
|
| 110 |
+
fresh_research: bool = False
|
| 111 |
question: str = Field(default="Bitcoin latest market news today", min_length=1, max_length=2000)
|
| 112 |
mode: Literal["live", "historical"] = "live"
|
| 113 |
prediction_timestamp: datetime | None = None
|
app/services/agent_config.py
CHANGED
|
@@ -24,6 +24,11 @@ class AgentSettings:
|
|
| 24 |
context_max_age_minutes: float
|
| 25 |
tavily_timeout_seconds: float
|
| 26 |
openai_timeout_seconds: float
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
@classmethod
|
| 29 |
def from_env(cls):
|
|
@@ -31,17 +36,24 @@ class AgentSettings:
|
|
| 31 |
os.getenv("TAVILY_API_KEY", "").strip(), bounded_env("RELIABILITY_THRESHOLD", .50, 0, 1),
|
| 32 |
bounded_env("NEWS_CACHE_MINUTES", 10, 0, 120), bounded_env("NEWS_MAX_AGE_HOURS", 72, 1, 720),
|
| 33 |
bounded_env("CONTEXT_MAX_AGE_MINUTES", 90, 1, 1440),
|
| 34 |
-
bounded_env("TAVILY_TIMEOUT_SECONDS", 12, 1, 60), bounded_env("OPENAI_TIMEOUT_SECONDS", 45, 1, 120)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
def public_status(self):
|
| 37 |
return {"openai_configured": bool(self.openai_key and self.openai_model),
|
| 38 |
"tavily_configured": bool(self.tavily_key), "openai_model": self.openai_model or None,
|
| 39 |
"agent_framework": "OpenAI Agents SDK",
|
| 40 |
-
"reliability_threshold": self.reliability_threshold, "news_cache_minutes": self.news_cache_minutes
|
|
|
|
|
|
|
| 41 |
|
| 42 |
def redact(value):
|
| 43 |
text = str(value)
|
| 44 |
-
for name in ("OPENAI_API_KEY", "TAVILY_API_KEY", "ALPHA_VANTAGE_API_KEY"):
|
| 45 |
key = os.getenv(name)
|
| 46 |
if key:
|
| 47 |
text = text.replace(key, "[redacted]")
|
|
|
|
| 24 |
context_max_age_minutes: float
|
| 25 |
tavily_timeout_seconds: float
|
| 26 |
openai_timeout_seconds: float
|
| 27 |
+
monitor_interval_minutes: float = 60
|
| 28 |
+
price_move_trigger_pct: float = 1.0
|
| 29 |
+
volatility_trigger_multiplier: float = 1.5
|
| 30 |
+
sentiment_shift_threshold: float = .25
|
| 31 |
+
research_cooldown_minutes: float = 30
|
| 32 |
|
| 33 |
@classmethod
|
| 34 |
def from_env(cls):
|
|
|
|
| 36 |
os.getenv("TAVILY_API_KEY", "").strip(), bounded_env("RELIABILITY_THRESHOLD", .50, 0, 1),
|
| 37 |
bounded_env("NEWS_CACHE_MINUTES", 10, 0, 120), bounded_env("NEWS_MAX_AGE_HOURS", 72, 1, 720),
|
| 38 |
bounded_env("CONTEXT_MAX_AGE_MINUTES", 90, 1, 1440),
|
| 39 |
+
bounded_env("TAVILY_TIMEOUT_SECONDS", 12, 1, 60), bounded_env("OPENAI_TIMEOUT_SECONDS", 45, 1, 120),
|
| 40 |
+
monitor_interval_minutes=bounded_env("MONITOR_INTERVAL_MINUTES", 60, 1, 1440),
|
| 41 |
+
price_move_trigger_pct=bounded_env("PRICE_MOVE_TRIGGER_PCT", 1, .01, 100),
|
| 42 |
+
volatility_trigger_multiplier=bounded_env("VOLATILITY_TRIGGER_MULTIPLIER", 1.5, 1.01, 20),
|
| 43 |
+
sentiment_shift_threshold=bounded_env("SENTIMENT_SHIFT_THRESHOLD", .25, .001, 2),
|
| 44 |
+
research_cooldown_minutes=bounded_env("RESEARCH_COOLDOWN_MINUTES", 30, 0, 1440))
|
| 45 |
|
| 46 |
def public_status(self):
|
| 47 |
return {"openai_configured": bool(self.openai_key and self.openai_model),
|
| 48 |
"tavily_configured": bool(self.tavily_key), "openai_model": self.openai_model or None,
|
| 49 |
"agent_framework": "OpenAI Agents SDK",
|
| 50 |
+
"reliability_threshold": self.reliability_threshold, "news_cache_minutes": self.news_cache_minutes,
|
| 51 |
+
"monitor_interval_minutes": self.monitor_interval_minutes,
|
| 52 |
+
"research_cooldown_minutes": self.research_cooldown_minutes}
|
| 53 |
|
| 54 |
def redact(value):
|
| 55 |
text = str(value)
|
| 56 |
+
for name in ("OPENAI_API_KEY", "TAVILY_API_KEY", "ALPHA_VANTAGE_API_KEY", "HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN", "MONITOR_BACKUP_TOKEN"):
|
| 57 |
key = os.getenv(name)
|
| 58 |
if key:
|
| 59 |
text = text.replace(key, "[redacted]")
|
app/services/market_monitoring.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persist quantitative scans, then optionally research a deterministic event."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import copy
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from app.agents.forecast_context_agent import ForecastContextAgent
|
| 6 |
+
from app.agents.market_research_agent import MarketResearchAgent
|
| 7 |
+
from app.agents.validation_agent import ValidationAgent
|
| 8 |
+
from app.agents.decision_agent import DecisionAgent
|
| 9 |
+
from app.schemas.agent_models import ResearchResult
|
| 10 |
+
from .research_triggers import ResearchTriggerEngine, market_metrics
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MarketMonitoringAgent:
|
| 14 |
+
def __init__(self, monitoring, research_agent=None, llm=None):
|
| 15 |
+
self.monitoring = monitoring
|
| 16 |
+
self.settings = monitoring.settings
|
| 17 |
+
self.store = monitoring.event_store
|
| 18 |
+
self.forecast = ForecastContextAgent(self.settings)
|
| 19 |
+
self.engine = ResearchTriggerEngine(self.settings)
|
| 20 |
+
self.research = research_agent or MarketResearchAgent(self.settings, store=self.store)
|
| 21 |
+
self.validation = ValidationAgent(self.settings)
|
| 22 |
+
self.decision = DecisionAgent(self.settings, self.validation, llm=llm)
|
| 23 |
+
|
| 24 |
+
def prepare(self, snapshot, candles, now=None):
|
| 25 |
+
metrics = market_metrics(candles, snapshot, self.settings.volatility_trigger_multiplier)
|
| 26 |
+
context = self.forecast.run(snapshot, now=now)
|
| 27 |
+
sentiment = context.sentiment.score
|
| 28 |
+
if context.sentiment.article_count == 0:
|
| 29 |
+
sentiment = None
|
| 30 |
+
return {**metrics, "data_fresh": context.data_fresh, "sentiment": sentiment,
|
| 31 |
+
"sentiment_scale": "weighted signed score [-1, 1]",
|
| 32 |
+
"forecasts": {h: f.model_dump() for h, f in context.forecasts.items()}}
|
| 33 |
+
|
| 34 |
+
def state(self, **values):
|
| 35 |
+
current = self.monitoring.store.get_state("research_operation", {})
|
| 36 |
+
self.monitoring.store.set_state("research_operation", {**current, **values})
|
| 37 |
+
|
| 38 |
+
def process(self, run_id, snapshot, candles, now=None):
|
| 39 |
+
now = now or datetime.now(timezone.utc)
|
| 40 |
+
current = self.prepare(snapshot, candles, now)
|
| 41 |
+
previous = self.store.scans(1)
|
| 42 |
+
trigger = self.engine.evaluate(current, previous[0]["payload"] if previous else None)
|
| 43 |
+
scan_id = self.store.scan(run_id, current, trigger, now)
|
| 44 |
+
self.monitoring.store.set_state("last_market_scan", now.isoformat())
|
| 45 |
+
# No paid provider or Decision Agent call on the ordinary path.
|
| 46 |
+
if not trigger["should_research"]:
|
| 47 |
+
self.state(status="IDLE", monitor="done", forecast="done", trigger="done", validation="idle", decision="idle")
|
| 48 |
+
return trigger
|
| 49 |
+
self.monitoring.store.set_state("last_research_trigger", {"timestamp": now.isoformat(), **trigger})
|
| 50 |
+
event, leader = self.store.claim(trigger["fingerprint"], current["market_timestamp"], trigger["triggered_by"], self.settings.research_cooldown_minutes, now)
|
| 51 |
+
self.store.link(scan_id, event["id"])
|
| 52 |
+
if not leader:
|
| 53 |
+
# Validate reused evidence again. Cooldown never extends source freshness.
|
| 54 |
+
cached = ResearchResult.model_validate(event["result"]) if event.get("result") else ResearchResult()
|
| 55 |
+
context = self.forecast.run(snapshot, now=now)
|
| 56 |
+
_, cached, _ = self.validation.validate(context, snapshot, cached, now=now)
|
| 57 |
+
self.state(status="CACHED" if cached.evidence else "IDLE", monitor="done", forecast="done", trigger="done", validation="done", decision="idle",
|
| 58 |
+
event_id=event["id"], cache_reason="Repeated event suppressed; no provider calls.")
|
| 59 |
+
return {**trigger, "cached": bool(cached.evidence), "suppressed": True}
|
| 60 |
+
self.state(status="SEARCHING", monitor="done", forecast="done", trigger="done", validation="idle", decision="idle", event_id=event["id"])
|
| 61 |
+
try:
|
| 62 |
+
question = "Explain the latest Bitcoin market context. Research topics: " + ", ".join(trigger["triggered_by"])
|
| 63 |
+
result = self.research.run(question, trigger_types=trigger["triggered_by"])
|
| 64 |
+
self.state(status="CACHED" if result.cached else "SEARCHING", validation="running", decision="idle", event_id=event["id"])
|
| 65 |
+
context = self.forecast.run(snapshot, now=now)
|
| 66 |
+
context, result, validation = self.validation.validate(context, snapshot, result)
|
| 67 |
+
useful = any(item.recency_verified and item.source_quality in {"primary", "established"} for item in result.evidence)
|
| 68 |
+
summary, llm_status = None, None
|
| 69 |
+
if useful:
|
| 70 |
+
self.state(status="CACHED" if result.cached else "SEARCHING", validation="done", decision="running", event_id=event["id"])
|
| 71 |
+
answer, llm_status, _ = self.decision.run(question, [], context, result, validation)
|
| 72 |
+
if llm_status == "ok":
|
| 73 |
+
summary = answer
|
| 74 |
+
failed = result.status == "unavailable" or llm_status in {"unavailable", "invalid_output"}
|
| 75 |
+
self.store.finish(event["id"], "error" if failed else "done" if useful else "empty", result.model_dump(), summary, llm_status)
|
| 76 |
+
self.state(status="ERROR" if failed else "CACHED" if result.cached else "IDLE", monitor="done", forecast="done", trigger="done", validation="done",
|
| 77 |
+
decision="error" if failed and useful else "done" if useful else "idle", event_id=event["id"])
|
| 78 |
+
except Exception:
|
| 79 |
+
# Forecasts and the scan already committed; research must never undo them.
|
| 80 |
+
self.store.finish(event["id"], "error")
|
| 81 |
+
self.state(status="ERROR", monitor="done", forecast="done", trigger="done", validation="error", decision="idle", event_id=event["id"], error="Context research failed; market forecasts remain saved.")
|
| 82 |
+
return trigger
|
app/services/monitor_schedule.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""UTC cadence, aligned two minutes after closed hourly candle boundaries."""
|
| 2 |
+
from datetime import datetime, timedelta, timezone
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def next_cycle(now, interval_minutes=60):
|
| 7 |
+
anchor = datetime(1970, 1, 1, 0, 2, tzinfo=timezone.utc)
|
| 8 |
+
seconds = float(interval_minutes) * 60
|
| 9 |
+
slot = math.floor((now - anchor).total_seconds() / seconds) + 1
|
| 10 |
+
return anchor + timedelta(seconds=slot * seconds)
|
app/services/monitor_worker_runtime.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Browser-independent scheduler; API owns its durable lease and due time."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import os
|
| 4 |
+
import signal
|
| 5 |
+
import threading
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
import requests
|
| 9 |
+
from .monitor_schedule import next_cycle
|
| 10 |
+
|
| 11 |
+
UTC = timezone.utc
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def next_hourly_run(now):
|
| 15 |
+
return next_cycle(now, 60)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class MonitoringWorker:
|
| 19 |
+
def __init__(self, api, session=None, now=None):
|
| 20 |
+
self.api = api.rstrip("/")
|
| 21 |
+
# Per-call sessions keep heartbeat and the long scan request independent.
|
| 22 |
+
self.session = session or requests
|
| 23 |
+
self.now = now or (lambda: datetime.now(UTC))
|
| 24 |
+
self.worker_id = uuid.uuid4().hex
|
| 25 |
+
self.stop = threading.Event()
|
| 26 |
+
|
| 27 |
+
def heartbeat(self):
|
| 28 |
+
response = self.session.post(self.api + "/monitoring/heartbeat", json={"worker_id": self.worker_id}, timeout=10)
|
| 29 |
+
response.raise_for_status()
|
| 30 |
+
return response.json()
|
| 31 |
+
|
| 32 |
+
def step(self):
|
| 33 |
+
status = self.heartbeat()
|
| 34 |
+
if not status.get("accepted"):
|
| 35 |
+
return "standby"
|
| 36 |
+
due = status.get("next_cycle_due")
|
| 37 |
+
if due and self.now() < datetime.fromisoformat(due):
|
| 38 |
+
return "waiting"
|
| 39 |
+
response = self.session.post(self.api + "/monitoring/run", json={
|
| 40 |
+
"trigger": "hourly", "worker_id": self.worker_id,
|
| 41 |
+
"use_gdelt": os.getenv("MONITOR_USE_GDELT", "true").lower() == "true"}, timeout=1500)
|
| 42 |
+
response.raise_for_status()
|
| 43 |
+
return response.json()["status"]
|
| 44 |
+
|
| 45 |
+
def run(self):
|
| 46 |
+
def renew():
|
| 47 |
+
while not self.stop.wait(25):
|
| 48 |
+
try:
|
| 49 |
+
self.heartbeat()
|
| 50 |
+
except (requests.RequestException, ValueError):
|
| 51 |
+
pass
|
| 52 |
+
thread = threading.Thread(target=renew, daemon=True)
|
| 53 |
+
thread.start()
|
| 54 |
+
try:
|
| 55 |
+
while not self.stop.is_set():
|
| 56 |
+
try:
|
| 57 |
+
result = self.step()
|
| 58 |
+
if result not in {"waiting", "standby"}:
|
| 59 |
+
print(f"Monitoring cycle: {result}", flush=True)
|
| 60 |
+
except (requests.RequestException, ValueError, KeyError) as error:
|
| 61 |
+
print(f"Monitoring API unavailable ({type(error).__name__}); retry shortly.", flush=True)
|
| 62 |
+
self.stop.wait(10)
|
| 63 |
+
finally:
|
| 64 |
+
self.stop.set()
|
| 65 |
+
thread.join(timeout=12)
|
| 66 |
+
try:
|
| 67 |
+
self.session.post(self.api + "/monitoring/worker/release", json={"worker_id": self.worker_id}, timeout=5)
|
| 68 |
+
except requests.RequestException:
|
| 69 |
+
pass
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def main():
|
| 73 |
+
worker = MonitoringWorker(os.getenv("BTC_API_URL", "http://btc-api:8000"))
|
| 74 |
+
signal.signal(signal.SIGTERM, lambda *_: worker.stop.set())
|
| 75 |
+
signal.signal(signal.SIGINT, lambda *_: worker.stop.set())
|
| 76 |
+
worker.run()
|
app/services/monitoring_backup.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Optional private Hub snapshots for Spaces with ephemeral local disks.
|
| 2 |
+
|
| 3 |
+
SQLite remains on local disk. Only consistent backup files are uploaded;
|
| 4 |
+
we never run SQLite/WAL directly on an object-storage mount.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import shutil
|
| 10 |
+
import sqlite3
|
| 11 |
+
import tempfile
|
| 12 |
+
import threading
|
| 13 |
+
from contextlib import closing
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class MonitoringBackup:
|
| 18 |
+
def __init__(self, repo=None, client=None, downloader=None):
|
| 19 |
+
self.repo = repo if repo is not None else os.getenv("MONITOR_BACKUP_REPO", "").strip()
|
| 20 |
+
self.token = os.getenv("MONITOR_BACKUP_TOKEN") or os.getenv("HF_TOKEN")
|
| 21 |
+
self.client = client
|
| 22 |
+
self.downloader = downloader
|
| 23 |
+
self.revision = None
|
| 24 |
+
self.lock = threading.Lock()
|
| 25 |
+
|
| 26 |
+
def api(self):
|
| 27 |
+
if self.client is None:
|
| 28 |
+
from huggingface_hub import HfApi
|
| 29 |
+
self.client = HfApi(token=self.token)
|
| 30 |
+
return self.client
|
| 31 |
+
|
| 32 |
+
def restore(self, path):
|
| 33 |
+
path = Path(path)
|
| 34 |
+
if not self.repo or (path.exists() and path.stat().st_size):
|
| 35 |
+
return False
|
| 36 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
try:
|
| 38 |
+
info = self.api().repo_info(self.repo, repo_type="dataset")
|
| 39 |
+
if not info.private:
|
| 40 |
+
raise RuntimeError("Monitoring backup repository must be private")
|
| 41 |
+
self.revision = info.sha
|
| 42 |
+
download = self.downloader
|
| 43 |
+
if download is None:
|
| 44 |
+
from huggingface_hub import hf_hub_download
|
| 45 |
+
download = hf_hub_download
|
| 46 |
+
source = download(repo_id=self.repo, filename="monitoring.sqlite3", repo_type="dataset",
|
| 47 |
+
revision=self.revision, token=self.token, etag_timeout=15)
|
| 48 |
+
# Validate before replacing anything; a failed restore cannot create
|
| 49 |
+
# an empty database that would later overwrite the durable history.
|
| 50 |
+
with closing(sqlite3.connect(f"file:{Path(source).as_posix()}?mode=ro", uri=True)) as db:
|
| 51 |
+
if db.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
|
| 52 |
+
raise RuntimeError("Invalid monitoring backup")
|
| 53 |
+
db.execute("SELECT id,probability_up,issued_at FROM forecasts LIMIT 1")
|
| 54 |
+
temporary = path.with_suffix(".restoring")
|
| 55 |
+
shutil.copyfile(source, temporary)
|
| 56 |
+
os.replace(temporary, path)
|
| 57 |
+
return True
|
| 58 |
+
except Exception:
|
| 59 |
+
raise RuntimeError("Monitoring history restore failed. Check private backup access; refusing to start with an empty history.") from None
|
| 60 |
+
|
| 61 |
+
def save(self, store):
|
| 62 |
+
if not self.repo or not self.lock.acquire(blocking=False):
|
| 63 |
+
return
|
| 64 |
+
try:
|
| 65 |
+
if self.revision is None:
|
| 66 |
+
info = self.api().repo_info(self.repo, repo_type="dataset")
|
| 67 |
+
if not info.private:
|
| 68 |
+
raise RuntimeError("Monitoring backup must be private")
|
| 69 |
+
self.revision = info.sha
|
| 70 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 71 |
+
target = Path(directory) / "monitoring.sqlite3"
|
| 72 |
+
with store._connection() as source, closing(sqlite3.connect(target)) as destination:
|
| 73 |
+
source.backup(destination)
|
| 74 |
+
# A downloadable snapshot must be self-contained; source
|
| 75 |
+
# WAL mode otherwise leaves cleanup writes in a sidecar.
|
| 76 |
+
destination.execute("PRAGMA journal_mode=DELETE")
|
| 77 |
+
# Runtime ownership cannot survive a container replacement.
|
| 78 |
+
destination.execute("DELETE FROM worker_lock")
|
| 79 |
+
destination.execute("DELETE FROM operation_leases")
|
| 80 |
+
destination.execute("DELETE FROM state WHERE key IN ('worker','active_run')")
|
| 81 |
+
destination.execute("UPDATE research_events SET status='error' WHERE status='running'")
|
| 82 |
+
destination.commit()
|
| 83 |
+
commit = self.api().upload_file(path_or_fileobj=str(target), path_in_repo="monitoring.sqlite3",
|
| 84 |
+
repo_id=self.repo, repo_type="dataset", parent_commit=self.revision,
|
| 85 |
+
commit_message="Save monitoring history and event state")
|
| 86 |
+
self.revision = commit.oid
|
| 87 |
+
store.set_state("backup", {"status": "OK", "saved_at": datetime.now(timezone.utc).isoformat()})
|
| 88 |
+
except Exception:
|
| 89 |
+
store.set_state("backup", {"status": "ERROR", "error": "Durable backup unavailable; local history remains saved. Check access or conflicting writers."})
|
| 90 |
+
finally:
|
| 91 |
+
self.lock.release()
|
app/services/monitoring_events_store.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Additive SQLite storage for scans, research references, caches and leases."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import json
|
| 4 |
+
import uuid
|
| 5 |
+
from datetime import timedelta
|
| 6 |
+
from app.monitoring_store import _utc, _iso, _json
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class EventStore:
|
| 10 |
+
def __init__(self, store):
|
| 11 |
+
self.store = store
|
| 12 |
+
with store._connection() as db:
|
| 13 |
+
db.executescript("""
|
| 14 |
+
CREATE TABLE IF NOT EXISTS monitoring_scans (
|
| 15 |
+
id INTEGER PRIMARY KEY, run_id INTEGER UNIQUE NOT NULL,
|
| 16 |
+
scanned_at TEXT NOT NULL, market_timestamp TEXT, payload TEXT NOT NULL,
|
| 17 |
+
trigger_result TEXT NOT NULL, research_event_id TEXT
|
| 18 |
+
);
|
| 19 |
+
CREATE TABLE IF NOT EXISTS research_events (
|
| 20 |
+
id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, market_timestamp TEXT,
|
| 21 |
+
created_at TEXT NOT NULL, status TEXT NOT NULL, reasons TEXT NOT NULL,
|
| 22 |
+
result TEXT, summary TEXT, llm_status TEXT, completed_at TEXT
|
| 23 |
+
);
|
| 24 |
+
CREATE INDEX IF NOT EXISTS research_fingerprint ON research_events(fingerprint,created_at);
|
| 25 |
+
CREATE TABLE IF NOT EXISTS research_query_cache (
|
| 26 |
+
key TEXT PRIMARY KEY, expires_at TEXT NOT NULL, payload TEXT NOT NULL
|
| 27 |
+
);
|
| 28 |
+
CREATE TABLE IF NOT EXISTS operation_leases (
|
| 29 |
+
name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at TEXT NOT NULL
|
| 30 |
+
);
|
| 31 |
+
""")
|
| 32 |
+
|
| 33 |
+
def acquire(self, name, owner, ttl=120, now=None):
|
| 34 |
+
now = _utc(now)
|
| 35 |
+
with self.store._connection() as db:
|
| 36 |
+
db.execute("BEGIN IMMEDIATE")
|
| 37 |
+
row = db.execute("SELECT * FROM operation_leases WHERE name=?", (name,)).fetchone()
|
| 38 |
+
if row and row["owner"] != owner and row["expires_at"] > _iso(now):
|
| 39 |
+
return False
|
| 40 |
+
db.execute("INSERT INTO operation_leases VALUES(?,?,?) ON CONFLICT(name) DO UPDATE SET owner=excluded.owner,expires_at=excluded.expires_at",
|
| 41 |
+
(name, owner, _iso(now + timedelta(seconds=ttl))))
|
| 42 |
+
return True
|
| 43 |
+
|
| 44 |
+
def owns(self, name, owner, now=None):
|
| 45 |
+
with self.store._connection() as db:
|
| 46 |
+
row = db.execute("SELECT 1 FROM operation_leases WHERE name=? AND owner=? AND expires_at>?", (name, owner, _iso(now))).fetchone()
|
| 47 |
+
return bool(row)
|
| 48 |
+
|
| 49 |
+
def release(self, name, owner):
|
| 50 |
+
with self.store._connection() as db:
|
| 51 |
+
db.execute("DELETE FROM operation_leases WHERE name=? AND owner=?", (name, owner))
|
| 52 |
+
|
| 53 |
+
def scan(self, run_id, payload, trigger, now=None):
|
| 54 |
+
with self.store._connection() as db:
|
| 55 |
+
db.execute("INSERT INTO monitoring_scans(run_id,scanned_at,market_timestamp,payload,trigger_result) VALUES(?,?,?,?,?) ON CONFLICT(run_id) DO NOTHING",
|
| 56 |
+
(run_id, _iso(now), payload.get("market_timestamp"), _json(payload), _json(trigger)))
|
| 57 |
+
return db.execute("SELECT id FROM monitoring_scans WHERE run_id=?", (run_id,)).fetchone()[0]
|
| 58 |
+
|
| 59 |
+
def scans(self, limit=50):
|
| 60 |
+
with self.store._connection() as db:
|
| 61 |
+
rows = db.execute("SELECT * FROM monitoring_scans ORDER BY id DESC LIMIT ?", (max(1, min(int(limit), 200)),)).fetchall()
|
| 62 |
+
return [{**dict(row), "payload": json.loads(row["payload"]), "trigger_result": json.loads(row["trigger_result"])} for row in rows]
|
| 63 |
+
|
| 64 |
+
def link(self, scan_id, event_id):
|
| 65 |
+
with self.store._connection() as db:
|
| 66 |
+
db.execute("UPDATE monitoring_scans SET research_event_id=? WHERE id=?", (event_id, scan_id))
|
| 67 |
+
|
| 68 |
+
@staticmethod
|
| 69 |
+
def event(row):
|
| 70 |
+
if not row:
|
| 71 |
+
return None
|
| 72 |
+
result = dict(row)
|
| 73 |
+
for field in ("reasons", "result"):
|
| 74 |
+
result[field] = json.loads(result[field]) if result[field] else None
|
| 75 |
+
return result
|
| 76 |
+
|
| 77 |
+
def claim(self, fingerprint, market_timestamp, reasons, cooldown_minutes, now=None):
|
| 78 |
+
now = _utc(now)
|
| 79 |
+
with self.store._connection() as db:
|
| 80 |
+
db.execute("BEGIN IMMEDIATE")
|
| 81 |
+
row = db.execute("SELECT * FROM research_events WHERE fingerprint=? ORDER BY created_at DESC LIMIT 1", (fingerprint,)).fetchone()
|
| 82 |
+
if row and (row["market_timestamp"] == market_timestamp or row["created_at"] > _iso(now - timedelta(minutes=cooldown_minutes))):
|
| 83 |
+
return self.event(row), False
|
| 84 |
+
event_id = uuid.uuid4().hex
|
| 85 |
+
db.execute("INSERT INTO research_events(id,fingerprint,market_timestamp,created_at,status,reasons) VALUES(?,?,?,?,?,?)",
|
| 86 |
+
(event_id, fingerprint, market_timestamp, _iso(now), "running", _json(reasons)))
|
| 87 |
+
row = db.execute("SELECT * FROM research_events WHERE id=?", (event_id,)).fetchone()
|
| 88 |
+
return self.event(row), True
|
| 89 |
+
|
| 90 |
+
def finish(self, event_id, status, result=None, summary=None, llm_status=None, now=None):
|
| 91 |
+
with self.store._connection() as db:
|
| 92 |
+
db.execute("UPDATE research_events SET status=?,result=?,summary=?,llm_status=?,completed_at=? WHERE id=?",
|
| 93 |
+
(status, _json(result) if result is not None else None, summary, llm_status, _iso(now), event_id))
|
| 94 |
+
|
| 95 |
+
def latest_event(self, with_evidence=False):
|
| 96 |
+
with self.store._connection() as db:
|
| 97 |
+
row = db.execute("SELECT * FROM research_events " + ("WHERE result IS NOT NULL " if with_evidence else "") + "ORDER BY created_at DESC LIMIT 1").fetchone()
|
| 98 |
+
return self.event(row)
|
| 99 |
+
|
| 100 |
+
def cache_get(self, key, now=None):
|
| 101 |
+
with self.store._connection() as db:
|
| 102 |
+
row = db.execute("SELECT payload FROM research_query_cache WHERE key=? AND expires_at>?", (key, _iso(now))).fetchone()
|
| 103 |
+
return json.loads(row[0]) if row else None
|
| 104 |
+
|
| 105 |
+
def cache_put(self, key, payload, seconds, now=None):
|
| 106 |
+
now = _utc(now)
|
| 107 |
+
with self.store._connection() as db:
|
| 108 |
+
db.execute("DELETE FROM research_query_cache WHERE expires_at<=?", (_iso(now),))
|
| 109 |
+
db.execute("INSERT INTO research_query_cache VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET expires_at=excluded.expires_at,payload=excluded.payload",
|
| 110 |
+
(key, _iso(now + timedelta(seconds=seconds)), _json(payload)))
|
| 111 |
+
db.execute("DELETE FROM research_query_cache WHERE key NOT IN (SELECT key FROM research_query_cache ORDER BY expires_at DESC LIMIT 256)")
|
app/services/research_triggers.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic monitoring features and event rules; never model inputs."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import math
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def finite(value):
|
| 10 |
+
try:
|
| 11 |
+
value = float(value)
|
| 12 |
+
return value if math.isfinite(value) else None
|
| 13 |
+
except (TypeError, ValueError):
|
| 14 |
+
return None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def market_metrics(candles, snapshot, volatility_multiplier=1.5):
|
| 18 |
+
# Match existing market features: sample std of hourly log returns and
|
| 19 |
+
# fractional close-to-close momentum. Use completed, contiguous candles.
|
| 20 |
+
frame = candles.sort_values("timestamp").drop_duplicates("timestamp").copy()
|
| 21 |
+
times = pd.to_datetime(frame["timestamp"], utc=True)
|
| 22 |
+
close = pd.Series(pd.to_numeric(frame["close"], errors="coerce").values, index=times)
|
| 23 |
+
close = close.asfreq("h")
|
| 24 |
+
returns = close.map(lambda x: math.log(x) if pd.notna(x) and x > 0 else float("nan")).diff()
|
| 25 |
+
def change(hours):
|
| 26 |
+
if len(close) <= hours or pd.isna(close.iloc[-hours-1]) or close.iloc[-hours-1] <= 0:
|
| 27 |
+
return None
|
| 28 |
+
return finite((close.iloc[-1] / close.iloc[-hours-1] - 1) * 100)
|
| 29 |
+
volatility = finite(returns.rolling(24, min_periods=24).std().iloc[-1])
|
| 30 |
+
# Prior 168 hourly returns: baseline excludes the current 24-hour window.
|
| 31 |
+
baseline = finite(returns.shift(24).rolling(168, min_periods=168).std().iloc[-1])
|
| 32 |
+
move24 = change(24)
|
| 33 |
+
market = snapshot.get("market", {})
|
| 34 |
+
regime = market.get("regime")
|
| 35 |
+
basis = "upstream" if regime else "monitor-only: 24h momentum versus 24h realized volatility"
|
| 36 |
+
if not regime and volatility is not None and baseline is not None and baseline > 0 and move24 is not None:
|
| 37 |
+
# Fixed descriptive regime boundaries, independent of research thresholds.
|
| 38 |
+
# These labels never alter model features, probabilities or reliability.
|
| 39 |
+
if volatility >= baseline * volatility_multiplier:
|
| 40 |
+
regime = "HIGH_VOL"
|
| 41 |
+
elif move24 / 100 > volatility * math.sqrt(24):
|
| 42 |
+
regime = "BULL"
|
| 43 |
+
elif move24 / 100 < -volatility * math.sqrt(24):
|
| 44 |
+
regime = "BEAR"
|
| 45 |
+
else:
|
| 46 |
+
regime = "RANGE"
|
| 47 |
+
return {"market_timestamp": times.iloc[-1].isoformat(), "price": finite(close.iloc[-1]),
|
| 48 |
+
"price_change_1h": change(1), "price_change_24h": move24,
|
| 49 |
+
"volatility": volatility, "volatility_baseline": baseline,
|
| 50 |
+
"momentum": None if move24 is None else move24 / 100,
|
| 51 |
+
"regime": regime, "regime_basis": basis}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class ResearchTriggerEngine:
|
| 55 |
+
def __init__(self, settings):
|
| 56 |
+
self.settings = settings
|
| 57 |
+
|
| 58 |
+
def evaluate(self, current, previous=None):
|
| 59 |
+
previous = previous or {}
|
| 60 |
+
reasons, details = [], {}
|
| 61 |
+
# Repeated scans of the same closed candle do not constitute new events.
|
| 62 |
+
fresh = current.get("data_fresh") is True
|
| 63 |
+
changed_candle = current.get("market_timestamp") != previous.get("market_timestamp")
|
| 64 |
+
if not fresh or not changed_candle:
|
| 65 |
+
return {"should_research": False, "triggered_by": [], "details": {}, "fingerprint": None}
|
| 66 |
+
move = finite(current.get("price_change_1h"))
|
| 67 |
+
if move is not None and abs(move) >= self.settings.price_move_trigger_pct:
|
| 68 |
+
reasons.append("large_price_move")
|
| 69 |
+
details["price_change_1h"] = move
|
| 70 |
+
old_regime, regime = previous.get("regime"), current.get("regime")
|
| 71 |
+
if old_regime and regime and old_regime != regime:
|
| 72 |
+
reasons.append("regime_change")
|
| 73 |
+
details.update(previous_regime=old_regime, current_regime=regime)
|
| 74 |
+
vol, baseline = finite(current.get("volatility")), finite(current.get("volatility_baseline"))
|
| 75 |
+
if vol is not None and baseline is not None and baseline > 0 and vol >= baseline * self.settings.volatility_trigger_multiplier:
|
| 76 |
+
reasons.append("unusual_volatility")
|
| 77 |
+
details.update(volatility=vol, volatility_baseline=baseline)
|
| 78 |
+
sentiment, old_sentiment = finite(current.get("sentiment")), finite(previous.get("sentiment"))
|
| 79 |
+
if sentiment is not None and old_sentiment is not None:
|
| 80 |
+
delta = sentiment - old_sentiment
|
| 81 |
+
# Meaningful reversal requires both sides outside a small neutral band.
|
| 82 |
+
reversal = sentiment * old_sentiment < 0 and min(abs(sentiment), abs(old_sentiment)) >= self.settings.sentiment_shift_threshold / 2
|
| 83 |
+
if abs(delta) >= self.settings.sentiment_shift_threshold or reversal:
|
| 84 |
+
reasons.append("sentiment_shift")
|
| 85 |
+
details.update(sentiment_change=delta, sentiment=sentiment)
|
| 86 |
+
transitions = {}
|
| 87 |
+
for horizon, forecast in current.get("forecasts", {}).items():
|
| 88 |
+
old = previous.get("forecasts", {}).get(horizon, {})
|
| 89 |
+
before, after = old.get("signal_state"), forecast.get("signal_state")
|
| 90 |
+
if before and after != before and after in {"UP", "DOWN", "NO_SIGNAL"} and (before in {"UP", "DOWN"} or after in {"UP", "DOWN"}):
|
| 91 |
+
transitions[horizon] = {"before": before, "after": after}
|
| 92 |
+
if transitions:
|
| 93 |
+
reasons.append("forecast_state_change")
|
| 94 |
+
details["signal_transitions"] = transitions
|
| 95 |
+
signature = {"reasons": sorted(reasons), "regime": regime,
|
| 96 |
+
"move_bucket": None if move is None else math.trunc(move / self.settings.price_move_trigger_pct),
|
| 97 |
+
"sentiment_direction": None if sentiment is None else (0 if abs(sentiment) < self.settings.sentiment_shift_threshold / 2 else 1 if sentiment > 0 else -1),
|
| 98 |
+
"signals": {h: f.get("signal_state") for h, f in current.get("forecasts", {}).items()}}
|
| 99 |
+
return {"should_research": bool(reasons), "triggered_by": reasons, "details": details,
|
| 100 |
+
"fingerprint": hashlib.sha256(json.dumps(signature, sort_keys=True).encode()).hexdigest() if reasons else None}
|
app/space_server.py
CHANGED
|
@@ -22,7 +22,7 @@ def create_app():
|
|
| 22 |
worker = None
|
| 23 |
if os.getenv("SPACE_START_MONITOR", "true").lower() == "true":
|
| 24 |
env = dict(os.environ, BTC_API_URL=local_api)
|
| 25 |
-
for key in ("OPENAI_API_KEY", "TAVILY_API_KEY", "ALPHA_VANTAGE_API_KEY"):
|
| 26 |
env.pop(key, None)
|
| 27 |
worker = subprocess.Popen([sys.executable, "-m", "app.monitor_worker"], env=env)
|
| 28 |
try:
|
|
|
|
| 22 |
worker = None
|
| 23 |
if os.getenv("SPACE_START_MONITOR", "true").lower() == "true":
|
| 24 |
env = dict(os.environ, BTC_API_URL=local_api)
|
| 25 |
+
for key in ("OPENAI_API_KEY", "TAVILY_API_KEY", "ALPHA_VANTAGE_API_KEY", "MONITOR_BACKUP_TOKEN", "HF_TOKEN", "HUGGINGFACEHUB_API_TOKEN"):
|
| 26 |
env.pop(key, None)
|
| 27 |
worker = subprocess.Popen([sys.executable, "-m", "app.monitor_worker"], env=env)
|
| 28 |
try:
|
app/static/monitor/analyst.css
CHANGED
|
@@ -1 +1,3 @@
|
|
| 1 |
.ai-heading{padding-bottom:24px}.ai-heading h1{font-size:34px}.live-state{font:10px var(--mono);letter-spacing:1px;color:var(--muted);border:1px solid var(--border);padding:8px 10px;border-radius:4px;white-space:nowrap}.live-state.live{color:var(--green);background:#152b24;border-color:#28513f}.market-strip{display:grid;grid-template-columns:1.25fr 1fr 1fr 1.2fr;border:1px solid var(--border);border-radius:7px;padding:20px 22px;background:linear-gradient(110deg,#142126,#13191d);gap:18px;align-items:center}.market-strip>div{min-width:0}.market-strip span{font:8px var(--mono);letter-spacing:.7px;color:#8c9ba4;display:block;line-height:1.6}.market-strip strong{display:block;font-size:12px;font-weight:500;margin-top:9px;overflow-wrap:anywhere}.market-price strong{font-size:27px;font-weight:550;letter-spacing:-.7px;color:#e9f5f8;font-variant-numeric:tabular-nums}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:28px 0 16px;gap:15px}.section-heading h2{font-size:15px;font-weight:550;margin:0}.section-heading p{font-size:11px;color:var(--muted);margin:7px 0 0}.forecast-cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.signal-card{padding:20px;border:1px solid var(--border);border-radius:7px;background:var(--panel);min-width:0;position:relative}.signal-card::before{content:'';position:absolute;top:-1px;left:20px;width:36px;height:2px;background:#5c717b}.signal-card.qualified-up::before{background:var(--green)}.signal-card.qualified-down::before{background:var(--red)}.signal-card-top{display:flex;align-items:center;justify-content:space-between;gap:8px;font:10px var(--mono);color:#b3c5cd;letter-spacing:.7px}.signal-state{font:8px var(--mono);border:1px solid #3b454b;color:#9aaab3;border-radius:3px;padding:4px 6px}.signal-title{font-size:19px;line-height:1.3;font-weight:500;letter-spacing:-.4px;margin:21px 0 9px;color:#c7d4db}.qualified-up .signal-title{color:var(--green)}.qualified-down .signal-title{color:var(--red)}.signal-reason{color:#8398a4;font-size:10px;line-height:1.7;margin:0 0 18px;min-height:34px}.signal-numbers{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid #283138;border-bottom:1px solid #283138;padding:14px 0;gap:10px}.signal-numbers span{display:block;color:#8b9fa9;font-size:10px}.signal-numbers strong{display:block;font:20px var(--mono);margin-top:8px;letter-spacing:-.5px;color:#d0dce2}.signal-facts{margin:15px 0 0;font-size:10px;display:grid;gap:10px}.signal-facts>div{display:flex;justify-content:space-between;gap:12px}.signal-facts dt{color:#77909f;flex-shrink:0}.signal-facts dd{margin:0;text-align:right;color:#afc1cc;overflow-wrap:anywhere}.signal-note{font-size:10px;color:#7893a2;line-height:1.8;margin:13px 0 24px}.analyst-layout{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(300px,1fr);gap:18px;align-items:start}.analyst-panel .eyebrow,.evidence-panel .eyebrow{display:block;font-size:8px;letter-spacing:1.3px;margin-bottom:9px}.analyst-panel .panel-heading h2,.evidence-panel .panel-heading h2{font-size:16px}.provider-status{font:9px var(--mono);line-height:1.7;color:#85a3b2;padding:0 22px 15px;overflow-wrap:anywhere}.agent-strip{display:flex;gap:7px;flex-wrap:wrap;padding:12px 22px;border-top:1px solid #243139;border-bottom:1px solid #243139;background:#121b20}.agent-strip span{font:9px var(--mono);color:#8499a6;border:1px solid #2b3b44;border-radius:4px;padding:5px 7px}.agent-strip span[data-state=running]{color:var(--cyan);border-color:#2a6570;background:#133139}.agent-strip span[data-state=done]{color:#a2c6b9}.agent-strip span[data-state=unavailable],.agent-strip span[data-state=error]{color:#c4b591}.chat-log{min-height:300px;max-height:590px;overflow-y:auto;padding:22px;scrollbar-color:#3b525f transparent}.chat-welcome{display:flex;align-items:center;flex-direction:column;text-align:center;padding:24px 20px}.analyst-orb{display:grid;place-items:center;width:53px;height:53px;background:radial-gradient(circle,#1e4148,#142329);border:1px solid #2b525d;border-radius:14px;font-size:29px;color:var(--cyan);box-shadow:0 0 32px #21bad00c}.chat-welcome h3{font-size:19px;font-weight:500;letter-spacing:-.4px;margin:18px 0 8px}.chat-welcome p{font-size:11px;line-height:1.9;max-width:280px;margin:0;color:#91a5b2}.chat-welcome>span{font:8px var(--mono);color:#657e8d;letter-spacing:.6px;margin-top:19px}.chat-message{margin:0 0 20px}.chat-message:last-child{margin-bottom:0}.message-label{font:8px var(--mono);color:#7c96a6;letter-spacing:1px;margin-bottom:8px;display:flex;justify-content:space-between;gap:10px}.message-content{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:1.85;color:#c2d2dc;padding:15px 17px;background:#172229;border:1px solid #283b46;border-radius:7px}.chat-message.user{margin-left:42px}.chat-message.user .message-content{background:#152b32;border-color:#244752;color:#d1e9ef}.chat-message.assistant .message-label{color:var(--cyan)}.message-sources{display:flex;flex-wrap:wrap;gap:7px;margin-top:10px}.source-link{font-size:10px;color:#9dcedb;border:1px solid #33515e;background:#172830;border-radius:4px;padding:6px 8px}.source-link:hover{background:#213c47}.quick-questions{display:flex;gap:7px;flex-wrap:wrap;padding:0 22px 18px}.quick-questions button{font-size:9px;color:#9bb5c4;background:#19252c;border:1px solid #2c3e49;padding:7px 9px;border-radius:5px;text-align:left}.quick-questions button:hover{color:#c6eff4;border-color:#386b79;background:#19313a}#chat-form{border-top:1px solid #293741;padding:17px 22px 16px}.chat-compose{display:flex;gap:10px;align-items:flex-end;border:1px solid #365261;background:#101a20;border-radius:6px;padding:10px}.chat-compose:focus-within{border-color:#47a3b6;box-shadow:0 0 0 2px #28c4d413}.chat-compose textarea{background:transparent;border:0;color:#d8e6ed;font:12px var(--sans);line-height:1.7;resize:vertical;min-height:43px;max-height:150px;width:100%;padding:3px 4px;outline:none}.chat-compose textarea::placeholder{color:#66818f}.chat-compose textarea:focus-visible{outline:none}.chat-compose .primary-button{font-size:10px;flex-shrink:0}.chat-foot{display:flex;justify-content:space-between;gap:12px;font-size:8px;line-height:1.7;color:#6d8c9e;margin-top:10px}.evidence-disclaimer{margin:0 20px 16px;padding:12px 13px;border-left:2px solid #376c7b;background:#13242b;font-size:10px;line-height:1.8;color:#8fb0c0}.evidence-list{max-height:670px;overflow-y:auto;padding:0 20px 4px;scrollbar-color:#3b525f transparent}.evidence-item{padding:17px 0;border-top:1px solid #2a363e}.evidence-tags{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:10px}.evidence-tag{font:8px var(--mono);letter-spacing:.4px;padding:4px 6px;border-radius:3px;background:#23323b;color:#92afc0}.evidence-tag.bullish{background:#19382e;color:#88c9b2}.evidence-tag.bearish{background:#392a30;color:#d5a0a9}.evidence-item h3{font-size:12px;font-weight:500;line-height:1.7;margin:0 0 9px}.evidence-item h3 a:hover{color:var(--cyan)}.evidence-meta{font:8px var(--mono);line-height:1.8;color:#859eac;overflow-wrap:anywhere}.evidence-item p{font-size:10px;color:#9bb0be;line-height:1.85;margin:10px 0}.evidence-item .source-link{display:inline-block;margin-top:4px}.evidence-empty{padding:35px 8px 42px;text-align:center;display:flex;align-items:center;flex-direction:column}.evidence-empty>span{color:#487888;font-size:33px;margin-bottom:17px}.evidence-empty strong{font-size:13px;font-weight:500}.evidence-empty p{font-size:11px;line-height:1.9;color:#7c99aa;max-width:260px}.evidence-footer{border-top:1px solid #27363f;padding:13px 20px;font-size:9px;line-height:1.7;color:#7996a6}.monitoring-heading{padding-top:34px}.monitoring-heading h2{font-size:23px;font-weight:500;letter-spacing:-.5px;margin:0;display:flex;align-items:center;gap:12px}.prediction-direction,.prediction-direction.down{color:#becfd8}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:1150px){.signal-card{padding:17px 15px}.signal-title{font-size:16px}.signal-facts{font-size:9px}.analyst-layout{grid-template-columns:minmax(0,1.3fr) minmax(280px,1fr)}.chat-foot>span:last-child{display:none}}@media(max-width:1000px){.analyst-layout{grid-template-columns:1fr}.evidence-list{max-height:450px}.market-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.forecast-cards{gap:10px}.signal-facts>div{display:block}.signal-facts dd{text-align:left;margin-top:4px}.signal-title{min-height:42px}.ai-heading h1{font-size:28px}}@media(max-width:700px){.forecast-cards{grid-template-columns:1fr}.signal-title{min-height:0}.signal-reason{min-height:0}.signal-facts>div{display:flex}.signal-facts dd{margin-top:0;text-align:right}.signal-card{padding:19px}.signal-title{font-size:21px}.market-strip{padding:18px}.ai-heading h1{font-size:25px}.ai-heading p{line-height:1.9}.live-state{font-size:8px;padding:7px}.chat-log{padding:17px;max-height:470px}.quick-questions{padding:0 17px 16px}#chat-form{padding:15px 17px}.agent-strip{padding:12px 17px}.provider-status{padding:0 17px 13px}.section-heading .subtle-tag{display:none}.chat-message.user{margin-left:20px}.monitoring-heading h2{font-size:21px}.sidebar{overflow-y:auto}}
|
|
|
|
|
|
|
|
|
| 1 |
.ai-heading{padding-bottom:24px}.ai-heading h1{font-size:34px}.live-state{font:10px var(--mono);letter-spacing:1px;color:var(--muted);border:1px solid var(--border);padding:8px 10px;border-radius:4px;white-space:nowrap}.live-state.live{color:var(--green);background:#152b24;border-color:#28513f}.market-strip{display:grid;grid-template-columns:1.25fr 1fr 1fr 1.2fr;border:1px solid var(--border);border-radius:7px;padding:20px 22px;background:linear-gradient(110deg,#142126,#13191d);gap:18px;align-items:center}.market-strip>div{min-width:0}.market-strip span{font:8px var(--mono);letter-spacing:.7px;color:#8c9ba4;display:block;line-height:1.6}.market-strip strong{display:block;font-size:12px;font-weight:500;margin-top:9px;overflow-wrap:anywhere}.market-price strong{font-size:27px;font-weight:550;letter-spacing:-.7px;color:#e9f5f8;font-variant-numeric:tabular-nums}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:28px 0 16px;gap:15px}.section-heading h2{font-size:15px;font-weight:550;margin:0}.section-heading p{font-size:11px;color:var(--muted);margin:7px 0 0}.forecast-cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.signal-card{padding:20px;border:1px solid var(--border);border-radius:7px;background:var(--panel);min-width:0;position:relative}.signal-card::before{content:'';position:absolute;top:-1px;left:20px;width:36px;height:2px;background:#5c717b}.signal-card.qualified-up::before{background:var(--green)}.signal-card.qualified-down::before{background:var(--red)}.signal-card-top{display:flex;align-items:center;justify-content:space-between;gap:8px;font:10px var(--mono);color:#b3c5cd;letter-spacing:.7px}.signal-state{font:8px var(--mono);border:1px solid #3b454b;color:#9aaab3;border-radius:3px;padding:4px 6px}.signal-title{font-size:19px;line-height:1.3;font-weight:500;letter-spacing:-.4px;margin:21px 0 9px;color:#c7d4db}.qualified-up .signal-title{color:var(--green)}.qualified-down .signal-title{color:var(--red)}.signal-reason{color:#8398a4;font-size:10px;line-height:1.7;margin:0 0 18px;min-height:34px}.signal-numbers{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid #283138;border-bottom:1px solid #283138;padding:14px 0;gap:10px}.signal-numbers span{display:block;color:#8b9fa9;font-size:10px}.signal-numbers strong{display:block;font:20px var(--mono);margin-top:8px;letter-spacing:-.5px;color:#d0dce2}.signal-facts{margin:15px 0 0;font-size:10px;display:grid;gap:10px}.signal-facts>div{display:flex;justify-content:space-between;gap:12px}.signal-facts dt{color:#77909f;flex-shrink:0}.signal-facts dd{margin:0;text-align:right;color:#afc1cc;overflow-wrap:anywhere}.signal-note{font-size:10px;color:#7893a2;line-height:1.8;margin:13px 0 24px}.analyst-layout{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(300px,1fr);gap:18px;align-items:start}.analyst-panel .eyebrow,.evidence-panel .eyebrow{display:block;font-size:8px;letter-spacing:1.3px;margin-bottom:9px}.analyst-panel .panel-heading h2,.evidence-panel .panel-heading h2{font-size:16px}.provider-status{font:9px var(--mono);line-height:1.7;color:#85a3b2;padding:0 22px 15px;overflow-wrap:anywhere}.agent-strip{display:flex;gap:7px;flex-wrap:wrap;padding:12px 22px;border-top:1px solid #243139;border-bottom:1px solid #243139;background:#121b20}.agent-strip span{font:9px var(--mono);color:#8499a6;border:1px solid #2b3b44;border-radius:4px;padding:5px 7px}.agent-strip span[data-state=running]{color:var(--cyan);border-color:#2a6570;background:#133139}.agent-strip span[data-state=done]{color:#a2c6b9}.agent-strip span[data-state=unavailable],.agent-strip span[data-state=error]{color:#c4b591}.chat-log{min-height:300px;max-height:590px;overflow-y:auto;padding:22px;scrollbar-color:#3b525f transparent}.chat-welcome{display:flex;align-items:center;flex-direction:column;text-align:center;padding:24px 20px}.analyst-orb{display:grid;place-items:center;width:53px;height:53px;background:radial-gradient(circle,#1e4148,#142329);border:1px solid #2b525d;border-radius:14px;font-size:29px;color:var(--cyan);box-shadow:0 0 32px #21bad00c}.chat-welcome h3{font-size:19px;font-weight:500;letter-spacing:-.4px;margin:18px 0 8px}.chat-welcome p{font-size:11px;line-height:1.9;max-width:280px;margin:0;color:#91a5b2}.chat-welcome>span{font:8px var(--mono);color:#657e8d;letter-spacing:.6px;margin-top:19px}.chat-message{margin:0 0 20px}.chat-message:last-child{margin-bottom:0}.message-label{font:8px var(--mono);color:#7c96a6;letter-spacing:1px;margin-bottom:8px;display:flex;justify-content:space-between;gap:10px}.message-content{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:1.85;color:#c2d2dc;padding:15px 17px;background:#172229;border:1px solid #283b46;border-radius:7px}.chat-message.user{margin-left:42px}.chat-message.user .message-content{background:#152b32;border-color:#244752;color:#d1e9ef}.chat-message.assistant .message-label{color:var(--cyan)}.message-sources{display:flex;flex-wrap:wrap;gap:7px;margin-top:10px}.source-link{font-size:10px;color:#9dcedb;border:1px solid #33515e;background:#172830;border-radius:4px;padding:6px 8px}.source-link:hover{background:#213c47}.quick-questions{display:flex;gap:7px;flex-wrap:wrap;padding:0 22px 18px}.quick-questions button{font-size:9px;color:#9bb5c4;background:#19252c;border:1px solid #2c3e49;padding:7px 9px;border-radius:5px;text-align:left}.quick-questions button:hover{color:#c6eff4;border-color:#386b79;background:#19313a}#chat-form{border-top:1px solid #293741;padding:17px 22px 16px}.chat-compose{display:flex;gap:10px;align-items:flex-end;border:1px solid #365261;background:#101a20;border-radius:6px;padding:10px}.chat-compose:focus-within{border-color:#47a3b6;box-shadow:0 0 0 2px #28c4d413}.chat-compose textarea{background:transparent;border:0;color:#d8e6ed;font:12px var(--sans);line-height:1.7;resize:vertical;min-height:43px;max-height:150px;width:100%;padding:3px 4px;outline:none}.chat-compose textarea::placeholder{color:#66818f}.chat-compose textarea:focus-visible{outline:none}.chat-compose .primary-button{font-size:10px;flex-shrink:0}.chat-foot{display:flex;justify-content:space-between;gap:12px;font-size:8px;line-height:1.7;color:#6d8c9e;margin-top:10px}.evidence-disclaimer{margin:0 20px 16px;padding:12px 13px;border-left:2px solid #376c7b;background:#13242b;font-size:10px;line-height:1.8;color:#8fb0c0}.evidence-list{max-height:670px;overflow-y:auto;padding:0 20px 4px;scrollbar-color:#3b525f transparent}.evidence-item{padding:17px 0;border-top:1px solid #2a363e}.evidence-tags{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:10px}.evidence-tag{font:8px var(--mono);letter-spacing:.4px;padding:4px 6px;border-radius:3px;background:#23323b;color:#92afc0}.evidence-tag.bullish{background:#19382e;color:#88c9b2}.evidence-tag.bearish{background:#392a30;color:#d5a0a9}.evidence-item h3{font-size:12px;font-weight:500;line-height:1.7;margin:0 0 9px}.evidence-item h3 a:hover{color:var(--cyan)}.evidence-meta{font:8px var(--mono);line-height:1.8;color:#859eac;overflow-wrap:anywhere}.evidence-item p{font-size:10px;color:#9bb0be;line-height:1.85;margin:10px 0}.evidence-item .source-link{display:inline-block;margin-top:4px}.evidence-empty{padding:35px 8px 42px;text-align:center;display:flex;align-items:center;flex-direction:column}.evidence-empty>span{color:#487888;font-size:33px;margin-bottom:17px}.evidence-empty strong{font-size:13px;font-weight:500}.evidence-empty p{font-size:11px;line-height:1.9;color:#7c99aa;max-width:260px}.evidence-footer{border-top:1px solid #27363f;padding:13px 20px;font-size:9px;line-height:1.7;color:#7996a6}.monitoring-heading{padding-top:34px}.monitoring-heading h2{font-size:23px;font-weight:500;letter-spacing:-.5px;margin:0;display:flex;align-items:center;gap:12px}.prediction-direction,.prediction-direction.down{color:#becfd8}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:1150px){.signal-card{padding:17px 15px}.signal-title{font-size:16px}.signal-facts{font-size:9px}.analyst-layout{grid-template-columns:minmax(0,1.3fr) minmax(280px,1fr)}.chat-foot>span:last-child{display:none}}@media(max-width:1000px){.analyst-layout{grid-template-columns:1fr}.evidence-list{max-height:450px}.market-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.forecast-cards{gap:10px}.signal-facts>div{display:block}.signal-facts dd{text-align:left;margin-top:4px}.signal-title{min-height:42px}.ai-heading h1{font-size:28px}}@media(max-width:700px){.forecast-cards{grid-template-columns:1fr}.signal-title{min-height:0}.signal-reason{min-height:0}.signal-facts>div{display:flex}.signal-facts dd{margin-top:0;text-align:right}.signal-card{padding:19px}.signal-title{font-size:21px}.market-strip{padding:18px}.ai-heading h1{font-size:25px}.ai-heading p{line-height:1.9}.live-state{font-size:8px;padding:7px}.chat-log{padding:17px;max-height:470px}.quick-questions{padding:0 17px 16px}#chat-form{padding:15px 17px}.agent-strip{padding:12px 17px}.provider-status{padding:0 17px 13px}.section-heading .subtle-tag{display:none}.chat-message.user{margin-left:20px}.monitoring-heading h2{font-size:21px}.sidebar{overflow-y:auto}}
|
| 2 |
+
|
| 3 |
+
.continuous-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:24px 0 18px}.continuous-facts{padding:0 22px 14px;margin:0}.continuous-facts div{display:flex;justify-content:space-between;gap:18px;padding:7px 0}.continuous-facts dt{color:#91a8b5}.continuous-facts dd{margin:0;text-align:right}.continuous-context{padding:22px;margin-bottom:20px}.continuous-context p{line-height:1.7;white-space:pre-wrap}.continuous-grid .signal-note{padding:0 22px 15px}#continuous-agent-status{display:flex;flex-wrap:wrap;gap:10px;margin-top:18px}#continuous-agent-status span{border:1px solid #29414e;border-radius:5px;padding:6px 9px;font-size:12px}#qualification-groups{display:flex;gap:25px;flex-wrap:wrap;color:#91a8b5;font-size:12px}@media(max-width:780px){.continuous-grid{grid-template-columns:1fr}.continuous-facts div{display:block}.continuous-facts dd{text-align:left;margin-top:5px}}
|
app/static/monitor/analyst.js
CHANGED
|
@@ -137,7 +137,7 @@
|
|
| 137 |
finally { polling = false; }
|
| 138 |
}, 1100);
|
| 139 |
try {
|
| 140 |
-
const data = await api('/' + kind, kind === 'chat' ? {message:question,history:previous,request_id:requestId} : {question,request_id:requestId});
|
| 141 |
if (generation !== epoch) return;
|
| 142 |
activity(data.agent_status);
|
| 143 |
if (kind === 'chat') {
|
|
|
|
| 137 |
finally { polling = false; }
|
| 138 |
}, 1100);
|
| 139 |
try {
|
| 140 |
+
const data = await api('/' + kind, kind === 'chat' ? {message:question,history:previous,request_id:requestId} : {question,request_id:requestId,fresh_research:true});
|
| 141 |
if (generation !== epoch) return;
|
| 142 |
activity(data.agent_status);
|
| 143 |
if (kind === 'chat') {
|
app/static/monitor/dashboard.js
CHANGED
|
@@ -15,6 +15,16 @@ function query(history=false){const p=new URLSearchParams({horizon:state.horizon
|
|
| 15 |
async function readJson(url){const r=await fetch(url,{cache:'no-store',signal:AbortSignal.timeout(20000)});if(!r.ok)throw new Error('API returned '+r.status);return r.json();}
|
| 16 |
|
| 17 |
function renderSummary(data){
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const sys=data.system, model=data.model, latest=data.latest;
|
| 19 |
setText('crumb','btc-direction-'+state.horizon+'h');
|
| 20 |
setText('model-name',modelName(latest?.model || model.model));
|
|
|
|
| 15 |
async function readJson(url){const r=await fetch(url,{cache:'no-store',signal:AbortSignal.timeout(20000)});if(!r.ok)throw new Error('API returned '+r.status);return r.json();}
|
| 16 |
|
| 17 |
function renderSummary(data){
|
| 18 |
+
const q=data.qualification;
|
| 19 |
+
if(q){
|
| 20 |
+
setText('qualification-performance','Signal coverage: '+percent(q.signal_coverage)+' | NO_SIGNAL: '+percent(q.no_signal_frequency)+' | UNKNOWN: '+percent(q.unknown_frequency)+' | Qualified accuracy: '+percent(q.qualified_performance.accuracy));
|
| 21 |
+
const groups=$('qualification-groups');groups.replaceChildren();
|
| 22 |
+
for(const [label,values] of [['Reliability',q.reliability_buckets],['Regime',q.regimes]]){
|
| 23 |
+
for(const [key,m] of Object.entries(values)){groups.append(element('span',label+' '+key+': '+percent(m.accuracy)+' accuracy / '+m.scored+' scored'));}
|
| 24 |
+
}
|
| 25 |
+
if(q.legacy_unannotated)groups.append(element('span',q.legacy_unannotated+' older forecasts have no saved issue-time gate; excluded from coverage.'));
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
const sys=data.system, model=data.model, latest=data.latest;
|
| 29 |
setText('crumb','btc-direction-'+state.horizon+'h');
|
| 30 |
setText('model-name',modelName(latest?.model || model.model));
|
app/static/monitor/index.html
CHANGED
|
@@ -8,6 +8,7 @@
|
|
| 8 |
<script src="/monitor/assets/dashboard.js" defer></script>
|
| 9 |
<link rel="stylesheet" href="/monitor/assets/analyst.css">
|
| 10 |
<script src="/monitor/assets/analyst.js" defer></script>
|
|
|
|
| 11 |
</head>
|
| 12 |
<body>
|
| 13 |
<aside class="sidebar">
|
|
@@ -39,6 +40,10 @@
|
|
| 39 |
<article class="panel analyst-panel" id="analyst"><div class="panel-heading"><div><span class="eyebrow">RESEARCH WORKSPACE</span><h2>AI Market Analyst</h2><p>Ask about your models and the news around Bitcoin.</p></div><button class="text-button" id="clear-chat" type="button">Clear chat</button></div><div class="provider-status" id="provider-status">Checking provider configuration…</div><div class="agent-strip" aria-label="Agent activity"><span id="agent-forecast">Forecast · idle</span><span id="agent-research">Research · idle</span><span id="agent-validation">Validation · idle</span><span id="agent-decision">Decision · idle</span></div><div class="chat-log" id="chat-log" role="log" aria-label="Chat history" aria-live="polite"><div class="chat-welcome"><div class="analyst-orb">✧</div><h3>A clearer view of BTC.</h3><p>Compare model signals, inspect reliability, or explore current market evidence with linked sources.</p><span>Grounded in your saved forecasts</span></div></div><div class="quick-questions" id="quick-questions" aria-label="Suggested questions"></div><form id="chat-form"><label class="sr-only" for="chat-input">Ask the AI market analyst</label><div class="chat-compose"><textarea id="chat-input" maxlength="2000" rows="2" placeholder="Ask about BTC, a forecast, or current news…" required></textarea><button class="primary-button" id="send-chat" type="submit">Send ↗</button></div><div class="chat-foot"><span id="chat-status" role="status">Ready · research runs only when needed</span><span>Enter to send · Shift + Enter for a new line</span></div></form></article>
|
| 40 |
<article class="panel evidence-panel" id="evidence"><div class="panel-heading"><div><span class="eyebrow">TAVILY RESEARCH</span><h2>Live Market Evidence</h2><p id="evidence-status">No research requested yet</p></div><button class="text-button" id="research-news" type="button" aria-label="Research latest Bitcoin news">↻ Research</button></div><div class="evidence-disclaimer">External web evidence provides market context and does not necessarily explain the forecasting model's prediction.</div><div class="evidence-list" id="evidence-list"><div class="evidence-empty"><span>◎</span><strong>Follow the evidence.</strong><p>Ask a news question or select Research to fetch current sources. Refreshing this dashboard does not trigger paid research.</p></div></div><div class="evidence-footer" id="evidence-footer">News never increases model reliability.</div></article>
|
| 41 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
<section class="page-heading monitoring-heading"><div><div class="eyebrow">FORWARD PERFORMANCE</div><h2>Model monitoring <span class="badge" id="model-badge">COLLECTING</span></h2><p>Every forecast recorded. Every outcome accounted for.</p></div><button class="primary-button" id="refresh-button">↻ <span>Run refresh</span></button></section>
|
| 43 |
<div class="toolbar"><div class="model-description"><strong id="model-name">VAE-Transformer</strong><span class="separator">/</span><span id="feature-set">Market only</span><span class="small-badge" id="horizon-description">1h horizon</span></div><div class="period" aria-label="Accuracy window"><button data-days="7" class="selected" aria-pressed="true">7 days</button><button data-days="30" aria-pressed="false">30 days</button></div></div>
|
| 44 |
<div class="notice" id="notice" role="status" hidden></div>
|
|
@@ -48,6 +53,7 @@
|
|
| 48 |
<article class="metric-card"><div class="metric-label">PENDING OUTCOMES <span>◷</span></div><div class="metric-value" id="pending">0</div><div class="metric-detail" id="pending-detail">Waiting for the first forecast</div><div class="metric-foot" id="missing-detail">Predictions are saved before evaluation</div></article>
|
| 49 |
<article class="metric-card"><div class="metric-label">BRIER SCORE <span>◎</span></div><div class="metric-value" id="brier">—</div><div class="metric-detail">Probability quality · lower is better</div><div class="metric-foot">0 is perfect · 0.25 for constant 50%</div></article>
|
| 50 |
</section>
|
|
|
|
| 51 |
<section class="chart-grid">
|
| 52 |
<article class="panel performance-panel"><div class="panel-heading"><div><h2>Accuracy over time</h2><p id="chart-description">Trailing 7-day accuracy · scored forecasts only</p></div><span class="subtle-tag">LIVE EVALUATION</span></div><div class="chart-legend"><span><i class="legend-line cyan-line"></i> Model</span><span><i class="legend-line muted-line"></i> Always UP baseline</span></div><div class="chart-wrap"><canvas id="accuracy-chart" aria-label="Rolling model accuracy and always UP baseline" role="img"></canvas><div class="chart-empty" id="chart-empty"><span class="empty-symbol">⌁</span><strong>Good evidence takes time.</strong><p>Your accuracy curve appears as forecasts reach their target time.</p></div></div><div class="chart-footer"><span id="window-note">No historical tests are included in live accuracy.</span><span>UTC</span></div></article>
|
| 53 |
<article class="panel latest-panel"><div class="panel-heading"><div><h2>Latest raw prediction</h2><p>Original forecast, saved at issue time</p></div><span class="dot" id="latest-dot"></span></div><div id="latest-empty" class="latest-empty"><span class="empty-symbol">↗</span><strong>Ready for the first forecast</strong><p>The hourly worker collects market data and records predictions automatically.</p></div><div id="latest-content" hidden><div class="prediction-direction" id="direction">—</div><div class="probability-heading"><span>Probability of UP</span><b id="probability">—</b></div><div class="probability-track"><div id="probability-fill"></div></div><dl class="prediction-facts"><div><dt>Reference close</dt><dd id="reference-price">—</dd></div><div><dt>Issued at</dt><dd id="issued-at">—</dd></div><div><dt>Outcome due</dt><dd id="target-at">—</dd></div><div><dt>Status</dt><dd id="latest-status">Pending</dd></div></dl></div><div class="model-version"><label for="version-select">Model version</label><select id="version-select"><option value="">Current deployment</option></select></div></article>
|
|
|
|
| 8 |
<script src="/monitor/assets/dashboard.js" defer></script>
|
| 9 |
<link rel="stylesheet" href="/monitor/assets/analyst.css">
|
| 10 |
<script src="/monitor/assets/analyst.js" defer></script>
|
| 11 |
+
<script src="/monitor/assets/monitoring-events.js" defer></script>
|
| 12 |
</head>
|
| 13 |
<body>
|
| 14 |
<aside class="sidebar">
|
|
|
|
| 40 |
<article class="panel analyst-panel" id="analyst"><div class="panel-heading"><div><span class="eyebrow">RESEARCH WORKSPACE</span><h2>AI Market Analyst</h2><p>Ask about your models and the news around Bitcoin.</p></div><button class="text-button" id="clear-chat" type="button">Clear chat</button></div><div class="provider-status" id="provider-status">Checking provider configuration…</div><div class="agent-strip" aria-label="Agent activity"><span id="agent-forecast">Forecast · idle</span><span id="agent-research">Research · idle</span><span id="agent-validation">Validation · idle</span><span id="agent-decision">Decision · idle</span></div><div class="chat-log" id="chat-log" role="log" aria-label="Chat history" aria-live="polite"><div class="chat-welcome"><div class="analyst-orb">✧</div><h3>A clearer view of BTC.</h3><p>Compare model signals, inspect reliability, or explore current market evidence with linked sources.</p><span>Grounded in your saved forecasts</span></div></div><div class="quick-questions" id="quick-questions" aria-label="Suggested questions"></div><form id="chat-form"><label class="sr-only" for="chat-input">Ask the AI market analyst</label><div class="chat-compose"><textarea id="chat-input" maxlength="2000" rows="2" placeholder="Ask about BTC, a forecast, or current news…" required></textarea><button class="primary-button" id="send-chat" type="submit">Send ↗</button></div><div class="chat-foot"><span id="chat-status" role="status">Ready · research runs only when needed</span><span>Enter to send · Shift + Enter for a new line</span></div></form></article>
|
| 41 |
<article class="panel evidence-panel" id="evidence"><div class="panel-heading"><div><span class="eyebrow">TAVILY RESEARCH</span><h2>Live Market Evidence</h2><p id="evidence-status">No research requested yet</p></div><button class="text-button" id="research-news" type="button" aria-label="Research latest Bitcoin news">↻ Research</button></div><div class="evidence-disclaimer">External web evidence provides market context and does not necessarily explain the forecasting model's prediction.</div><div class="evidence-list" id="evidence-list"><div class="evidence-empty"><span>◎</span><strong>Follow the evidence.</strong><p>Ask a news question or select Research to fetch current sources. Refreshing this dashboard does not trigger paid research.</p></div></div><div class="evidence-footer" id="evidence-footer">News never increases model reliability.</div></article>
|
| 42 |
</section>
|
| 43 |
+
<section class="continuous-grid" aria-label="Continuous market monitoring">
|
| 44 |
+
<article class="panel"><div class="panel-heading"><div><h2>Market monitor</h2><p id="scan-cadence">Scheduled independently of this browser</p></div><span class="badge" id="scan-worker">CONNECTING</span></div><dl class="continuous-facts"><div><dt>Last market scan</dt><dd id="scan-last">Not yet available</dd></div><div><dt>Next market scan</dt><dd id="scan-next">Not yet available</dd></div><div><dt>Last successful prediction cycle</dt><dd id="scan-success">Not yet available</dd></div><div><dt>History backup</dt><dd id="scan-backup">Checking</dd></div></dl><p class="signal-note" id="scan-error"></p></article>
|
| 45 |
+
<article class="panel"><div class="panel-heading"><div><h2>Market research</h2><p>Research runs when meaningful changes occur</p></div><span class="badge" id="research-state">IDLE</span></div><dl class="continuous-facts"><div><dt>Last research</dt><dd id="research-last-time">Not yet available</dd></div><div><dt>Trigger</dt><dd id="research-trigger-reason">No event yet</dd></div></dl><p class="signal-note" id="research-event-note">Web research is event-triggered to reduce unnecessary API calls.</p></article>
|
| 46 |
+
</section><section class="panel continuous-context"><div class="panel-heading"><h2>Latest event context</h2></div><p id="stored-market-context">Awaiting validated research.</p><div id="continuous-agent-status" aria-label="Operational agent status"></div></section>
|
| 47 |
<section class="page-heading monitoring-heading"><div><div class="eyebrow">FORWARD PERFORMANCE</div><h2>Model monitoring <span class="badge" id="model-badge">COLLECTING</span></h2><p>Every forecast recorded. Every outcome accounted for.</p></div><button class="primary-button" id="refresh-button">↻ <span>Run refresh</span></button></section>
|
| 48 |
<div class="toolbar"><div class="model-description"><strong id="model-name">VAE-Transformer</strong><span class="separator">/</span><span id="feature-set">Market only</span><span class="small-badge" id="horizon-description">1h horizon</span></div><div class="period" aria-label="Accuracy window"><button data-days="7" class="selected" aria-pressed="true">7 days</button><button data-days="30" aria-pressed="false">30 days</button></div></div>
|
| 49 |
<div class="notice" id="notice" role="status" hidden></div>
|
|
|
|
| 53 |
<article class="metric-card"><div class="metric-label">PENDING OUTCOMES <span>◷</span></div><div class="metric-value" id="pending">0</div><div class="metric-detail" id="pending-detail">Waiting for the first forecast</div><div class="metric-foot" id="missing-detail">Predictions are saved before evaluation</div></article>
|
| 54 |
<article class="metric-card"><div class="metric-label">BRIER SCORE <span>◎</span></div><div class="metric-value" id="brier">—</div><div class="metric-detail">Probability quality · lower is better</div><div class="metric-foot">0 is perfect · 0.25 for constant 50%</div></article>
|
| 55 |
</section>
|
| 56 |
+
<section class="panel continuous-context"><h2>Signal qualification performance</h2><p id="qualification-performance">Awaiting issue-time signal records.</p><div id="qualification-groups"></div></section>
|
| 57 |
<section class="chart-grid">
|
| 58 |
<article class="panel performance-panel"><div class="panel-heading"><div><h2>Accuracy over time</h2><p id="chart-description">Trailing 7-day accuracy · scored forecasts only</p></div><span class="subtle-tag">LIVE EVALUATION</span></div><div class="chart-legend"><span><i class="legend-line cyan-line"></i> Model</span><span><i class="legend-line muted-line"></i> Always UP baseline</span></div><div class="chart-wrap"><canvas id="accuracy-chart" aria-label="Rolling model accuracy and always UP baseline" role="img"></canvas><div class="chart-empty" id="chart-empty"><span class="empty-symbol">⌁</span><strong>Good evidence takes time.</strong><p>Your accuracy curve appears as forecasts reach their target time.</p></div></div><div class="chart-footer"><span id="window-note">No historical tests are included in live accuracy.</span><span>UTC</span></div></article>
|
| 59 |
<article class="panel latest-panel"><div class="panel-heading"><div><h2>Latest raw prediction</h2><p>Original forecast, saved at issue time</p></div><span class="dot" id="latest-dot"></span></div><div id="latest-empty" class="latest-empty"><span class="empty-symbol">↗</span><strong>Ready for the first forecast</strong><p>The hourly worker collects market data and records predictions automatically.</p></div><div id="latest-content" hidden><div class="prediction-direction" id="direction">—</div><div class="probability-heading"><span>Probability of UP</span><b id="probability">—</b></div><div class="probability-track"><div id="probability-fill"></div></div><dl class="prediction-facts"><div><dt>Reference close</dt><dd id="reference-price">—</dd></div><div><dt>Issued at</dt><dd id="issued-at">—</dd></div><div><dt>Outcome due</dt><dd id="target-at">—</dd></div><div><dt>Status</dt><dd id="latest-status">Pending</dd></div></dl></div><div class="model-version"><label for="version-select">Model version</label><select id="version-select"><option value="">Current deployment</option></select></div></article>
|
app/static/monitor/monitoring-events.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(() => {
|
| 2 |
+
'use strict';
|
| 3 |
+
const $ = id => document.getElementById(id);
|
| 4 |
+
const stamp = value => value && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString().slice(0,16).replace('T',' ') + ' UTC' : 'Not yet available';
|
| 5 |
+
let busy = false;
|
| 6 |
+
function text(id, value) { $(id).textContent = value; }
|
| 7 |
+
async function refresh() {
|
| 8 |
+
if (busy) return;
|
| 9 |
+
busy = true;
|
| 10 |
+
try {
|
| 11 |
+
const response = await fetch('/monitoring/activity', {cache:'no-store', signal:AbortSignal.timeout(15000)});
|
| 12 |
+
if (!response.ok) throw new Error('Monitoring API unavailable');
|
| 13 |
+
const data = await response.json(), event = data.last_research, operation = data.research || {};
|
| 14 |
+
text('scan-worker', data.worker_alive ? 'ACTIVE' : 'STOPPED');
|
| 15 |
+
text('scan-last', stamp(data.last_market_scan));
|
| 16 |
+
text('scan-next', stamp(data.next_cycle_due));
|
| 17 |
+
text('scan-success', stamp(data.last_successful_prediction_cycle));
|
| 18 |
+
text('scan-backup', (data.backup?.status || 'UNKNOWN') + (data.backup?.saved_at ? ' · ' + stamp(data.backup.saved_at) : ''));
|
| 19 |
+
text('scan-cadence', `Every ${data.monitor_interval_minutes} minutes; runs while this page is closed.`);
|
| 20 |
+
text('research-state', operation.status || 'IDLE');
|
| 21 |
+
text('research-last-time', stamp(event?.result?.retrieved_at || event?.completed_at));
|
| 22 |
+
const trigger = data.last_research_trigger;
|
| 23 |
+
let reason = trigger?.triggered_by?.map(value => value.replaceAll('_',' ')).join(', ') || 'No meaningful change detected';
|
| 24 |
+
if (trigger?.details?.price_change_1h != null) reason += ` (${trigger.details.price_change_1h.toFixed(2)}% in 1h)`;
|
| 25 |
+
text('research-trigger-reason', reason);
|
| 26 |
+
text('research-event-note', operation.cache_reason || (event?.status === 'error' ? 'Last context research was unavailable. Saved market forecasts are preserved.' : 'Web research is event-triggered to reduce unnecessary API calls.'));
|
| 27 |
+
text('stored-market-context', event?.summary || 'A contextual summary appears after an event produces useful validated evidence.');
|
| 28 |
+
const agents = [['Market monitor','monitor'],['Forecast agent','forecast'],['Trigger engine','trigger'],['Research agent',null],['Validation agent','validation'],['Decision agent','decision']];
|
| 29 |
+
const fragment = document.createDocumentFragment();
|
| 30 |
+
for (const [label,key] of agents) {
|
| 31 |
+
const node = document.createElement('span');
|
| 32 |
+
node.textContent = label + ': ' + (key ? operation[key] || 'idle' : operation.status || 'IDLE');
|
| 33 |
+
fragment.append(node);
|
| 34 |
+
}
|
| 35 |
+
$('continuous-agent-status').replaceChildren(fragment);
|
| 36 |
+
text('scan-error', [data.last_error, data.backup?.error].filter(Boolean).join(' '));
|
| 37 |
+
} catch {
|
| 38 |
+
text('scan-worker','UNAVAILABLE');
|
| 39 |
+
text('research-state','UNAVAILABLE');
|
| 40 |
+
text('scan-error','Cannot confirm current monitoring health; displayed timestamps may be stale.');
|
| 41 |
+
} finally { busy = false; }
|
| 42 |
+
}
|
| 43 |
+
window.addEventListener('focus', refresh);
|
| 44 |
+
refresh(); setInterval(refresh, 30000);
|
| 45 |
+
})();
|
requirements-agents.txt
CHANGED
|
@@ -1,2 +1,3 @@
|
|
| 1 |
# Official framework used by the conversational Decision Agent.
|
| 2 |
openai-agents==0.22.1
|
|
|
|
|
|
| 1 |
# Official framework used by the conversational Decision Agent.
|
| 2 |
openai-agents==0.22.1
|
| 3 |
+
huggingface-hub>=1.0,<2
|
scripts/check_space_bundle.py
CHANGED
|
@@ -11,6 +11,10 @@ def check_bundle(root):
|
|
| 11 |
"app/agents/validation_agent.py", "app/agents/answer_guard.py",
|
| 12 |
"app/services/llm_service.py", "app/services/agent_config.py",
|
| 13 |
"app/services/tavily_service.py", "app/schemas/agent_models.py",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"app/static/monitor/index.html", "app/static/monitor/analyst.js",
|
| 15 |
"app/static/monitor/analyst.css", "app/static/monitor/dashboard.js",
|
| 16 |
"app/static/monitor/styles.css", "requirements-agents.txt",
|
|
|
|
| 11 |
"app/agents/validation_agent.py", "app/agents/answer_guard.py",
|
| 12 |
"app/services/llm_service.py", "app/services/agent_config.py",
|
| 13 |
"app/services/tavily_service.py", "app/schemas/agent_models.py",
|
| 14 |
+
"app/services/research_triggers.py", "app/services/market_monitoring.py",
|
| 15 |
+
"app/services/monitoring_events_store.py", "app/services/monitoring_backup.py",
|
| 16 |
+
"app/services/monitor_schedule.py", "app/services/monitor_worker_runtime.py",
|
| 17 |
+
"app/static/monitor/monitoring-events.js",
|
| 18 |
"app/static/monitor/index.html", "app/static/monitor/analyst.js",
|
| 19 |
"app/static/monitor/analyst.css", "app/static/monitor/dashboard.js",
|
| 20 |
"app/static/monitor/styles.css", "requirements-agents.txt",
|
tests/test_event_monitoring.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scheduled scans and research gating with real SQLite and offline providers."""
|
| 2 |
+
import copy
|
| 3 |
+
import tempfile
|
| 4 |
+
import unittest
|
| 5 |
+
from dataclasses import replace
|
| 6 |
+
from datetime import datetime, timedelta, timezone
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from unittest.mock import Mock, patch
|
| 9 |
+
|
| 10 |
+
import pandas as pd
|
| 11 |
+
from fastapi.testclient import TestClient
|
| 12 |
+
from app import api
|
| 13 |
+
from app.monitoring_service import MonitoringService
|
| 14 |
+
from app.monitor_worker import MonitoringWorker
|
| 15 |
+
from app.services.market_monitoring import MarketMonitoringAgent
|
| 16 |
+
from app.services.monitoring_events_store import EventStore
|
| 17 |
+
from app.services.research_triggers import ResearchTriggerEngine, market_metrics
|
| 18 |
+
from app.services.monitor_schedule import next_cycle
|
| 19 |
+
from app.schemas.agent_models import ResearchResult, ChatRequest, DecisionPlan
|
| 20 |
+
from app.agents.market_research_agent import MarketResearchAgent
|
| 21 |
+
from app.agents.orchestrator import AgentOrchestrator, needs_research
|
| 22 |
+
from test_analyst_integration import settings, saved_snapshot, article, ResearchStub, PlanStub
|
| 23 |
+
|
| 24 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 25 |
+
UTC = timezone.utc
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class EventMonitoringTests(unittest.TestCase):
|
| 29 |
+
def setUp(self):
|
| 30 |
+
self.temp = tempfile.TemporaryDirectory()
|
| 31 |
+
self.service = MonitoringService(ROOT, Path(self.temp.name) / "events.sqlite3")
|
| 32 |
+
self.service.settings = settings()
|
| 33 |
+
self.raw = saved_snapshot(p=.1)
|
| 34 |
+
one = self.raw["forecasts"]["1"]
|
| 35 |
+
one["current_price"] = 100.
|
| 36 |
+
self.raw["forecasts"] = {str(h): {**copy.deepcopy(one), "horizon_hours": h} for h in (1, 6, 24)}
|
| 37 |
+
self.raw["market"].update(price=100., regime="RANGE")
|
| 38 |
+
self.origin = datetime.fromisoformat(one["latest_candle"])
|
| 39 |
+
self.market = pd.DataFrame({"timestamp": pd.date_range(end=self.origin, periods=500, freq="h"), "close": [100.] * 500})
|
| 40 |
+
self.research = ResearchStub(ResearchResult(status="ok", evidence=[article()]))
|
| 41 |
+
selected = DecisionPlan(focus_horizons=["1h"], fact_ids=[], evidence_ids=["bull"], interpretation="bullish_context", answer_style="concise",
|
| 42 |
+
answer="The report describes ETF inflows. {{source:bull}}")
|
| 43 |
+
self.llm = PlanStub(selected)
|
| 44 |
+
self.monitor = MarketMonitoringAgent(self.service, self.research, self.llm)
|
| 45 |
+
self.service._market_monitor = self.monitor
|
| 46 |
+
self.pipeline = Mock()
|
| 47 |
+
self.pipeline.market_agent.fetch_closed_candles.side_effect = lambda **_: self.market.copy()
|
| 48 |
+
self.pipeline.refresh.side_effect = lambda **_: copy.deepcopy(self.raw)
|
| 49 |
+
self.service._pipeline = self.pipeline
|
| 50 |
+
self.old_service = api._service
|
| 51 |
+
api._service = self.service
|
| 52 |
+
self.client = TestClient(api.app)
|
| 53 |
+
self.seed_previous()
|
| 54 |
+
|
| 55 |
+
def tearDown(self):
|
| 56 |
+
api._service = self.old_service
|
| 57 |
+
self.temp.cleanup()
|
| 58 |
+
|
| 59 |
+
def seed_previous(self, transform=None):
|
| 60 |
+
previous = self.monitor.prepare(self.raw, self.market)
|
| 61 |
+
previous["market_timestamp"] = (self.origin - timedelta(hours=1)).isoformat()
|
| 62 |
+
if transform:
|
| 63 |
+
transform(previous)
|
| 64 |
+
# Replace only this fixture's baseline scan; never production history.
|
| 65 |
+
with self.service.store._connection() as db:
|
| 66 |
+
db.execute("DELETE FROM monitoring_scans")
|
| 67 |
+
self.service.event_store.scan(-1, previous, {"should_research": False, "triggered_by": []})
|
| 68 |
+
|
| 69 |
+
def price_move(self):
|
| 70 |
+
self.market.loc[self.market.index[-1], "close"] = 102.
|
| 71 |
+
self.raw["market"]["price"] = 102.
|
| 72 |
+
for forecast in self.raw["forecasts"].values():
|
| 73 |
+
forecast["current_price"] = 102.
|
| 74 |
+
|
| 75 |
+
def test_ordinary_cycle_records_scan_without_paid_calls(self):
|
| 76 |
+
result = self.service.run()
|
| 77 |
+
self.assertEqual(result["status"], "success")
|
| 78 |
+
self.assertFalse(result["research_trigger"]["should_research"])
|
| 79 |
+
self.assertEqual(self.research.calls, [])
|
| 80 |
+
self.assertEqual(self.llm.calls, [])
|
| 81 |
+
self.assertEqual(len(self.service.event_store.scans()), 2)
|
| 82 |
+
self.assertTrue(self.service.monitoring_activity()["last_cycle_completed"])
|
| 83 |
+
|
| 84 |
+
def test_large_price_move_calls_research_and_saves_context_reference(self):
|
| 85 |
+
self.price_move()
|
| 86 |
+
result = self.service.run()
|
| 87 |
+
self.assertIn("large_price_move", result["research_trigger"]["triggered_by"])
|
| 88 |
+
self.assertEqual(len(self.research.calls), 1)
|
| 89 |
+
self.assertEqual(len(self.llm.calls), 1)
|
| 90 |
+
event = self.service.event_store.latest_event()
|
| 91 |
+
self.assertEqual(event["status"], "done")
|
| 92 |
+
self.assertIn("ETF inflows", event["summary"])
|
| 93 |
+
scan = self.service.event_store.scans(1)[0]
|
| 94 |
+
self.assertEqual(scan["research_event_id"], event["id"])
|
| 95 |
+
self.assertNotIn("evidence", scan["payload"])
|
| 96 |
+
|
| 97 |
+
def test_regime_transition_triggers_research(self):
|
| 98 |
+
self.raw["market"]["regime"] = "HIGH_VOL"
|
| 99 |
+
self.assertIn("regime_change", self.service.run()["research_trigger"]["triggered_by"])
|
| 100 |
+
self.assertEqual(len(self.research.calls), 1)
|
| 101 |
+
|
| 102 |
+
def test_signed_internal_sentiment_shift_triggers_research(self):
|
| 103 |
+
self.raw["sentiment"]["latest_sentiment_score"] = .4
|
| 104 |
+
self.seed_previous()
|
| 105 |
+
self.raw["sentiment"]["latest_sentiment_score"] = -.4
|
| 106 |
+
self.assertIn("sentiment_shift", self.service.run()["research_trigger"]["triggered_by"])
|
| 107 |
+
self.assertEqual(len(self.research.calls), 1)
|
| 108 |
+
|
| 109 |
+
def test_small_probability_change_does_not_trigger(self):
|
| 110 |
+
for forecast in self.raw["forecasts"].values():
|
| 111 |
+
forecast["probability_up"] = .51
|
| 112 |
+
self.seed_previous()
|
| 113 |
+
for forecast in self.raw["forecasts"].values():
|
| 114 |
+
forecast["probability_up"] = .52
|
| 115 |
+
self.assertFalse(self.service.run()["research_trigger"]["should_research"])
|
| 116 |
+
self.assertEqual(self.research.calls, [])
|
| 117 |
+
|
| 118 |
+
def test_reliability_crossing_no_signal_to_down_triggers(self):
|
| 119 |
+
for forecast in self.raw["forecasts"].values():
|
| 120 |
+
forecast["validated_reliability"] = .4
|
| 121 |
+
self.seed_previous()
|
| 122 |
+
for forecast in self.raw["forecasts"].values():
|
| 123 |
+
forecast["validated_reliability"] = .8
|
| 124 |
+
self.assertIn("forecast_state_change", self.service.run()["research_trigger"]["triggered_by"])
|
| 125 |
+
self.assertEqual(len(self.research.calls), 1)
|
| 126 |
+
|
| 127 |
+
def test_same_candle_never_researches_again_even_after_restart(self):
|
| 128 |
+
self.price_move()
|
| 129 |
+
self.service.run()
|
| 130 |
+
self.service._market_monitor = MarketMonitoringAgent(self.service, self.research, self.llm)
|
| 131 |
+
result = self.service.run()
|
| 132 |
+
self.assertEqual(result["status"], "up_to_date")
|
| 133 |
+
self.assertEqual(len(self.research.calls), 1)
|
| 134 |
+
self.assertEqual(len(self.llm.calls), 1)
|
| 135 |
+
|
| 136 |
+
def test_persistent_cooldown_reuses_event_without_research_or_decision(self):
|
| 137 |
+
self.price_move()
|
| 138 |
+
self.service.run()
|
| 139 |
+
current = self.monitor.prepare(self.service.snapshot(), self.market)
|
| 140 |
+
current["market_timestamp"] = (self.origin + timedelta(hours=1)).isoformat()
|
| 141 |
+
reopened = MarketMonitoringAgent(self.service, self.research, self.llm)
|
| 142 |
+
with patch.object(reopened, "prepare", return_value=current):
|
| 143 |
+
result = reopened.process(999, self.service.snapshot(), self.market)
|
| 144 |
+
self.assertTrue(result["suppressed"])
|
| 145 |
+
self.assertEqual(len(self.research.calls), 1)
|
| 146 |
+
self.assertEqual(len(self.llm.calls), 1)
|
| 147 |
+
self.assertEqual(self.service.monitoring_activity()["research"]["status"], "CACHED")
|
| 148 |
+
|
| 149 |
+
def test_tavily_failure_does_not_fail_market_cycle(self):
|
| 150 |
+
self.price_move()
|
| 151 |
+
self.research.result = ResearchResult(status="unavailable")
|
| 152 |
+
result = self.service.run()
|
| 153 |
+
self.assertEqual(result["status"], "success")
|
| 154 |
+
self.assertEqual(result["recorded"]["inserted"], 3)
|
| 155 |
+
self.assertEqual(self.llm.calls, [])
|
| 156 |
+
self.assertIsNone(self.service.store.get_state("last_error"))
|
| 157 |
+
self.assertEqual(self.service.monitoring_activity()["research"]["status"], "ERROR")
|
| 158 |
+
|
| 159 |
+
def test_openai_failure_preserves_forecasts_and_scan(self):
|
| 160 |
+
self.price_move()
|
| 161 |
+
self.llm.value = None
|
| 162 |
+
result = self.service.run()
|
| 163 |
+
self.assertEqual(result["status"], "success")
|
| 164 |
+
self.assertEqual(result["recorded"]["inserted"], 3)
|
| 165 |
+
self.assertEqual(self.service.event_store.latest_event()["status"], "error")
|
| 166 |
+
self.assertIsNone(self.service.event_store.latest_event()["summary"])
|
| 167 |
+
|
| 168 |
+
def test_bearish_news_cannot_promote_unreliable_model(self):
|
| 169 |
+
self.price_move()
|
| 170 |
+
for forecast in self.raw["forecasts"].values():
|
| 171 |
+
forecast["validated_reliability"] = .42
|
| 172 |
+
self.research.result = ResearchResult(status="ok", evidence=[article("bear", headline="Bitcoin ETF outflows rise", direction="bearish")])
|
| 173 |
+
self.service.run()
|
| 174 |
+
for view in self.service.event_store.scans(1)[0]["payload"]["forecasts"].values():
|
| 175 |
+
self.assertEqual(view["signal_state"], "NO_SIGNAL")
|
| 176 |
+
self.assertEqual(view["reliability"], .42)
|
| 177 |
+
self.assertEqual(self.service.summary()["qualification"]["no_signal_frequency"], 1.)
|
| 178 |
+
|
| 179 |
+
def test_no_useful_validated_evidence_means_no_decision_call(self):
|
| 180 |
+
self.price_move()
|
| 181 |
+
self.research.result = ResearchResult(status="ok", evidence=[article(published_at=None)])
|
| 182 |
+
self.service.run()
|
| 183 |
+
self.assertEqual(self.llm.calls, [])
|
| 184 |
+
|
| 185 |
+
def test_forward_outcome_resolution_keeps_original_prediction_and_gate(self):
|
| 186 |
+
self.raw["forecasts"]["1"]["validated_reliability"] = .8
|
| 187 |
+
self.service.run()
|
| 188 |
+
original = self.service.store.history(horizon=1)["items"][0]
|
| 189 |
+
now = self.origin + timedelta(hours=2, minutes=1)
|
| 190 |
+
self.service.store.settle([{"timestamp": self.origin + timedelta(hours=1), "close": 90}], now=now)
|
| 191 |
+
row = self.service.store.history(horizon=1, now=now)["items"][0]
|
| 192 |
+
self.assertEqual(row["probability_up"], original["probability_up"])
|
| 193 |
+
self.assertEqual(row["issued_at"], original["issued_at"])
|
| 194 |
+
self.assertEqual(row["correct"], 1)
|
| 195 |
+
metric = self.service.store.summary(horizon=1, now=now)
|
| 196 |
+
self.assertEqual(metric["qualification"]["qualified_performance"]["accuracy"], 1.)
|
| 197 |
+
self.assertAlmostEqual(metric["brier_score"], .01)
|
| 198 |
+
|
| 199 |
+
def test_two_workers_and_restart_execute_without_browser_or_duplicates(self):
|
| 200 |
+
client = self.client
|
| 201 |
+
class Transport:
|
| 202 |
+
def post(self, url, json, timeout):
|
| 203 |
+
return client.post(url.replace("http://testserver", ""), json=json)
|
| 204 |
+
first = MonitoringWorker("http://testserver", session=Transport())
|
| 205 |
+
second = MonitoringWorker("http://testserver", session=Transport())
|
| 206 |
+
self.assertEqual(first.step(), "success")
|
| 207 |
+
self.assertEqual(second.step(), "standby")
|
| 208 |
+
self.assertEqual(first.step(), "waiting")
|
| 209 |
+
self.service.event_store.release("scheduler", first.worker_id)
|
| 210 |
+
self.assertEqual(second.step(), "waiting")
|
| 211 |
+
self.assertEqual(first.step(), "standby")
|
| 212 |
+
self.assertEqual(self.pipeline.refresh.call_count, 1)
|
| 213 |
+
self.assertEqual(self.research.calls, [])
|
| 214 |
+
|
| 215 |
+
def test_explicit_fresh_why_research_bypasses_monitoring_cooldown(self):
|
| 216 |
+
self.price_move()
|
| 217 |
+
self.service.run()
|
| 218 |
+
analyst = AgentOrchestrator(self.service, self.service.settings, self.research, self.llm)
|
| 219 |
+
analyst.chat(ChatRequest(message="Why is the model predicting DOWN?", fresh_research=True))
|
| 220 |
+
self.assertEqual(len(self.research.calls), 2)
|
| 221 |
+
self.assertTrue(self.research.calls[-1][1]["fresh"])
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class TriggerUnitTests(unittest.TestCase):
|
| 225 |
+
def test_volatility_trigger_and_missing_baseline(self):
|
| 226 |
+
engine = ResearchTriggerEngine(settings())
|
| 227 |
+
point = {"data_fresh": True, "market_timestamp": "new", "volatility": .03, "volatility_baseline": .01}
|
| 228 |
+
self.assertIn("unusual_volatility", engine.evaluate(point)["triggered_by"])
|
| 229 |
+
point["volatility_baseline"] = None
|
| 230 |
+
self.assertFalse(engine.evaluate(point)["should_research"])
|
| 231 |
+
|
| 232 |
+
def test_stale_context_never_triggers_paid_research(self):
|
| 233 |
+
point = {"data_fresh": False, "market_timestamp": "new", "price_change_1h": 20}
|
| 234 |
+
self.assertFalse(ResearchTriggerEngine(settings()).evaluate(point)["should_research"])
|
| 235 |
+
|
| 236 |
+
def test_price_threshold_is_configurable(self):
|
| 237 |
+
point = {"data_fresh": True, "market_timestamp": "new", "price_change_1h": 1.5}
|
| 238 |
+
self.assertFalse(ResearchTriggerEngine(settings(price_move_trigger_pct=2)).evaluate(point)["should_research"])
|
| 239 |
+
|
| 240 |
+
def test_query_plans_are_bounded_relevant_and_direction_neutral(self):
|
| 241 |
+
for trigger, keyword in (("large_price_move", "liquidations"), ("unusual_volatility", "liquidations"), ("sentiment_shift", "regulation")):
|
| 242 |
+
queries = MarketResearchAgent.query_plan("model DOWN", trigger_types=[trigger])
|
| 243 |
+
self.assertEqual(len(queries), 3)
|
| 244 |
+
self.assertIn(keyword, " ".join(queries))
|
| 245 |
+
self.assertNotIn("will fall", " ".join(queries))
|
| 246 |
+
|
| 247 |
+
def test_requested_market_explanations_route_to_research(self):
|
| 248 |
+
for question in ("Why is BTC moving?", "Why is the model predicting DOWN?", "What happened in the market?", "What news is affecting BTC?", "Explain the latest move."):
|
| 249 |
+
self.assertTrue(needs_research(question), question)
|
| 250 |
+
|
| 251 |
+
def test_configurable_cadence_and_midnight_rollover(self):
|
| 252 |
+
now = datetime(2026, 9, 8, 23, 59, tzinfo=UTC)
|
| 253 |
+
self.assertEqual(next_cycle(now, 60), datetime(2026, 9, 9, 0, 2, tzinfo=UTC))
|
| 254 |
+
self.assertEqual(next_cycle(now.replace(hour=9, minute=5), 15), now.replace(hour=9, minute=17))
|
| 255 |
+
|
| 256 |
+
def test_metrics_do_not_invent_returns_across_missing_candles(self):
|
| 257 |
+
times = pd.date_range("2026-01-01", periods=26, freq="h", tz="UTC")
|
| 258 |
+
frame = pd.DataFrame({"timestamp": times, "close": [100.] * 26}).drop(index=24)
|
| 259 |
+
point = market_metrics(frame, {})
|
| 260 |
+
self.assertIsNone(point["price_change_1h"])
|
| 261 |
+
self.assertIsNone(point["volatility"])
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
class DurableQueryCacheTests(unittest.TestCase):
|
| 265 |
+
def test_restart_reuses_query_cache_but_explicit_fresh_bypasses_it(self):
|
| 266 |
+
with tempfile.TemporaryDirectory() as directory:
|
| 267 |
+
service = MonitoringService(ROOT, Path(directory) / "cache.sqlite3")
|
| 268 |
+
tavily = Mock()
|
| 269 |
+
tavily.search.return_value = {"status": "empty", "results": []}
|
| 270 |
+
config = settings()
|
| 271 |
+
first = MarketResearchAgent(config, tavily, service.event_store)
|
| 272 |
+
first.run("Bitcoin latest market news")
|
| 273 |
+
self.assertEqual(tavily.search.call_count, 3)
|
| 274 |
+
second = MarketResearchAgent(config, tavily, EventStore(service.store))
|
| 275 |
+
self.assertTrue(second.run("Bitcoin latest market news").cached)
|
| 276 |
+
self.assertEqual(tavily.search.call_count, 3)
|
| 277 |
+
second.run("Bitcoin latest market news", fresh=True)
|
| 278 |
+
self.assertEqual(tavily.search.call_count, 6)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
if __name__ == "__main__":
|
| 282 |
+
unittest.main()
|
tests/test_monitoring_persistence.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import shutil
|
| 2 |
+
import sqlite3
|
| 3 |
+
import tempfile
|
| 4 |
+
import unittest
|
| 5 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 6 |
+
from datetime import datetime, timedelta, timezone
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from types import SimpleNamespace
|
| 9 |
+
from unittest.mock import Mock
|
| 10 |
+
|
| 11 |
+
from app.monitoring_store import MonitoringStore
|
| 12 |
+
from app.monitoring_service import MonitoringService
|
| 13 |
+
from app.services.monitoring_backup import MonitoringBackup
|
| 14 |
+
from app.services.monitoring_events_store import EventStore
|
| 15 |
+
|
| 16 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
UTC = timezone.utc
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class PersistenceTests(unittest.TestCase):
|
| 21 |
+
def setUp(self):
|
| 22 |
+
self.temp = tempfile.TemporaryDirectory()
|
| 23 |
+
self.path = Path(self.temp.name)
|
| 24 |
+
self.store = MonitoringStore(self.path / 'live.sqlite3')
|
| 25 |
+
self.events = EventStore(self.store)
|
| 26 |
+
self.store.set_state('preserve', {'probability_up': .4123})
|
| 27 |
+
self.client = Mock()
|
| 28 |
+
self.client.repo_info.return_value = SimpleNamespace(private=True, sha='revision-a')
|
| 29 |
+
self.client.upload_file.return_value = SimpleNamespace(oid='revision-b')
|
| 30 |
+
self.backup = MonitoringBackup('owner/private', self.client)
|
| 31 |
+
|
| 32 |
+
def tearDown(self):
|
| 33 |
+
self.temp.cleanup()
|
| 34 |
+
|
| 35 |
+
def test_consistent_snapshot_restore_preserves_data_and_clears_ownership(self):
|
| 36 |
+
self.store.try_acquire_lock('old')
|
| 37 |
+
self.events.acquire('scheduler', 'old')
|
| 38 |
+
self.store.set_state('worker', {'worker_id': 'old'})
|
| 39 |
+
self.events.claim('fingerprint', 'candle', ['price'], 30)
|
| 40 |
+
archive = self.path / 'archive.sqlite3'
|
| 41 |
+
def upload(**kwargs):
|
| 42 |
+
self.assertEqual(kwargs['parent_commit'], 'revision-a')
|
| 43 |
+
shutil.copyfile(kwargs['path_or_fileobj'], archive)
|
| 44 |
+
return SimpleNamespace(oid='revision-b')
|
| 45 |
+
self.client.upload_file.side_effect = upload
|
| 46 |
+
self.backup.save(self.store)
|
| 47 |
+
self.assertEqual(self.store.get_state('backup')['status'], 'OK')
|
| 48 |
+
restored = self.path / 'restored.sqlite3'
|
| 49 |
+
restore = MonitoringBackup('owner/private', self.client, lambda **_: str(archive))
|
| 50 |
+
self.assertTrue(restore.restore(restored))
|
| 51 |
+
db = MonitoringStore(restored)
|
| 52 |
+
self.assertEqual(db.get_state('preserve'), {'probability_up': .4123})
|
| 53 |
+
self.assertIsNone(db.get_state('worker'))
|
| 54 |
+
self.assertTrue(EventStore(db).acquire('scheduler', 'new'))
|
| 55 |
+
self.assertEqual(EventStore(db).latest_event()['status'], 'error')
|
| 56 |
+
self.assertTrue(db.try_acquire_lock('new'))
|
| 57 |
+
with sqlite3.connect(restored) as connection:
|
| 58 |
+
self.assertEqual(connection.execute('PRAGMA integrity_check').fetchone()[0], 'ok')
|
| 59 |
+
|
| 60 |
+
def test_failed_upload_preserves_local_history_and_reports_error(self):
|
| 61 |
+
self.client.upload_file.side_effect = RuntimeError('credential should not be exposed')
|
| 62 |
+
self.backup.save(self.store)
|
| 63 |
+
self.assertEqual(self.store.get_state('preserve')['probability_up'], .4123)
|
| 64 |
+
status = self.store.get_state('backup')
|
| 65 |
+
self.assertEqual(status['status'], 'ERROR')
|
| 66 |
+
self.assertNotIn('credential', str(status))
|
| 67 |
+
|
| 68 |
+
def test_public_repository_is_never_uploaded_to(self):
|
| 69 |
+
self.client.repo_info.return_value.private = False
|
| 70 |
+
self.backup.save(self.store)
|
| 71 |
+
self.client.upload_file.assert_not_called()
|
| 72 |
+
self.assertEqual(self.store.get_state('backup')['status'], 'ERROR')
|
| 73 |
+
|
| 74 |
+
def test_corrupt_restore_fails_closed(self):
|
| 75 |
+
corrupt = self.path / 'corrupt'
|
| 76 |
+
corrupt.write_text('not a database')
|
| 77 |
+
target = self.path / 'new.sqlite3'
|
| 78 |
+
backup = MonitoringBackup('owner/private', self.client, lambda **_: str(corrupt))
|
| 79 |
+
with self.assertRaisesRegex(RuntimeError, 'refusing to start'):
|
| 80 |
+
backup.restore(target)
|
| 81 |
+
self.assertFalse(target.exists())
|
| 82 |
+
|
| 83 |
+
def test_existing_local_history_is_not_replaced(self):
|
| 84 |
+
self.assertFalse(self.backup.restore(self.store.path))
|
| 85 |
+
self.client.repo_info.assert_not_called()
|
| 86 |
+
|
| 87 |
+
def test_disabled_backup_uses_no_network(self):
|
| 88 |
+
backup = MonitoringBackup('', self.client)
|
| 89 |
+
self.assertFalse(backup.restore(self.path / 'absent'))
|
| 90 |
+
backup.save(self.store)
|
| 91 |
+
self.client.repo_info.assert_not_called()
|
| 92 |
+
|
| 93 |
+
def test_concurrent_workers_only_one_owns_scheduler(self):
|
| 94 |
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
| 95 |
+
results = list(pool.map(lambda n: self.events.acquire('scheduler', str(n)), range(8)))
|
| 96 |
+
self.assertEqual(sum(results), 1)
|
| 97 |
+
|
| 98 |
+
def test_query_cache_expires_without_extending_on_reads(self):
|
| 99 |
+
now = datetime.now(UTC)
|
| 100 |
+
self.events.cache_put('query', {'results': []}, 600, now)
|
| 101 |
+
self.assertIsNotNone(self.events.cache_get('query', now + timedelta(minutes=9)))
|
| 102 |
+
self.assertIsNone(self.events.cache_get('query', now + timedelta(minutes=10)))
|
| 103 |
+
|
| 104 |
+
def test_fingerprint_cooldown_survives_new_store_and_expires(self):
|
| 105 |
+
now = datetime.now(UTC)
|
| 106 |
+
_, first = self.events.claim('same', 'candle-a', [], 30, now)
|
| 107 |
+
restarted = EventStore(MonitoringStore(self.store.path))
|
| 108 |
+
_, recent = restarted.claim('same', 'candle-b', [], 30, now + timedelta(minutes=20))
|
| 109 |
+
_, expired = restarted.claim('same', 'candle-b', [], 30, now + timedelta(minutes=31))
|
| 110 |
+
self.assertEqual((first, recent, expired), (True, False, True))
|
| 111 |
+
|
| 112 |
+
def test_finished_failure_keeps_retry_due_and_does_not_claim_success(self):
|
| 113 |
+
service = MonitoringService(ROOT, self.path / 'service.sqlite3')
|
| 114 |
+
now = datetime.now(UTC)
|
| 115 |
+
started = now - timedelta(minutes=4)
|
| 116 |
+
due = (now + timedelta(minutes=3)).isoformat()
|
| 117 |
+
service.store.set_state('last_cycle_started', started.isoformat())
|
| 118 |
+
service.store.set_state('last_cycle_finished', (started + timedelta(seconds=1)).isoformat())
|
| 119 |
+
service.store.set_state('next_cycle_due', due)
|
| 120 |
+
status = service.heartbeat(worker_id='new')
|
| 121 |
+
self.assertEqual(status['next_cycle_due'], due)
|
| 122 |
+
self.assertIsNone(status['last_cycle_completed'])
|
| 123 |
+
|
| 124 |
+
def test_interrupted_cycle_is_rescheduled_after_lease_expires(self):
|
| 125 |
+
service = MonitoringService(ROOT, self.path / 'service.sqlite3')
|
| 126 |
+
now = datetime.now(UTC)
|
| 127 |
+
service.store.set_state('last_cycle_started', (now - timedelta(minutes=4)).isoformat())
|
| 128 |
+
service.store.set_state('next_cycle_due', (now + timedelta(minutes=30)).isoformat())
|
| 129 |
+
status = service.heartbeat(worker_id='new')
|
| 130 |
+
self.assertLess(datetime.fromisoformat(status['next_cycle_due']), now + timedelta(seconds=10))
|
| 131 |
+
self.assertIsNone(status['last_cycle_completed'])
|