store.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  1. from __future__ import annotations
  2. import json
  3. import sqlite3
  4. from datetime import datetime, timezone
  5. from pathlib import Path
  6. from typing import Any
  7. ROOT = Path(__file__).resolve().parents[2]
  8. DATA_DIR = ROOT / "data"
  9. DB_PATH = DATA_DIR / "hermes_mcp.sqlite3"
  10. SCHEMA_STATEMENTS = [
  11. """
  12. create table if not exists concerns (
  13. id text primary key,
  14. account_id text,
  15. market_symbol text,
  16. base_currency text,
  17. quote_currency text,
  18. strategy_id text,
  19. decision_profile_id text,
  20. source text not null,
  21. status text not null default 'active',
  22. notes text,
  23. created_at text not null,
  24. updated_at text not null
  25. )
  26. """,
  27. """
  28. create table if not exists decision_profiles (
  29. id text primary key,
  30. name text not null,
  31. description text,
  32. config_json text not null,
  33. status text not null default 'active',
  34. created_at text not null,
  35. updated_at text not null
  36. )
  37. """,
  38. """
  39. create table if not exists strategy_groups (
  40. id text primary key,
  41. concern_id text not null,
  42. name text not null,
  43. strategy_family text,
  44. decision_profile_id text,
  45. notes text,
  46. status text not null default 'active',
  47. created_at text not null,
  48. updated_at text not null,
  49. foreign key(concern_id) references concerns(id) on delete cascade,
  50. foreign key(decision_profile_id) references decision_profiles(id)
  51. )
  52. """,
  53. """
  54. create table if not exists strategy_assignments (
  55. id text primary key,
  56. strategy_group_id text not null,
  57. strategy_id text not null,
  58. strategy_type text,
  59. role text,
  60. status text not null default 'active',
  61. notes text,
  62. created_at text not null,
  63. updated_at text not null,
  64. foreign key(strategy_group_id) references strategy_groups(id) on delete cascade
  65. )
  66. """,
  67. """
  68. create table if not exists cycles (
  69. id text primary key,
  70. started_at text not null,
  71. finished_at text,
  72. status text not null default 'running',
  73. trigger text not null,
  74. notes text
  75. )
  76. """,
  77. """
  78. create table if not exists observations (
  79. id text primary key,
  80. cycle_id text not null,
  81. concern_id text,
  82. source text not null,
  83. kind text not null,
  84. payload_json text not null,
  85. observed_at text not null,
  86. foreign key(cycle_id) references cycles(id),
  87. foreign key(concern_id) references concerns(id)
  88. )
  89. """,
  90. """
  91. create table if not exists states (
  92. id text primary key,
  93. cycle_id text not null,
  94. concern_id text not null,
  95. market_regime text,
  96. volatility_state text,
  97. liquidity_state text,
  98. sentiment_pressure text,
  99. event_risk text,
  100. execution_quality text,
  101. confidence real,
  102. payload_json text not null,
  103. created_at text not null,
  104. foreign key(cycle_id) references cycles(id),
  105. foreign key(concern_id) references concerns(id)
  106. )
  107. """,
  108. """
  109. create table if not exists narratives (
  110. id text primary key,
  111. cycle_id text not null,
  112. concern_id text not null,
  113. summary text not null,
  114. key_drivers_json text not null,
  115. risk_flags_json text not null,
  116. uncertainties_json text not null,
  117. confidence real,
  118. created_at text not null,
  119. foreign key(cycle_id) references cycles(id),
  120. foreign key(concern_id) references concerns(id)
  121. )
  122. """,
  123. """
  124. create table if not exists decisions (
  125. id text primary key,
  126. cycle_id text not null,
  127. concern_id text not null,
  128. action text not null,
  129. target_strategy text,
  130. target_policy_json text,
  131. reason_summary text,
  132. confidence real,
  133. requires_action integer not null default 0,
  134. created_at text not null,
  135. foreign key(cycle_id) references cycles(id),
  136. foreign key(concern_id) references concerns(id)
  137. )
  138. """,
  139. """
  140. create table if not exists actions (
  141. id text primary key,
  142. decision_id text not null,
  143. target text not null,
  144. command text not null,
  145. request_json text not null,
  146. response_json text,
  147. status text not null default 'pending',
  148. executed_at text,
  149. foreign key(decision_id) references decisions(id)
  150. )
  151. """,
  152. """
  153. create table if not exists coverage_gaps (
  154. id text primary key,
  155. cycle_id text not null,
  156. concern_id text,
  157. gap_type text not null,
  158. summary text not null,
  159. recommendation_json text not null,
  160. status text not null default 'open',
  161. created_at text not null,
  162. foreign key(cycle_id) references cycles(id),
  163. foreign key(concern_id) references concerns(id)
  164. )
  165. """,
  166. """
  167. create table if not exists regime_samples (
  168. id text primary key,
  169. cycle_id text not null,
  170. concern_id text not null,
  171. timeframe text not null,
  172. regime_json text not null,
  173. captured_at text not null,
  174. foreign key(cycle_id) references cycles(id),
  175. foreign key(concern_id) references concerns(id)
  176. )
  177. """,
  178. "create index if not exists idx_observations_cycle on observations(cycle_id)",
  179. "create index if not exists idx_observations_concern on observations(concern_id)",
  180. "create index if not exists idx_concerns_profile on concerns(decision_profile_id)",
  181. "create index if not exists idx_decision_profiles_status on decision_profiles(status)",
  182. "create index if not exists idx_strategy_groups_concern on strategy_groups(concern_id)",
  183. "create index if not exists idx_strategy_groups_profile on strategy_groups(decision_profile_id)",
  184. "create index if not exists idx_strategy_assignments_group on strategy_assignments(strategy_group_id)",
  185. "create index if not exists idx_strategy_assignments_strategy on strategy_assignments(strategy_id)",
  186. "create index if not exists idx_states_cycle on states(cycle_id)",
  187. "create index if not exists idx_states_concern on states(concern_id)",
  188. "create index if not exists idx_narratives_cycle on narratives(cycle_id)",
  189. "create index if not exists idx_decisions_cycle on decisions(cycle_id)",
  190. "create index if not exists idx_actions_decision on actions(decision_id)",
  191. "create index if not exists idx_gaps_cycle on coverage_gaps(cycle_id)",
  192. "create index if not exists idx_regime_samples_concern on regime_samples(concern_id)",
  193. ]
  194. def _now() -> str:
  195. return datetime.now(timezone.utc).isoformat()
  196. def _connect() -> sqlite3.Connection:
  197. DATA_DIR.mkdir(parents=True, exist_ok=True)
  198. conn = sqlite3.connect(DB_PATH)
  199. conn.row_factory = sqlite3.Row
  200. conn.execute("pragma foreign_keys = on")
  201. return conn
  202. def _migrate_decisions_schema(conn: sqlite3.Connection) -> None:
  203. decision_columns = {row[1] for row in conn.execute("pragma table_info(decisions)").fetchall()}
  204. if "mode" not in decision_columns:
  205. return
  206. conn.execute("pragma foreign_keys = off")
  207. conn.execute("alter table actions rename to actions_old")
  208. conn.execute("alter table decisions rename to decisions_old")
  209. conn.execute(
  210. """
  211. create table decisions (
  212. id text primary key,
  213. cycle_id text not null,
  214. concern_id text not null,
  215. action text not null,
  216. target_strategy text,
  217. target_policy_json text,
  218. reason_summary text,
  219. confidence real,
  220. requires_action integer not null default 0,
  221. created_at text not null,
  222. foreign key(cycle_id) references cycles(id),
  223. foreign key(concern_id) references concerns(id)
  224. )
  225. """
  226. )
  227. conn.execute(
  228. """
  229. create table actions (
  230. id text primary key,
  231. decision_id text not null,
  232. target text not null,
  233. command text not null,
  234. request_json text not null,
  235. response_json text,
  236. status text not null default 'pending',
  237. executed_at text,
  238. foreign key(decision_id) references decisions(id)
  239. )
  240. """
  241. )
  242. conn.execute(
  243. """
  244. insert into decisions(id, cycle_id, concern_id, action, target_strategy, target_policy_json, reason_summary, confidence, requires_action, created_at)
  245. select id, cycle_id, concern_id, action, target_strategy, target_policy_json, reason_summary, confidence, requires_action, created_at
  246. from decisions_old
  247. """
  248. )
  249. conn.execute(
  250. """
  251. insert into actions(id, decision_id, target, command, request_json, response_json, status, executed_at)
  252. select id, decision_id, target, command, request_json, response_json, status, executed_at
  253. from actions_old
  254. """
  255. )
  256. conn.execute("drop table actions_old")
  257. conn.execute("drop table decisions_old")
  258. conn.execute("pragma foreign_keys = on")
  259. def init_db() -> None:
  260. with _connect() as conn:
  261. for stmt in SCHEMA_STATEMENTS:
  262. if stmt.lstrip().lower().startswith("create index"):
  263. continue
  264. conn.execute(stmt)
  265. conn.execute(
  266. """
  267. create table if not exists state (
  268. key text primary key,
  269. value text not null,
  270. updated_at text not null default current_timestamp
  271. )
  272. """
  273. )
  274. concern_columns = {row[1] for row in conn.execute("pragma table_info(concerns)").fetchall()}
  275. for column in ("base_currency", "quote_currency", "decision_profile_id"):
  276. if column not in concern_columns:
  277. conn.execute(f"alter table concerns add column {column} text")
  278. _migrate_decisions_schema(conn)
  279. for stmt in SCHEMA_STATEMENTS:
  280. if not stmt.lstrip().lower().startswith("create index"):
  281. continue
  282. conn.execute(stmt)
  283. def get_state() -> dict[str, Any]:
  284. init_db()
  285. with _connect() as conn:
  286. row = conn.execute("select value from state where key = ?", ("snapshot",)).fetchone()
  287. if not row:
  288. return {
  289. "status": "stub",
  290. "thinking": "Hermes is scaffolded and waiting for integrations.",
  291. "layers": ["concerns", "decision_profiles", "strategy_groups", "strategy_assignments", "cycles", "observations", "states", "narratives", "decisions", "actions", "coverage_gaps"],
  292. }
  293. return json.loads(row["value"])
  294. def put_state(payload: dict[str, Any]) -> None:
  295. init_db()
  296. with _connect() as conn:
  297. conn.execute(
  298. "insert into state(key, value, updated_at) values(?, ?, current_timestamp) on conflict(key) do update set value=excluded.value, updated_at=current_timestamp",
  299. ("snapshot", json.dumps(payload)),
  300. )
  301. def upsert_concern(*, id: str, account_id: str | None, market_symbol: str | None, base_currency: str | None = None, quote_currency: str | None = None, strategy_id: str | None, decision_profile_id: str | None = None, source: str, status: str = "active", notes: str | None = None) -> None:
  302. init_db()
  303. now = _now()
  304. with _connect() as conn:
  305. conn.execute(
  306. """
  307. insert into concerns(id, account_id, market_symbol, base_currency, quote_currency, strategy_id, decision_profile_id, source, status, notes, created_at, updated_at)
  308. values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  309. on conflict(id) do update set
  310. account_id=excluded.account_id,
  311. market_symbol=excluded.market_symbol,
  312. base_currency=excluded.base_currency,
  313. quote_currency=excluded.quote_currency,
  314. strategy_id=excluded.strategy_id,
  315. decision_profile_id=excluded.decision_profile_id,
  316. source=excluded.source,
  317. status=excluded.status,
  318. notes=excluded.notes,
  319. updated_at=excluded.updated_at
  320. """,
  321. (id, account_id, market_symbol, base_currency, quote_currency, strategy_id, decision_profile_id, source, status, notes, now, now),
  322. )
  323. def list_concerns() -> list[dict[str, Any]]:
  324. init_db()
  325. with _connect() as conn:
  326. rows = conn.execute("select * from concerns order by updated_at desc").fetchall()
  327. return [dict(r) for r in rows]
  328. def upsert_decision_profile(*, id: str, name: str, config: dict[str, Any], description: str | None = None, status: str = "active") -> None:
  329. init_db()
  330. now = _now()
  331. with _connect() as conn:
  332. conn.execute(
  333. """
  334. insert into decision_profiles(id, name, description, config_json, status, created_at, updated_at)
  335. values(?, ?, ?, ?, ?, ?, ?)
  336. on conflict(id) do update set
  337. name=excluded.name,
  338. description=excluded.description,
  339. config_json=excluded.config_json,
  340. status=excluded.status,
  341. updated_at=excluded.updated_at
  342. """,
  343. (id, name, description, json.dumps(config, ensure_ascii=False), status, now, now),
  344. )
  345. def list_decision_profiles() -> list[dict[str, Any]]:
  346. init_db()
  347. with _connect() as conn:
  348. rows = conn.execute("select * from decision_profiles order by updated_at desc").fetchall()
  349. return [dict(r) for r in rows]
  350. def get_decision_profile(*, profile_id: str) -> dict[str, Any] | None:
  351. init_db()
  352. profile_id = str(profile_id or "").strip()
  353. if not profile_id:
  354. return None
  355. with _connect() as conn:
  356. row = conn.execute("select * from decision_profiles where id = ?", (profile_id,)).fetchone()
  357. return dict(row) if row else None
  358. def upsert_strategy_group(*, id: str, concern_id: str, name: str, strategy_family: str | None = None, decision_profile_id: str | None = None, notes: str | None = None, status: str = "active") -> None:
  359. init_db()
  360. now = _now()
  361. with _connect() as conn:
  362. conn.execute(
  363. """
  364. insert into strategy_groups(id, concern_id, name, strategy_family, decision_profile_id, notes, status, created_at, updated_at)
  365. values(?, ?, ?, ?, ?, ?, ?, ?, ?)
  366. on conflict(id) do update set
  367. concern_id=excluded.concern_id,
  368. name=excluded.name,
  369. strategy_family=excluded.strategy_family,
  370. decision_profile_id=excluded.decision_profile_id,
  371. notes=excluded.notes,
  372. status=excluded.status,
  373. updated_at=excluded.updated_at
  374. """,
  375. (id, concern_id, name, strategy_family, decision_profile_id, notes, status, now, now),
  376. )
  377. def list_strategy_groups(*, concern_id: str | None = None) -> list[dict[str, Any]]:
  378. init_db()
  379. with _connect() as conn:
  380. if concern_id:
  381. rows = conn.execute("select * from strategy_groups where concern_id = ? order by updated_at desc", (concern_id,)).fetchall()
  382. else:
  383. rows = conn.execute("select * from strategy_groups order by updated_at desc").fetchall()
  384. return [dict(r) for r in rows]
  385. def upsert_strategy_assignment(*, id: str, strategy_group_id: str, strategy_id: str, strategy_type: str | None = None, role: str | None = None, status: str = "active", notes: str | None = None) -> None:
  386. init_db()
  387. now = _now()
  388. with _connect() as conn:
  389. conn.execute(
  390. """
  391. insert into strategy_assignments(id, strategy_group_id, strategy_id, strategy_type, role, status, notes, created_at, updated_at)
  392. values(?, ?, ?, ?, ?, ?, ?, ?, ?)
  393. on conflict(id) do update set
  394. strategy_group_id=excluded.strategy_group_id,
  395. strategy_id=excluded.strategy_id,
  396. strategy_type=excluded.strategy_type,
  397. role=excluded.role,
  398. status=excluded.status,
  399. notes=excluded.notes,
  400. updated_at=excluded.updated_at
  401. """,
  402. (id, strategy_group_id, strategy_id, strategy_type, role, status, notes, now, now),
  403. )
  404. def list_strategy_assignments(*, strategy_group_id: str | None = None) -> list[dict[str, Any]]:
  405. init_db()
  406. with _connect() as conn:
  407. if strategy_group_id:
  408. rows = conn.execute("select * from strategy_assignments where strategy_group_id = ? order by updated_at desc", (strategy_group_id,)).fetchall()
  409. else:
  410. rows = conn.execute("select * from strategy_assignments order by updated_at desc").fetchall()
  411. return [dict(r) for r in rows]
  412. def delete_concern(*, concern_id: str) -> dict[str, int]:
  413. init_db()
  414. concern_id = str(concern_id or "").strip()
  415. if not concern_id:
  416. return {"concerns": 0, "strategy_groups": 0, "strategy_assignments": 0, "observations": 0, "states": 0, "narratives": 0, "decisions": 0, "actions": 0, "coverage_gaps": 0, "regime_samples": 0}
  417. deleted = {"concerns": 0, "strategy_groups": 0, "strategy_assignments": 0, "observations": 0, "states": 0, "narratives": 0, "decisions": 0, "actions": 0, "coverage_gaps": 0, "regime_samples": 0}
  418. with _connect() as conn:
  419. decision_ids = [row[0] for row in conn.execute("select id from decisions where concern_id = ?", (concern_id,)).fetchall()]
  420. group_ids = [row[0] for row in conn.execute("select id from strategy_groups where concern_id = ?", (concern_id,)).fetchall()]
  421. deleted["actions"] = conn.execute(
  422. f"delete from actions where decision_id in ({','.join('?' for _ in decision_ids)})",
  423. decision_ids,
  424. ).rowcount if decision_ids else 0
  425. deleted["strategy_assignments"] = conn.execute(
  426. f"delete from strategy_assignments where strategy_group_id in ({','.join('?' for _ in group_ids)})",
  427. group_ids,
  428. ).rowcount if group_ids else 0
  429. deleted["strategy_groups"] = conn.execute("delete from strategy_groups where concern_id = ?", (concern_id,)).rowcount or 0
  430. deleted["observations"] = conn.execute("delete from observations where concern_id = ?", (concern_id,)).rowcount or 0
  431. deleted["states"] = conn.execute("delete from states where concern_id = ?", (concern_id,)).rowcount or 0
  432. deleted["narratives"] = conn.execute("delete from narratives where concern_id = ?", (concern_id,)).rowcount or 0
  433. deleted["coverage_gaps"] = conn.execute("delete from coverage_gaps where concern_id = ?", (concern_id,)).rowcount or 0
  434. deleted["regime_samples"] = conn.execute("delete from regime_samples where concern_id = ?", (concern_id,)).rowcount or 0
  435. deleted["decisions"] = conn.execute("delete from decisions where concern_id = ?", (concern_id,)).rowcount or 0
  436. deleted["concerns"] = conn.execute("delete from concerns where id = ?", (concern_id,)).rowcount or 0
  437. return deleted
  438. def prune_older_than(days: int) -> dict[str, int]:
  439. init_db()
  440. cutoff = datetime.now(timezone.utc).timestamp() - (days * 86400)
  441. cutoff_iso = datetime.fromtimestamp(cutoff, tz=timezone.utc).isoformat()
  442. with _connect() as conn:
  443. deleted = {}
  444. for table in ("actions", "decisions", "narratives", "states", "observations", "coverage_gaps", "cycles"):
  445. if table == "actions":
  446. where = "executed_at is not null and executed_at < ?"
  447. elif table in {"decisions", "narratives", "states", "observations", "coverage_gaps", "cycles"}:
  448. where = "created_at < ?" if table != "cycles" else "started_at < ?"
  449. else:
  450. continue
  451. cur = conn.execute(f"delete from {table} where {where}", (cutoff_iso,))
  452. deleted[table] = cur.rowcount if cur.rowcount is not None else 0
  453. return deleted
  454. def sync_concerns_from_strategies(strategies: list[dict[str, Any]]) -> list[dict[str, Any]]:
  455. seen: set[str] = set()
  456. synced: list[dict[str, Any]] = []
  457. existing = {str(c.get("id") or ""): c for c in list_concerns()}
  458. for s in strategies:
  459. account_id = str(s.get("account_id") or "").strip() or None
  460. market_symbol = str(s.get("market_symbol") or "").strip() or None
  461. base_currency = str(s.get("base_currency") or "").strip() or None
  462. quote_currency = str(s.get("counter_currency") or s.get("quote_currency") or "").strip() or None
  463. strategy_id = str(s.get("id") or s.get("name") or s.get("strategy_type") or "").strip() or None
  464. if not account_id or not market_symbol:
  465. continue
  466. concern_id = f"{account_id}:{market_symbol}"
  467. if concern_id in seen:
  468. continue
  469. seen.add(concern_id)
  470. current = existing.get(concern_id, {})
  471. upsert_concern(
  472. id=concern_id,
  473. account_id=account_id,
  474. market_symbol=market_symbol,
  475. base_currency=base_currency,
  476. quote_currency=quote_currency,
  477. strategy_id=strategy_id,
  478. decision_profile_id=str(current.get("decision_profile_id") or "").strip() or None,
  479. source=str(current.get("source") or "trader_inventory"),
  480. status=str(current.get("status") or "active"),
  481. notes=str(current.get("notes") or "mirrored from trader strategy inventory").strip() or None,
  482. )
  483. synced.append({"id": concern_id, "account_id": account_id, "market_symbol": market_symbol, "base_currency": base_currency, "quote_currency": quote_currency, "strategy_id": strategy_id})
  484. return synced
  485. def table_counts() -> dict[str, int]:
  486. init_db()
  487. tables = ["concerns", "decision_profiles", "strategy_groups", "strategy_assignments", "cycles", "observations", "states", "narratives", "decisions", "actions", "coverage_gaps", "regime_samples"]
  488. out: dict[str, int] = {}
  489. with _connect() as conn:
  490. for table in tables:
  491. out[table] = int(conn.execute(f"select count(*) as n from {table}").fetchone()["n"])
  492. return out
  493. def latest_cycle() -> dict[str, Any] | None:
  494. init_db()
  495. with _connect() as conn:
  496. row = conn.execute("select * from cycles order by started_at desc limit 1").fetchone()
  497. return dict(row) if row else None
  498. def latest_cycles(limit: int = 20) -> list[dict[str, Any]]:
  499. init_db()
  500. with _connect() as conn:
  501. rows = conn.execute("select * from cycles order by started_at desc limit ?", (limit,)).fetchall()
  502. return [dict(r) for r in rows]
  503. def upsert_cycle(*, id: str, started_at: str, finished_at: str | None, status: str, trigger: str, notes: str | None = None) -> None:
  504. init_db()
  505. with _connect() as conn:
  506. conn.execute(
  507. """
  508. insert into cycles(id, started_at, finished_at, status, trigger, notes)
  509. values(?, ?, ?, ?, ?, ?)
  510. on conflict(id) do update set
  511. started_at=excluded.started_at,
  512. finished_at=excluded.finished_at,
  513. status=excluded.status,
  514. trigger=excluded.trigger,
  515. notes=excluded.notes
  516. """,
  517. (id, started_at, finished_at, status, trigger, notes),
  518. )
  519. def upsert_regime_sample(*, id: str, cycle_id: str, concern_id: str, timeframe: str, regime_json: str, captured_at: str) -> None:
  520. init_db()
  521. with _connect() as conn:
  522. conn.execute(
  523. """
  524. insert into regime_samples(id, cycle_id, concern_id, timeframe, regime_json, captured_at)
  525. values(?, ?, ?, ?, ?, ?)
  526. on conflict(id) do update set
  527. cycle_id=excluded.cycle_id,
  528. concern_id=excluded.concern_id,
  529. timeframe=excluded.timeframe,
  530. regime_json=excluded.regime_json,
  531. captured_at=excluded.captured_at
  532. """,
  533. (id, cycle_id, concern_id, timeframe, regime_json, captured_at),
  534. )
  535. def upsert_observation(*, id: str, cycle_id: str, concern_id: str | None, source: str, kind: str, payload_json: str, observed_at: str | None = None) -> None:
  536. init_db()
  537. observed_at = observed_at or _now()
  538. with _connect() as conn:
  539. conn.execute(
  540. """
  541. insert into observations(id, cycle_id, concern_id, source, kind, payload_json, observed_at)
  542. values(?, ?, ?, ?, ?, ?, ?)
  543. on conflict(id) do update set
  544. cycle_id=excluded.cycle_id,
  545. concern_id=excluded.concern_id,
  546. source=excluded.source,
  547. kind=excluded.kind,
  548. payload_json=excluded.payload_json,
  549. observed_at=excluded.observed_at
  550. """,
  551. (id, cycle_id, concern_id, source, kind, payload_json, observed_at),
  552. )
  553. def upsert_state(*, id: str, cycle_id: str, concern_id: str, market_regime: str | None, volatility_state: str | None, liquidity_state: str | None, sentiment_pressure: str | None, event_risk: str | None, execution_quality: str | None, confidence: float | None, payload_json: str, created_at: str | None = None) -> None:
  554. init_db()
  555. created_at = created_at or _now()
  556. with _connect() as conn:
  557. conn.execute(
  558. """
  559. insert into states(id, cycle_id, concern_id, market_regime, volatility_state, liquidity_state, sentiment_pressure, event_risk, execution_quality, confidence, payload_json, created_at)
  560. values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  561. on conflict(id) do update set
  562. cycle_id=excluded.cycle_id,
  563. concern_id=excluded.concern_id,
  564. market_regime=excluded.market_regime,
  565. volatility_state=excluded.volatility_state,
  566. liquidity_state=excluded.liquidity_state,
  567. sentiment_pressure=excluded.sentiment_pressure,
  568. event_risk=excluded.event_risk,
  569. execution_quality=excluded.execution_quality,
  570. confidence=excluded.confidence,
  571. payload_json=excluded.payload_json,
  572. created_at=excluded.created_at
  573. """,
  574. (id, cycle_id, concern_id, market_regime, volatility_state, liquidity_state, sentiment_pressure, event_risk, execution_quality, confidence, payload_json, created_at),
  575. )
  576. def upsert_narrative(*, id: str, cycle_id: str, concern_id: str, summary: str, key_drivers_json: str, risk_flags_json: str, uncertainties_json: str, confidence: float | None, created_at: str | None = None) -> None:
  577. init_db()
  578. created_at = created_at or _now()
  579. with _connect() as conn:
  580. conn.execute(
  581. """
  582. insert into narratives(id, cycle_id, concern_id, summary, key_drivers_json, risk_flags_json, uncertainties_json, confidence, created_at)
  583. values(?, ?, ?, ?, ?, ?, ?, ?, ?)
  584. on conflict(id) do update set
  585. cycle_id=excluded.cycle_id,
  586. concern_id=excluded.concern_id,
  587. summary=excluded.summary,
  588. key_drivers_json=excluded.key_drivers_json,
  589. risk_flags_json=excluded.risk_flags_json,
  590. uncertainties_json=excluded.uncertainties_json,
  591. confidence=excluded.confidence,
  592. created_at=excluded.created_at
  593. """,
  594. (id, cycle_id, concern_id, summary, key_drivers_json, risk_flags_json, uncertainties_json, confidence, created_at),
  595. )
  596. def upsert_decision(*, id: str, cycle_id: str, concern_id: str, action: str, target_strategy: str | None, target_policy_json: str | None, reason_summary: str | None, confidence: float | None, requires_action: bool, created_at: str | None = None) -> None:
  597. init_db()
  598. created_at = created_at or _now()
  599. with _connect() as conn:
  600. conn.execute(
  601. """
  602. insert into decisions(id, cycle_id, concern_id, action, target_strategy, target_policy_json, reason_summary, confidence, requires_action, created_at)
  603. values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  604. on conflict(id) do update set
  605. cycle_id=excluded.cycle_id,
  606. concern_id=excluded.concern_id,
  607. action=excluded.action,
  608. target_strategy=excluded.target_strategy,
  609. target_policy_json=excluded.target_policy_json,
  610. reason_summary=excluded.reason_summary,
  611. confidence=excluded.confidence,
  612. requires_action=excluded.requires_action,
  613. created_at=excluded.created_at
  614. """,
  615. (id, cycle_id, concern_id, action, target_strategy, target_policy_json, reason_summary, confidence, 1 if requires_action else 0, created_at),
  616. )
  617. def latest_states(limit: int = 20) -> list[dict[str, Any]]:
  618. init_db()
  619. with _connect() as conn:
  620. rows = conn.execute("select * from states order by created_at desc limit ?", (limit,)).fetchall()
  621. return [dict(r) for r in rows]
  622. def recent_states_for_concern(*, concern_id: str, since_seconds: int, limit: int = 24) -> list[dict[str, Any]]:
  623. init_db()
  624. cutoff = datetime.now(timezone.utc).timestamp() - max(1, since_seconds)
  625. cutoff_iso = datetime.fromtimestamp(cutoff, tz=timezone.utc).isoformat()
  626. with _connect() as conn:
  627. rows = conn.execute(
  628. "select * from states where concern_id = ? and created_at >= ? order by created_at asc limit ?",
  629. (concern_id, cutoff_iso, limit),
  630. ).fetchall()
  631. return [dict(r) for r in rows]
  632. def latest_decisions(limit: int = 20) -> list[dict[str, Any]]:
  633. init_db()
  634. with _connect() as conn:
  635. rows = conn.execute("select * from decisions order by created_at desc limit ?", (limit,)).fetchall()
  636. return [dict(r) for r in rows]
  637. def latest_narratives(limit: int = 20) -> list[dict[str, Any]]:
  638. init_db()
  639. with _connect() as conn:
  640. rows = conn.execute("select * from narratives order by created_at desc limit ?", (limit,)).fetchall()
  641. return [dict(r) for r in rows]
  642. def latest_regime_samples(limit: int = 20) -> list[dict[str, Any]]:
  643. init_db()
  644. with _connect() as conn:
  645. rows = conn.execute("select * from regime_samples order by captured_at desc limit ?", (limit,)).fetchall()
  646. return [dict(r) for r in rows]
  647. def latest_observations(limit: int = 20, source: str | None = None) -> list[dict[str, Any]]:
  648. init_db()
  649. with _connect() as conn:
  650. if source:
  651. rows = conn.execute(
  652. "select * from observations where source = ? order by observed_at desc limit ?",
  653. (source, limit),
  654. ).fetchall()
  655. else:
  656. rows = conn.execute("select * from observations order by observed_at desc limit ?", (limit,)).fetchall()
  657. return [dict(r) for r in rows]
  658. def recent_regime_samples(limit: int = 200) -> list[dict[str, Any]]:
  659. init_db()
  660. with _connect() as conn:
  661. rows = conn.execute("select * from regime_samples order by captured_at desc limit ?", (limit,)).fetchall()
  662. return [dict(r) for r in rows]