chonthichar commited on
Commit
6f8f877
·
verified ·
1 Parent(s): c2769af

Add event-triggered monitoring and durable private history

Browse files
.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
- research = self.research_agent.run(redact(request.question), mode=request.mode, prediction_timestamp=request.prediction_timestamp)
 
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
- research = self.research_agent.run(question)
 
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
- """Hourly scheduler; FastAPI is the single inference and database owner."""
2
- from __future__ import annotations
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
- message = str(exc)
28
- for name in ("ALPHA_VANTAGE_API_KEY", "OPENAI_API_KEY"):
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
- self.store = MonitoringStore(db_path or os.getenv("BTC_MONITOR_DB", str(self.root / "runtime/monitoring.sqlite3")))
 
 
 
 
 
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
- recorded = self.store.record_forecasts(data["forecasts"], issued_at=issued_at)
 
 
 
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'])