feat: chatbot
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user