|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
|
|
|
import json
|
|
|
import sqlite3
|
|
|
+from datetime import datetime, timezone
|
|
|
from pathlib import Path
|
|
|
from typing import Any
|
|
|
|
|
|
@@ -9,16 +10,162 @@ ROOT = Path(__file__).resolve().parents[2]
|
|
|
DATA_DIR = ROOT / "data"
|
|
|
DB_PATH = DATA_DIR / "hermes_mcp.sqlite3"
|
|
|
|
|
|
+SCHEMA_STATEMENTS = [
|
|
|
+ """
|
|
|
+ create table if not exists concerns (
|
|
|
+ id text primary key,
|
|
|
+ account_id text,
|
|
|
+ market_symbol text,
|
|
|
+ base_currency text,
|
|
|
+ quote_currency text,
|
|
|
+ strategy_id text,
|
|
|
+ source text not null,
|
|
|
+ status text not null default 'active',
|
|
|
+ notes text,
|
|
|
+ created_at text not null,
|
|
|
+ updated_at text not null
|
|
|
+ )
|
|
|
+ """,
|
|
|
+ """
|
|
|
+ create table if not exists cycles (
|
|
|
+ id text primary key,
|
|
|
+ started_at text not null,
|
|
|
+ finished_at text,
|
|
|
+ status text not null default 'running',
|
|
|
+ trigger text not null,
|
|
|
+ notes text
|
|
|
+ )
|
|
|
+ """,
|
|
|
+ """
|
|
|
+ create table if not exists observations (
|
|
|
+ id text primary key,
|
|
|
+ cycle_id text not null,
|
|
|
+ concern_id text,
|
|
|
+ source text not null,
|
|
|
+ kind text not null,
|
|
|
+ payload_json text not null,
|
|
|
+ observed_at text not null,
|
|
|
+ foreign key(cycle_id) references cycles(id),
|
|
|
+ foreign key(concern_id) references concerns(id)
|
|
|
+ )
|
|
|
+ """,
|
|
|
+ """
|
|
|
+ create table if not exists states (
|
|
|
+ id text primary key,
|
|
|
+ cycle_id text not null,
|
|
|
+ concern_id text not null,
|
|
|
+ market_regime text,
|
|
|
+ volatility_state text,
|
|
|
+ liquidity_state text,
|
|
|
+ sentiment_pressure text,
|
|
|
+ event_risk text,
|
|
|
+ execution_quality text,
|
|
|
+ confidence real,
|
|
|
+ payload_json text not null,
|
|
|
+ created_at text not null,
|
|
|
+ foreign key(cycle_id) references cycles(id),
|
|
|
+ foreign key(concern_id) references concerns(id)
|
|
|
+ )
|
|
|
+ """,
|
|
|
+ """
|
|
|
+ create table if not exists narratives (
|
|
|
+ id text primary key,
|
|
|
+ cycle_id text not null,
|
|
|
+ concern_id text not null,
|
|
|
+ summary text not null,
|
|
|
+ key_drivers_json text not null,
|
|
|
+ risk_flags_json text not null,
|
|
|
+ uncertainties_json text not null,
|
|
|
+ confidence real,
|
|
|
+ created_at text not null,
|
|
|
+ foreign key(cycle_id) references cycles(id),
|
|
|
+ foreign key(concern_id) references concerns(id)
|
|
|
+ )
|
|
|
+ """,
|
|
|
+ """
|
|
|
+ create table if not exists decisions (
|
|
|
+ id text primary key,
|
|
|
+ cycle_id text not null,
|
|
|
+ concern_id text not null,
|
|
|
+ mode text not null,
|
|
|
+ action text not null,
|
|
|
+ target_strategy text,
|
|
|
+ target_policy_json text,
|
|
|
+ reason_summary text,
|
|
|
+ confidence real,
|
|
|
+ requires_action integer not null default 0,
|
|
|
+ created_at text not null,
|
|
|
+ foreign key(cycle_id) references cycles(id),
|
|
|
+ foreign key(concern_id) references concerns(id)
|
|
|
+ )
|
|
|
+ """,
|
|
|
+ """
|
|
|
+ create table if not exists actions (
|
|
|
+ id text primary key,
|
|
|
+ decision_id text not null,
|
|
|
+ target text not null,
|
|
|
+ command text not null,
|
|
|
+ request_json text not null,
|
|
|
+ response_json text,
|
|
|
+ status text not null default 'pending',
|
|
|
+ executed_at text,
|
|
|
+ foreign key(decision_id) references decisions(id)
|
|
|
+ )
|
|
|
+ """,
|
|
|
+ """
|
|
|
+ create table if not exists coverage_gaps (
|
|
|
+ id text primary key,
|
|
|
+ cycle_id text not null,
|
|
|
+ concern_id text,
|
|
|
+ gap_type text not null,
|
|
|
+ summary text not null,
|
|
|
+ recommendation_json text not null,
|
|
|
+ status text not null default 'open',
|
|
|
+ created_at text not null,
|
|
|
+ foreign key(cycle_id) references cycles(id),
|
|
|
+ foreign key(concern_id) references concerns(id)
|
|
|
+ )
|
|
|
+ """,
|
|
|
+ """
|
|
|
+ create table if not exists regime_samples (
|
|
|
+ id text primary key,
|
|
|
+ cycle_id text not null,
|
|
|
+ concern_id text not null,
|
|
|
+ timeframe text not null,
|
|
|
+ regime_json text not null,
|
|
|
+ captured_at text not null,
|
|
|
+ foreign key(cycle_id) references cycles(id),
|
|
|
+ foreign key(concern_id) references concerns(id)
|
|
|
+ )
|
|
|
+ """,
|
|
|
+ "create index if not exists idx_observations_cycle on observations(cycle_id)",
|
|
|
+ "create index if not exists idx_observations_concern on observations(concern_id)",
|
|
|
+ "create index if not exists idx_states_cycle on states(cycle_id)",
|
|
|
+ "create index if not exists idx_states_concern on states(concern_id)",
|
|
|
+ "create index if not exists idx_narratives_cycle on narratives(cycle_id)",
|
|
|
+ "create index if not exists idx_decisions_cycle on decisions(cycle_id)",
|
|
|
+ "create index if not exists idx_actions_decision on actions(decision_id)",
|
|
|
+ "create index if not exists idx_gaps_cycle on coverage_gaps(cycle_id)",
|
|
|
+ "create index if not exists idx_regime_samples_concern on regime_samples(concern_id)",
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+def _now() -> str:
|
|
|
+ return datetime.now(timezone.utc).isoformat()
|
|
|
+
|
|
|
|
|
|
def _connect() -> sqlite3.Connection:
|
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
conn = sqlite3.connect(DB_PATH)
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
+ conn.execute("pragma foreign_keys = on")
|
|
|
return conn
|
|
|
|
|
|
|
|
|
def init_db() -> None:
|
|
|
with _connect() as conn:
|
|
|
+ for stmt in SCHEMA_STATEMENTS:
|
|
|
+ conn.execute(stmt)
|
|
|
conn.execute(
|
|
|
"""
|
|
|
create table if not exists state (
|
|
|
@@ -27,7 +174,12 @@ def init_db() -> None:
|
|
|
updated_at text not null default current_timestamp
|
|
|
)
|
|
|
"""
|
|
|
- )
|
|
|
+ )
|
|
|
+
|
|
|
+ concern_columns = {row[1] for row in conn.execute("pragma table_info(concerns)").fetchall()}
|
|
|
+ for column in ("base_currency", "quote_currency"):
|
|
|
+ if column not in concern_columns:
|
|
|
+ conn.execute(f"alter table concerns add column {column} text")
|
|
|
|
|
|
|
|
|
def get_state() -> dict[str, Any]:
|
|
|
@@ -38,7 +190,7 @@ def get_state() -> dict[str, Any]:
|
|
|
return {
|
|
|
"status": "stub",
|
|
|
"thinking": "Hermes is scaffolded and waiting for integrations.",
|
|
|
- "layers": ["overview", "signals", "features", "narrative", "decision", "explanation"],
|
|
|
+ "layers": ["concerns", "cycles", "observations", "states", "narratives", "decisions", "actions", "coverage_gaps"],
|
|
|
}
|
|
|
return json.loads(row["value"])
|
|
|
|
|
|
@@ -50,3 +202,141 @@ def put_state(payload: dict[str, Any]) -> None:
|
|
|
"insert into state(key, value, updated_at) values(?, ?, current_timestamp) on conflict(key) do update set value=excluded.value, updated_at=current_timestamp",
|
|
|
("snapshot", json.dumps(payload)),
|
|
|
)
|
|
|
+
|
|
|
+
|
|
|
+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, source: str, status: str = "active", notes: str | None = None) -> None:
|
|
|
+ init_db()
|
|
|
+ now = _now()
|
|
|
+ with _connect() as conn:
|
|
|
+ conn.execute(
|
|
|
+ """
|
|
|
+ insert into concerns(id, account_id, market_symbol, base_currency, quote_currency, strategy_id, source, status, notes, created_at, updated_at)
|
|
|
+ values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
+ on conflict(id) do update set
|
|
|
+ account_id=excluded.account_id,
|
|
|
+ market_symbol=excluded.market_symbol,
|
|
|
+ base_currency=excluded.base_currency,
|
|
|
+ quote_currency=excluded.quote_currency,
|
|
|
+ strategy_id=excluded.strategy_id,
|
|
|
+ source=excluded.source,
|
|
|
+ status=excluded.status,
|
|
|
+ notes=excluded.notes,
|
|
|
+ updated_at=excluded.updated_at
|
|
|
+ """,
|
|
|
+ (id, account_id, market_symbol, base_currency, quote_currency, strategy_id, source, status, notes, now, now),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def list_concerns() -> list[dict[str, Any]]:
|
|
|
+ init_db()
|
|
|
+ with _connect() as conn:
|
|
|
+ rows = conn.execute("select * from concerns order by updated_at desc").fetchall()
|
|
|
+ return [dict(r) for r in rows]
|
|
|
+
|
|
|
+
|
|
|
+def prune_older_than(days: int) -> dict[str, int]:
|
|
|
+ init_db()
|
|
|
+ cutoff = datetime.now(timezone.utc).timestamp() - (days * 86400)
|
|
|
+ cutoff_iso = datetime.fromtimestamp(cutoff, tz=timezone.utc).isoformat()
|
|
|
+ with _connect() as conn:
|
|
|
+ deleted = {}
|
|
|
+ for table in ("actions", "decisions", "narratives", "states", "observations", "coverage_gaps", "cycles"):
|
|
|
+ if table == "actions":
|
|
|
+ where = "executed_at is not null and executed_at < ?"
|
|
|
+ elif table in {"decisions", "narratives", "states", "observations", "coverage_gaps", "cycles"}:
|
|
|
+ where = "created_at < ?" if table != "cycles" else "started_at < ?"
|
|
|
+ else:
|
|
|
+ continue
|
|
|
+ cur = conn.execute(f"delete from {table} where {where}", (cutoff_iso,))
|
|
|
+ deleted[table] = cur.rowcount if cur.rowcount is not None else 0
|
|
|
+ return deleted
|
|
|
+
|
|
|
+
|
|
|
+def sync_concerns_from_strategies(strategies: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
|
+ seen: set[str] = set()
|
|
|
+ synced: list[dict[str, Any]] = []
|
|
|
+ for s in strategies:
|
|
|
+ account_id = str(s.get("account_id") or "").strip() or None
|
|
|
+ market_symbol = str(s.get("market_symbol") or "").strip() or None
|
|
|
+ base_currency = str(s.get("base_currency") or "").strip() or None
|
|
|
+ quote_currency = str(s.get("counter_currency") or s.get("quote_currency") or "").strip() or None
|
|
|
+ strategy_id = str(s.get("id") or s.get("name") or s.get("strategy_type") or "").strip() or None
|
|
|
+ if not account_id or not market_symbol:
|
|
|
+ continue
|
|
|
+ concern_id = f"{account_id}:{market_symbol}"
|
|
|
+ if concern_id in seen:
|
|
|
+ continue
|
|
|
+ seen.add(concern_id)
|
|
|
+ upsert_concern(
|
|
|
+ id=concern_id,
|
|
|
+ account_id=account_id,
|
|
|
+ market_symbol=market_symbol,
|
|
|
+ base_currency=base_currency,
|
|
|
+ quote_currency=quote_currency,
|
|
|
+ strategy_id=strategy_id,
|
|
|
+ source="trader_inventory",
|
|
|
+ status="active",
|
|
|
+ notes="mirrored from trader strategy inventory",
|
|
|
+ )
|
|
|
+ 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})
|
|
|
+ return synced
|
|
|
+
|
|
|
+
|
|
|
+def table_counts() -> dict[str, int]:
|
|
|
+ init_db()
|
|
|
+ tables = ["concerns", "cycles", "observations", "states", "narratives", "decisions", "actions", "coverage_gaps", "regime_samples"]
|
|
|
+ out: dict[str, int] = {}
|
|
|
+ with _connect() as conn:
|
|
|
+ for table in tables:
|
|
|
+ out[table] = int(conn.execute(f"select count(*) as n from {table}").fetchone()["n"])
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+def latest_cycle() -> dict[str, Any] | None:
|
|
|
+ init_db()
|
|
|
+ with _connect() as conn:
|
|
|
+ row = conn.execute("select * from cycles order by started_at desc limit 1").fetchone()
|
|
|
+ return dict(row) if row else None
|
|
|
+
|
|
|
+
|
|
|
+def upsert_cycle(*, id: str, started_at: str, finished_at: str | None, status: str, trigger: str, notes: str | None = None) -> None:
|
|
|
+ init_db()
|
|
|
+ with _connect() as conn:
|
|
|
+ conn.execute(
|
|
|
+ """
|
|
|
+ insert into cycles(id, started_at, finished_at, status, trigger, notes)
|
|
|
+ values(?, ?, ?, ?, ?, ?)
|
|
|
+ on conflict(id) do update set
|
|
|
+ started_at=excluded.started_at,
|
|
|
+ finished_at=excluded.finished_at,
|
|
|
+ status=excluded.status,
|
|
|
+ trigger=excluded.trigger,
|
|
|
+ notes=excluded.notes
|
|
|
+ """,
|
|
|
+ (id, started_at, finished_at, status, trigger, notes),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def upsert_regime_sample(*, id: str, cycle_id: str, concern_id: str, timeframe: str, regime_json: str, captured_at: str) -> None:
|
|
|
+ init_db()
|
|
|
+ with _connect() as conn:
|
|
|
+ conn.execute(
|
|
|
+ """
|
|
|
+ insert into regime_samples(id, cycle_id, concern_id, timeframe, regime_json, captured_at)
|
|
|
+ values(?, ?, ?, ?, ?, ?)
|
|
|
+ on conflict(id) do update set
|
|
|
+ cycle_id=excluded.cycle_id,
|
|
|
+ concern_id=excluded.concern_id,
|
|
|
+ timeframe=excluded.timeframe,
|
|
|
+ regime_json=excluded.regime_json,
|
|
|
+ captured_at=excluded.captured_at
|
|
|
+ """,
|
|
|
+ (id, cycle_id, concern_id, timeframe, regime_json, captured_at),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def latest_regime_samples(limit: int = 20) -> list[dict[str, Any]]:
|
|
|
+ init_db()
|
|
|
+ with _connect() as conn:
|
|
|
+ rows = conn.execute("select * from regime_samples order by captured_at desc limit ?", (limit,)).fetchall()
|
|
|
+ return [dict(r) for r in rows]
|