feat: chatbot

This commit is contained in:
OpenSquared
2026-07-15 08:47:16 +02:00
parent da536e2638
commit ce9c0b53a9
11 changed files with 837 additions and 64 deletions

View File

@@ -143,6 +143,32 @@ def init_db():
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)""",
# AI Chat widget — trade ideas proposed by the AI via function-calling, pending
# user confirmation before they ever touch the real portfolio table
"""CREATE TABLE IF NOT EXISTS ai_trade_proposals (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
status TEXT DEFAULT 'pending',
title TEXT NOT NULL,
underlying TEXT NOT NULL,
strategy TEXT NOT NULL,
asset_class TEXT DEFAULT 'indices',
expiry_days INTEGER DEFAULT 90,
capital_invested REAL NOT NULL,
legs_json TEXT NOT NULL,
geo_trigger TEXT DEFAULT '',
rationale TEXT DEFAULT '',
portfolio_id TEXT,
resolved_at TEXT
)""",
# Wavelets — richer per-cycle state (slope/energy/ridge), one row per (ticker, band)
# every cycle regardless of whether a signal fired (was: only on firing)
"ALTER TABLE wavelet_watchlist_signals ADD COLUMN slope REAL",
"ALTER TABLE wavelet_watchlist_signals ADD COLUMN value REAL",
"ALTER TABLE wavelet_watchlist_signals ADD COLUMN energy REAL",
"ALTER TABLE wavelet_watchlist_signals ADD COLUMN ridge_period_days REAL",
"ALTER TABLE wavelet_watchlist_signals ADD COLUMN params_json TEXT",
]:
try:
c.execute(_sql)
@@ -153,6 +179,10 @@ def init_db():
c.execute("CREATE INDEX IF NOT EXISTS idx_wws_ticker_date ON wavelet_watchlist_signals(ticker, computed_at DESC)")
except Exception:
pass
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_atp_session_status ON ai_trade_proposals(session_id, status)")
except Exception:
pass
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_chat_session_date ON ai_chat_messages(session_id, created_at)")
@@ -3192,25 +3222,51 @@ def save_wavelet_signals(run_id: str, signals: List[Dict]) -> None:
for s in signals:
conn.execute(
"INSERT INTO wavelet_watchlist_signals "
"(run_id, ticker, band_label, period_low_days, period_high_days, signal_kind, direction, price_at_signal) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"(run_id, ticker, band_label, period_low_days, period_high_days, signal_kind, direction, price_at_signal, "
"slope, value, energy, ridge_period_days, params_json) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(run_id, s.get("ticker"), s.get("band_label"), s.get("period_low_days"), s.get("period_high_days"),
s.get("signal_kind"), s.get("direction"), s.get("price_at_signal")),
s.get("signal_kind"), s.get("direction"), s.get("price_at_signal"),
s.get("slope"), s.get("value"), s.get("energy"), s.get("ridge_period_days"), s.get("params_json")),
)
conn.commit()
conn.close()
def _latest_wavelet_run_id() -> Optional[str]:
conn = get_conn()
row = conn.execute(
"SELECT run_id FROM wavelet_watchlist_signals ORDER BY computed_at DESC LIMIT 1"
).fetchone()
conn.close()
return row["run_id"] if row else None
def get_latest_wavelet_signals() -> List[Dict]:
"""Most recent signal per ticker (one row per ticker, its latest computed_at)."""
"""Fired signals only (signal_kind IS NOT NULL) from the most recent cycle
scan — feeds the Dashboard 'Wavelets Signal' card, unchanged behavior."""
run_id = _latest_wavelet_run_id()
if not run_id:
return []
conn = get_conn()
rows = conn.execute(
"""SELECT w.* FROM wavelet_watchlist_signals w
INNER JOIN (
SELECT ticker, MAX(computed_at) AS max_computed_at
FROM wavelet_watchlist_signals GROUP BY ticker
) latest ON w.ticker = latest.ticker AND w.computed_at = latest.max_computed_at
ORDER BY w.computed_at DESC"""
"SELECT * FROM wavelet_watchlist_signals WHERE run_id=? AND signal_kind IS NOT NULL ORDER BY computed_at DESC",
(run_id,),
).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_latest_wavelet_state() -> List[Dict]:
"""Every (ticker, band) row from the most recent cycle scan — signal or
not. Used for the rich AI chat context block (slope/energy/ridge state)."""
run_id = _latest_wavelet_run_id()
if not run_id:
return []
conn = get_conn()
rows = conn.execute(
"SELECT * FROM wavelet_watchlist_signals WHERE run_id=? ORDER BY ticker, band_label",
(run_id,),
).fetchall()
conn.close()
return [dict(r) for r in rows]
@@ -3255,6 +3311,72 @@ def clear_chat_session(session_id: str) -> None:
conn.close()
# ── AI Chat widget — trade proposals (pending confirmation) ────────────────────
def save_ai_trade_proposal(proposal: Dict[str, Any]) -> str:
import uuid
proposal_id = uuid.uuid4().hex
conn = get_conn()
conn.execute(
"""INSERT INTO ai_trade_proposals (
id, session_id, status, title, underlying, strategy, asset_class,
expiry_days, capital_invested, legs_json, geo_trigger, rationale
) VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
proposal_id, proposal["session_id"], proposal["title"], proposal["underlying"],
proposal["strategy"], proposal.get("asset_class", "indices"),
proposal.get("expiry_days", 90), proposal["capital_invested"],
json.dumps(proposal.get("legs", [])), proposal.get("geo_trigger", ""),
proposal.get("rationale", ""),
),
)
conn.commit()
conn.close()
return proposal_id
def get_ai_trade_proposal(proposal_id: str) -> Optional[Dict[str, Any]]:
conn = get_conn()
row = conn.execute("SELECT * FROM ai_trade_proposals WHERE id=?", (proposal_id,)).fetchone()
conn.close()
if not row:
return None
d = dict(row)
try:
d["legs"] = json.loads(d.pop("legs_json") or "[]")
except Exception:
d["legs"] = []
return d
def get_ai_trade_proposals(status: str = "pending") -> List[Dict[str, Any]]:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM ai_trade_proposals WHERE status=? ORDER BY created_at DESC",
(status,),
).fetchall()
conn.close()
out = []
for r in rows:
d = dict(r)
try:
d["legs"] = json.loads(d.pop("legs_json") or "[]")
except Exception:
d["legs"] = []
out.append(d)
return out
def resolve_ai_trade_proposal(proposal_id: str, status: str, portfolio_id: Optional[str] = None) -> None:
conn = get_conn()
conn.execute(
"UPDATE ai_trade_proposals SET status=?, portfolio_id=?, resolved_at=datetime('now') WHERE id=?",
(status, portfolio_id, proposal_id),
)
conn.commit()
conn.close()
# ── System Logs ───────────────────────────────────────────────────────────────
def log_system_event(
@@ -5498,6 +5620,32 @@ def get_ai_desk_by_type(desk_type: str) -> Optional[Dict[str, Any]]:
return next((d for d in desks if d["type"] == desk_type and d.get("active")), None)
def backfill_wavelet_desk_defaults() -> None:
"""One-time idempotent patch for Technical Desks created before the wavelet
signal catalog existed: wavelet_signals.py treats wavelet_engine/
wavelet_extremum/wavelet_level_threshold as enabled when absent from
config.signals (preserves the always-on pre-desk-config behavior), but the
AI Desks toggle UI shows a missing key as OFF — writing the explicit
defaults here keeps what the UI displays honest about what's computed."""
desk = get_ai_desk_by_type("technical")
if not desk:
return
signals = (desk.get("config") or {}).get("signals") or {}
defaults = {
"wavelet_engine": {"enabled": True, "num_levels": 4, "wavelet": "gmw", "method": "cwt", "lookback_days": 120},
"wavelet_extremum": {"enabled": True},
"wavelet_level_threshold": {"enabled": True, "threshold_k": 2.0},
}
changed = False
for key, val in defaults.items():
if key not in signals:
signals[key] = val
changed = True
if changed:
desk["config"]["signals"] = signals
update_ai_desk_by_id(desk["id"], desk)
def upsert_ai_desk(desk: Dict[str, Any]) -> int:
conn = get_conn()
try: