513 lines
19 KiB
Python
513 lines
19 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Run-scoped SQLite storage for query-oriented runtime history."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Iterable, Optional
|
|
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id TEXT PRIMARY KEY,
|
|
event_type TEXT NOT NULL,
|
|
timestamp TEXT,
|
|
agent_id TEXT,
|
|
agent_name TEXT,
|
|
ticker TEXT,
|
|
title TEXT,
|
|
content TEXT,
|
|
payload_json TEXT NOT NULL,
|
|
run_date TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(event_type, timestamp DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_events_ticker_time ON events(ticker, timestamp DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS trades (
|
|
id TEXT PRIMARY KEY,
|
|
ticker TEXT NOT NULL,
|
|
side TEXT,
|
|
qty REAL,
|
|
price REAL,
|
|
timestamp TEXT,
|
|
trading_date TEXT,
|
|
agent_id TEXT,
|
|
meta_json TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_trades_ticker_time ON trades(ticker, timestamp DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS signals (
|
|
id TEXT PRIMARY KEY,
|
|
ticker TEXT NOT NULL,
|
|
agent_id TEXT,
|
|
agent_name TEXT,
|
|
role TEXT,
|
|
signal TEXT,
|
|
confidence REAL,
|
|
reasoning_json TEXT,
|
|
reasons_json TEXT,
|
|
risks_json TEXT,
|
|
invalidation TEXT,
|
|
next_action TEXT,
|
|
intrinsic_value REAL,
|
|
fair_value_range_json TEXT,
|
|
value_gap_pct REAL,
|
|
valuation_methods_json TEXT,
|
|
real_return REAL,
|
|
is_correct TEXT,
|
|
trade_date TEXT,
|
|
created_at TEXT,
|
|
meta_json TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_signals_ticker_date ON signals(ticker, trade_date DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_signals_agent_date ON signals(agent_id, trade_date DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS price_points (
|
|
id TEXT PRIMARY KEY,
|
|
ticker TEXT NOT NULL,
|
|
timestamp TEXT NOT NULL,
|
|
price REAL NOT NULL,
|
|
open_price REAL,
|
|
ret REAL,
|
|
source TEXT,
|
|
meta_json TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_price_points_ticker_time ON price_points(ticker, timestamp DESC);
|
|
"""
|
|
|
|
|
|
def _json_dumps(value: Any) -> str:
|
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
|
|
|
|
|
|
def _hash_key(*parts: Any) -> str:
|
|
raw = "::".join("" if part is None else str(part) for part in parts)
|
|
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
class RuntimeDb:
|
|
"""Small SQLite helper for append-mostly runtime data."""
|
|
|
|
def __init__(self, db_path: Path):
|
|
self.db_path = Path(db_path)
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._init_db()
|
|
|
|
def _connect(self) -> sqlite3.Connection:
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
return conn
|
|
|
|
def _init_db(self):
|
|
with self._connect() as conn:
|
|
conn.executescript(SCHEMA)
|
|
|
|
def insert_event(self, event: Dict[str, Any]):
|
|
payload = dict(event or {})
|
|
if not payload:
|
|
return
|
|
|
|
event_id = payload.get("id") or _hash_key(
|
|
payload.get("type"),
|
|
payload.get("timestamp"),
|
|
payload.get("agentId") or payload.get("agent_id"),
|
|
payload.get("content"),
|
|
payload.get("title"),
|
|
)
|
|
ticker = payload.get("ticker")
|
|
if not ticker and isinstance(payload.get("tickers"), list) and len(payload["tickers"]) == 1:
|
|
ticker = payload["tickers"][0]
|
|
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO events
|
|
(id, event_type, timestamp, agent_id, agent_name, ticker, title, content, payload_json, run_date)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
event_id,
|
|
payload.get("type"),
|
|
payload.get("timestamp"),
|
|
payload.get("agentId") or payload.get("agent_id"),
|
|
payload.get("agentName") or payload.get("agent_name"),
|
|
ticker,
|
|
payload.get("title"),
|
|
payload.get("content"),
|
|
_json_dumps(payload),
|
|
payload.get("date") or payload.get("trading_date") or payload.get("run_date"),
|
|
),
|
|
)
|
|
|
|
def get_recent_feed_events(
|
|
self,
|
|
*,
|
|
limit: int = 200,
|
|
event_types: Optional[Iterable[str]] = None,
|
|
) -> list[Dict[str, Any]]:
|
|
"""Return recent persisted feed events in newest-first order."""
|
|
event_types = tuple(event_types or ())
|
|
sql = """
|
|
SELECT payload_json
|
|
FROM events
|
|
"""
|
|
params: list[Any] = []
|
|
if event_types:
|
|
placeholders = ",".join("?" for _ in event_types)
|
|
sql += f" WHERE event_type IN ({placeholders})"
|
|
params.extend(event_types)
|
|
sql += " ORDER BY timestamp DESC LIMIT ?"
|
|
params.append(max(1, int(limit)))
|
|
|
|
with self._connect() as conn:
|
|
rows = conn.execute(sql, params).fetchall()
|
|
|
|
items: list[Dict[str, Any]] = []
|
|
for row in rows:
|
|
try:
|
|
payload = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
|
except json.JSONDecodeError:
|
|
payload = {}
|
|
if payload:
|
|
items.append(payload)
|
|
return items
|
|
|
|
def get_last_day_feed_events(
|
|
self,
|
|
*,
|
|
current_date: Optional[str] = None,
|
|
limit: int = 200,
|
|
event_types: Optional[Iterable[str]] = None,
|
|
) -> list[Dict[str, Any]]:
|
|
"""Return latest trading day events in newest-first order for replay."""
|
|
event_types = tuple(event_types or ())
|
|
target_date = str(current_date or "").strip() or None
|
|
|
|
with self._connect() as conn:
|
|
if not target_date:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT run_date
|
|
FROM events
|
|
WHERE run_date IS NOT NULL AND TRIM(run_date) != ''
|
|
ORDER BY run_date DESC
|
|
LIMIT 1
|
|
"""
|
|
).fetchone()
|
|
target_date = row["run_date"] if row else None
|
|
|
|
if not target_date:
|
|
return []
|
|
|
|
sql = """
|
|
SELECT payload_json
|
|
FROM events
|
|
WHERE run_date = ?
|
|
"""
|
|
params: list[Any] = [target_date]
|
|
if event_types:
|
|
placeholders = ",".join("?" for _ in event_types)
|
|
sql += f" AND event_type IN ({placeholders})"
|
|
params.extend(event_types)
|
|
sql += " ORDER BY timestamp DESC LIMIT ?"
|
|
params.append(max(1, int(limit)))
|
|
rows = conn.execute(sql, params).fetchall()
|
|
|
|
items: list[Dict[str, Any]] = []
|
|
for row in rows:
|
|
try:
|
|
payload = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
|
except json.JSONDecodeError:
|
|
payload = {}
|
|
if payload:
|
|
items.append(payload)
|
|
return items
|
|
|
|
def upsert_trade(self, trade: Dict[str, Any]):
|
|
payload = dict(trade or {})
|
|
if not payload:
|
|
return
|
|
|
|
trade_id = payload.get("id") or _hash_key(
|
|
payload.get("ticker"),
|
|
payload.get("timestamp") or payload.get("ts"),
|
|
payload.get("side"),
|
|
payload.get("qty"),
|
|
payload.get("price"),
|
|
)
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO trades
|
|
(id, ticker, side, qty, price, timestamp, trading_date, agent_id, meta_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
trade_id,
|
|
payload.get("ticker"),
|
|
payload.get("side"),
|
|
payload.get("qty"),
|
|
payload.get("price"),
|
|
payload.get("timestamp") or payload.get("ts"),
|
|
payload.get("trading_date"),
|
|
payload.get("agentId") or payload.get("agent_id"),
|
|
_json_dumps(payload),
|
|
),
|
|
)
|
|
|
|
def upsert_signal(self, signal: Dict[str, Any], *, agent_id: str, agent_name: str, role: str):
|
|
payload = dict(signal or {})
|
|
ticker = payload.get("ticker")
|
|
if not ticker:
|
|
return
|
|
|
|
signal_id = _hash_key(
|
|
agent_id,
|
|
ticker,
|
|
payload.get("date"),
|
|
payload.get("signal"),
|
|
payload.get("confidence"),
|
|
)
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO signals
|
|
(id, ticker, agent_id, agent_name, role, signal, confidence, reasoning_json,
|
|
reasons_json, risks_json, invalidation, next_action, intrinsic_value,
|
|
fair_value_range_json, value_gap_pct, valuation_methods_json,
|
|
real_return, is_correct, trade_date, created_at, meta_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
signal_id,
|
|
ticker,
|
|
agent_id,
|
|
agent_name,
|
|
role,
|
|
payload.get("signal"),
|
|
payload.get("confidence"),
|
|
_json_dumps(payload.get("reasoning")),
|
|
_json_dumps(payload.get("reasons")),
|
|
_json_dumps(payload.get("risks")),
|
|
payload.get("invalidation"),
|
|
payload.get("next_action"),
|
|
payload.get("intrinsic_value"),
|
|
_json_dumps(payload.get("fair_value_range")),
|
|
payload.get("value_gap_pct"),
|
|
_json_dumps(payload.get("valuation_methods")),
|
|
payload.get("real_return"),
|
|
None if payload.get("is_correct") is None else str(payload.get("is_correct")),
|
|
payload.get("date"),
|
|
payload.get("created_at") or payload.get("date"),
|
|
_json_dumps(payload),
|
|
),
|
|
)
|
|
|
|
def replace_signals_for_leaderboard(self, leaderboard: Iterable[Dict[str, Any]]):
|
|
with self._connect() as conn:
|
|
conn.execute("DELETE FROM signals")
|
|
for agent in leaderboard:
|
|
agent_id = agent.get("agentId")
|
|
agent_name = agent.get("name")
|
|
role = agent.get("role")
|
|
for signal in agent.get("signals", []) or []:
|
|
payload = dict(signal or {})
|
|
ticker = payload.get("ticker")
|
|
if not ticker:
|
|
continue
|
|
signal_id = _hash_key(
|
|
agent_id,
|
|
ticker,
|
|
payload.get("date"),
|
|
payload.get("signal"),
|
|
payload.get("confidence"),
|
|
)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO signals
|
|
(id, ticker, agent_id, agent_name, role, signal, confidence, reasoning_json,
|
|
reasons_json, risks_json, invalidation, next_action, intrinsic_value,
|
|
fair_value_range_json, value_gap_pct, valuation_methods_json,
|
|
real_return, is_correct, trade_date, created_at, meta_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
signal_id,
|
|
ticker,
|
|
agent_id,
|
|
agent_name,
|
|
role,
|
|
payload.get("signal"),
|
|
payload.get("confidence"),
|
|
_json_dumps(payload.get("reasoning")),
|
|
_json_dumps(payload.get("reasons")),
|
|
_json_dumps(payload.get("risks")),
|
|
payload.get("invalidation"),
|
|
payload.get("next_action"),
|
|
payload.get("intrinsic_value"),
|
|
_json_dumps(payload.get("fair_value_range")),
|
|
payload.get("value_gap_pct"),
|
|
_json_dumps(payload.get("valuation_methods")),
|
|
payload.get("real_return"),
|
|
None if payload.get("is_correct") is None else str(payload.get("is_correct")),
|
|
payload.get("date"),
|
|
payload.get("created_at") or payload.get("date"),
|
|
_json_dumps(payload),
|
|
),
|
|
)
|
|
|
|
def insert_price_point(
|
|
self,
|
|
*,
|
|
ticker: str,
|
|
timestamp: str,
|
|
price: float,
|
|
open_price: Optional[float] = None,
|
|
ret: Optional[float] = None,
|
|
source: Optional[str] = None,
|
|
meta: Optional[Dict[str, Any]] = None,
|
|
):
|
|
price_id = _hash_key(ticker, timestamp, price, open_price, ret)
|
|
with self._connect() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO price_points
|
|
(id, ticker, timestamp, price, open_price, ret, source, meta_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
price_id,
|
|
ticker,
|
|
timestamp,
|
|
price,
|
|
open_price,
|
|
ret,
|
|
source,
|
|
_json_dumps(meta or {}),
|
|
),
|
|
)
|
|
|
|
def get_stock_explain_snapshot(
|
|
self,
|
|
ticker: str,
|
|
*,
|
|
limit_events: int = 24,
|
|
limit_trades: int = 12,
|
|
limit_signals: int = 12,
|
|
) -> Dict[str, list[Dict[str, Any]]]:
|
|
"""Fetch query-oriented history for a single ticker."""
|
|
symbol = str(ticker or "").strip().upper()
|
|
if not symbol:
|
|
return {"events": [], "trades": [], "signals": []}
|
|
|
|
with self._connect() as conn:
|
|
trade_rows = conn.execute(
|
|
"""
|
|
SELECT * FROM trades
|
|
WHERE ticker = ?
|
|
ORDER BY timestamp DESC
|
|
LIMIT ?
|
|
""",
|
|
(symbol, limit_trades),
|
|
).fetchall()
|
|
signal_rows = conn.execute(
|
|
"""
|
|
SELECT * FROM signals
|
|
WHERE ticker = ?
|
|
ORDER BY trade_date DESC, created_at DESC
|
|
LIMIT ?
|
|
""",
|
|
(symbol, limit_signals),
|
|
).fetchall()
|
|
event_rows = conn.execute(
|
|
"""
|
|
SELECT * FROM events
|
|
WHERE payload_json LIKE ? OR content LIKE ? OR title LIKE ? OR ticker = ?
|
|
ORDER BY timestamp DESC
|
|
LIMIT ?
|
|
""",
|
|
(f"%{symbol}%", f"%{symbol}%", f"%{symbol}%", symbol, limit_events * 3),
|
|
).fetchall()
|
|
|
|
normalized_events = []
|
|
seen_event_ids: set[str] = set()
|
|
for row in event_rows:
|
|
payload = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
|
content = str(row["content"] or payload.get("content") or "")
|
|
title = str(row["title"] or payload.get("title") or "")
|
|
if symbol not in f"{title} {content}".upper() and str(row["ticker"] or "").upper() != symbol:
|
|
continue
|
|
event_id = row["id"]
|
|
if event_id in seen_event_ids:
|
|
continue
|
|
seen_event_ids.add(event_id)
|
|
normalized_events.append(
|
|
{
|
|
"id": event_id,
|
|
"type": "mention",
|
|
"timestamp": row["timestamp"],
|
|
"title": title or f"{row['agent_name'] or '未知角色'}提及 {symbol}",
|
|
"meta": payload.get("conferenceTitle")
|
|
or payload.get("feedType")
|
|
or row["event_type"],
|
|
"body": content,
|
|
"tone": "neutral",
|
|
"agent": row["agent_name"] or payload.get("agentName") or payload.get("agent"),
|
|
},
|
|
)
|
|
if len(normalized_events) >= limit_events:
|
|
break
|
|
|
|
normalized_trades = [
|
|
{
|
|
"id": row["id"],
|
|
"type": "trade",
|
|
"timestamp": row["timestamp"],
|
|
"title": f"{row['side']} {int(row['qty'] or 0)} 股",
|
|
"meta": "交易执行",
|
|
"body": f"成交价 ${float(row['price'] or 0):.2f}",
|
|
"tone": "positive" if row["side"] == "LONG" else "negative" if row["side"] == "SHORT" else "neutral",
|
|
}
|
|
for row in trade_rows
|
|
]
|
|
|
|
normalized_signals = [
|
|
{
|
|
"id": row["id"],
|
|
"type": "signal",
|
|
"timestamp": f"{row['trade_date']}T08:00:00" if row["trade_date"] else row["created_at"],
|
|
"title": f"{row['agent_name']} 给出{row['signal'] or '中性'}信号",
|
|
"meta": row["role"],
|
|
"body": (
|
|
f"后验收益 {float(row['real_return']) * 100:+.2f}%"
|
|
if row["real_return"] is not None
|
|
else "该信号暂未完成后验评估"
|
|
),
|
|
"tone": "positive" if str(row["signal"] or "").lower() in {"bullish", "buy", "long"} else "negative" if str(row["signal"] or "").lower() in {"bearish", "sell", "short"} else "neutral",
|
|
# Extended signal fields
|
|
"signal": row["signal"],
|
|
"confidence": row["confidence"],
|
|
"reasoning": json.loads(row["reasoning_json"]) if row["reasoning_json"] else None,
|
|
"reasons": json.loads(row["reasons_json"]) if row["reasons_json"] else None,
|
|
"risks": json.loads(row["risks_json"]) if row["risks_json"] else None,
|
|
"invalidation": row["invalidation"],
|
|
"next_action": row["next_action"],
|
|
"intrinsic_value": row["intrinsic_value"],
|
|
"fair_value_range": json.loads(row["fair_value_range_json"]) if row["fair_value_range_json"] else None,
|
|
"value_gap_pct": row["value_gap_pct"],
|
|
"valuation_methods": json.loads(row["valuation_methods_json"]) if row["valuation_methods_json"] else None,
|
|
}
|
|
for row in signal_rows
|
|
]
|
|
|
|
return {
|
|
"events": normalized_events,
|
|
"trades": normalized_trades,
|
|
"signals": normalized_signals,
|
|
}
|