trading-assistant / database.py
ruudshailesh's picture
Upload database.py
a271f2f verified
Raw
History Blame Contribute Delete
22.7 kB
"""
database.py β€” Supabase Python client version.
Uses SUPABASE_URL + SUPABASE_KEY (secret key) from environment variables.
No direct database connection needed β€” all operations go through Supabase REST API.
All public function signatures are IDENTICAL to the SQLite version.
No other module needs to change.
Required environment variables:
SUPABASE_URL β€” https://xxxxxxxxxxxx.supabase.co
SUPABASE_KEY β€” sb_secret_... (your secret key, NOT publishable)
Set locally: .env file (never commit)
Set on Railway: Variables tab in your service
"""
import logging
import os
import traceback
from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from dotenv import load_dotenv
from supabase import create_client, Client
import config
# ── Load .env locally (no-op on Railway) ─────────────────────────────────────
load_dotenv()
# ── Logging (stdout-safe for HF Spaces β€” no file handlers) ───────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("database")
app_logger = logging.getLogger("app")
app_logger.setLevel(logging.INFO)
# File handler β€” optional, skip silently if filesystem is read-only
try:
_fh = logging.FileHandler(config.APP_LOG)
_fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
app_logger.addHandler(_fh)
except Exception:
pass
# ── Supabase client (created once, reused) ────────────────────────────────────
_client: Optional[Client] = None
def _get_client() -> Client:
"""
Returns the Supabase client. Creates it on first call.
Raises EnvironmentError if credentials are missing.
"""
global _client
if _client is not None:
return _client
url = os.environ.get("SUPABASE_URL", "").strip()
key = os.environ.get("SUPABASE_KEY", "").strip()
if not url or not key:
raise EnvironmentError(
"SUPABASE_URL and SUPABASE_KEY must be set.\n"
"Local: add them to your .env file.\n"
"Railway: add them in the Variables tab."
)
_client = create_client(url, key)
return _client
def _sb() -> Client:
"""Shorthand for _get_client()."""
return _get_client()
# ── Schema creation via SQL ───────────────────────────────────────────────────
# Supabase Python client doesn't run raw DDL directly.
# Tables must be created via Supabase SQL Editor (one-time setup).
# init_db() checks if tables exist and prints instructions if not.
CREATE_TABLES_SQL = """
-- Run this ONCE in Supabase SQL Editor (Database β†’ SQL Editor β†’ New query)
CREATE TABLE IF NOT EXISTS trades (
id BIGSERIAL PRIMARY KEY,
date TEXT NOT NULL,
ticker TEXT NOT NULL,
strategy TEXT NOT NULL,
score REAL,
entry REAL NOT NULL,
stop REAL NOT NULL,
target REAL NOT NULL,
position_size INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'Pending',
outcome_pct REAL,
momentum REAL,
volume_spike REAL,
volatility REAL,
atr REAL,
sector TEXT,
exit_price REAL,
exit_date TEXT,
gap_exit INTEGER NOT NULL DEFAULT 0,
explanation TEXT
);
CREATE TABLE IF NOT EXISTS metrics (
id BIGSERIAL PRIMARY KEY,
date TEXT NOT NULL,
strategy TEXT NOT NULL,
win_rate REAL,
avg_return REAL,
max_drawdown REAL,
trades_sampled INTEGER,
source TEXT DEFAULT 'live'
);
CREATE TABLE IF NOT EXISTS weights (
strategy TEXT PRIMARY KEY,
momentum_w REAL NOT NULL,
volume_w REAL NOT NULL,
volatility_w REAL NOT NULL,
last_updated TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS weights_history (
id BIGSERIAL PRIMARY KEY,
timestamp TEXT NOT NULL,
strategy TEXT NOT NULL,
momentum_w REAL NOT NULL,
volume_w REAL NOT NULL,
volatility_w REAL NOT NULL,
trigger_event TEXT NOT NULL,
trades_count INTEGER
);
CREATE TABLE IF NOT EXISTS watchlist (
ticker TEXT PRIMARY KEY,
date_added TEXT NOT NULL,
active_status INTEGER NOT NULL DEFAULT 1,
notes TEXT
);
CREATE TABLE IF NOT EXISTS spy_context (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
date TEXT,
spy_price REAL,
spy_20dma REAL,
spy_vol_20d REAL,
spy_vol_80pct REAL,
bearish_flag INTEGER NOT NULL DEFAULT 0,
high_vol_flag INTEGER NOT NULL DEFAULT 0
);
-- Seed spy_context row
INSERT INTO spy_context (id, bearish_flag, high_vol_flag)
VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING;
-- Disable Row Level Security (since only your server accesses this)
ALTER TABLE trades DISABLE ROW LEVEL SECURITY;
ALTER TABLE metrics DISABLE ROW LEVEL SECURITY;
ALTER TABLE weights DISABLE ROW LEVEL SECURITY;
ALTER TABLE weights_history DISABLE ROW LEVEL SECURITY;
ALTER TABLE watchlist DISABLE ROW LEVEL SECURITY;
ALTER TABLE spy_context DISABLE ROW LEVEL SECURITY;
"""
# ── Init ──────────────────────────────────────────────────────────────────────
def init_db() -> bool:
"""
Checks DB connectivity and seeds default weights.
Tables must already exist (created via SQL Editor β€” see CREATE_TABLES_SQL above).
Prints helpful instructions if tables are missing.
"""
try:
sb = _sb()
# Check connectivity by querying weights table
try:
sb.table("weights").select("strategy").limit(1).execute()
except Exception:
print("\n" + "="*60)
print("FIRST TIME SETUP REQUIRED")
print("="*60)
print("Tables not found in Supabase.")
print("Please run the SQL below in:")
print("Supabase β†’ SQL Editor β†’ New query β†’ paste β†’ Run\n")
print(CREATE_TABLES_SQL)
print("="*60 + "\n")
return False
_seed_default_weights()
app_logger.info("Supabase DB ready")
return True
except Exception as e:
logger.error("init_db failed: %s\n%s", e, traceback.format_exc())
return False
def _seed_default_weights():
"""Insert BASE_WEIGHTS for each strategy if not already present."""
sb = _sb()
now = datetime.now(timezone.utc).isoformat()
for strategy, w in config.BASE_WEIGHTS.items():
existing = (
sb.table("weights")
.select("strategy")
.eq("strategy", strategy)
.execute()
)
if not existing.data:
sb.table("weights").insert({
"strategy": strategy,
"momentum_w": w["momentum"],
"volume_w": w["volume"],
"volatility_w": w["volatility"],
"last_updated": now,
}).execute()
sb.table("weights_history").insert({
"timestamp": now,
"strategy": strategy,
"momentum_w": w["momentum"],
"volume_w": w["volume"],
"volatility_w": w["volatility"],
"trigger_event": "init",
"trades_count": 0,
}).execute()
# ── Trades ────────────────────────────────────────────────────────────────────
def insert_trade(trade: Dict[str, Any]) -> Optional[int]:
"""Insert new trade. Returns row id or None on failure."""
try:
res = _sb().table("trades").insert({
"date": trade["date"],
"ticker": trade["ticker"],
"strategy": trade["strategy"],
"score": trade.get("score"),
"entry": trade["entry"],
"stop": trade["stop"],
"target": trade["target"],
"position_size": trade["position_size"],
"status": "Pending",
"momentum": trade.get("momentum"),
"volume_spike": trade.get("volume_spike"),
"volatility": trade.get("volatility"),
"atr": trade.get("atr"),
"sector": trade.get("sector"),
"explanation": trade.get("explanation"),
}).execute()
if res.data:
return res.data[0]["id"]
return None
except Exception as e:
logger.error("insert_trade failed for %s: %s", trade.get("ticker"), e)
return None
def update_trade_status(
trade_id: int,
status: str,
exit_price: Optional[float] = None,
exit_date: Optional[str] = None,
gap_exit: bool = False,
) -> bool:
"""Update trade status and optionally record exit details."""
if status not in config.TRADE_STATUSES:
logger.error("Invalid status: %s", status)
return False
try:
sb = _sb()
# Fetch entry price to compute outcome_pct
row = sb.table("trades").select("entry").eq("id", trade_id).execute()
if not row.data:
logger.error("Trade id=%s not found", trade_id)
return False
entry = row.data[0].get("entry")
outcome_pct = None
if exit_price and entry:
outcome_pct = (exit_price - entry) / entry
sb.table("trades").update({
"status": status,
"exit_price": exit_price,
"exit_date": exit_date or date.today().isoformat(),
"outcome_pct": outcome_pct,
"gap_exit": 1 if gap_exit else 0,
}).eq("id", trade_id).execute()
app_logger.info(
"Trade id=%s β†’ %s exit=%.4f outcome=%s gap=%s",
trade_id, status, exit_price or 0, outcome_pct, gap_exit,
)
return True
except Exception as e:
logger.error("update_trade_status failed id=%s: %s", trade_id, e)
return False
def get_active_trades() -> List[Dict[str, Any]]:
try:
res = (
_sb().table("trades")
.select("*")
.in_("status", ["Pending", "Executed"])
.order("date", desc=True)
.execute()
)
return res.data or []
except Exception as e:
logger.error("get_active_trades failed: %s", e)
return []
def get_all_trades(
status_filter: Optional[str] = None,
strategy_filter: Optional[str] = None,
limit: int = 500,
) -> List[Dict[str, Any]]:
try:
q = _sb().table("trades").select("*")
if status_filter:
q = q.eq("status", status_filter)
if strategy_filter:
q = q.eq("strategy", strategy_filter)
res = q.order("date", desc=True).limit(limit).execute()
return res.data or []
except Exception as e:
logger.error("get_all_trades failed: %s", e)
return []
def get_closed_trades(strategy: Optional[str] = None, limit: int = 200) -> List[Dict[str, Any]]:
try:
q = (
_sb().table("trades")
.select("*")
.in_("status", ["Success", "Failed", "Expired"])
)
if strategy:
q = q.eq("strategy", strategy)
res = q.order("exit_date", desc=True).limit(limit).execute()
return res.data or []
except Exception as e:
logger.error("get_closed_trades failed: %s", e)
return []
def check_wash_sale(ticker: str) -> bool:
try:
cutoff = (date.today() - timedelta(days=config.WASH_SALE_DAYS)).isoformat()
res = (
_sb().table("trades")
.select("id, outcome_pct, status")
.eq("ticker", ticker.upper())
.in_("status", ["Failed", "Expired"])
.gte("exit_date", cutoff)
.execute()
)
if not res.data:
return False
# Check for losses or unconfirmed expired trades
for row in res.data:
op = row.get("outcome_pct")
if op is not None and op < 0:
return True
if row.get("status") == "Expired" and op is None:
return True
return False
except Exception as e:
logger.error("check_wash_sale failed for %s: %s", ticker, e)
return False
def get_tickers_in_active_trades() -> List[str]:
try:
res = (
_sb().table("trades")
.select("ticker")
.in_("status", ["Pending", "Executed"])
.execute()
)
return list({r["ticker"] for r in (res.data or [])})
except Exception as e:
logger.error("get_tickers_in_active_trades failed: %s", e)
return []
def expire_old_pending_trades() -> int:
try:
cutoff = (
date.today() - timedelta(days=config.SIGNAL_EXPIRY_DAYS * 1.5)
).isoformat()
res = (
_sb().table("trades")
.update({
"status": "Expired",
"exit_date": date.today().isoformat(),
"outcome_pct": -1.0,
})
.eq("status", "Pending")
.lte("date", cutoff)
.execute()
)
count = len(res.data) if res.data else 0
if count:
app_logger.info("Auto-expired %d old Pending trade(s)", count)
return count
except Exception as e:
logger.error("expire_old_pending_trades failed: %s", e)
return 0
# ── Weights ───────────────────────────────────────────────────────────────────
def get_weights(strategy: str) -> Dict[str, float]:
try:
res = (
_sb().table("weights")
.select("momentum_w, volume_w, volatility_w")
.eq("strategy", strategy)
.execute()
)
if res.data:
row = res.data[0]
return {
"momentum": row["momentum_w"],
"volume": row["volume_w"],
"volatility": row["volatility_w"],
}
except Exception as e:
logger.error("get_weights failed for %s: %s", strategy, e)
return config.BASE_WEIGHTS[strategy].copy()
def update_weights(
strategy: str,
new_weights: Dict[str, float],
trigger_event: str = "learning_update",
trades_count: int = 0,
) -> bool:
now = datetime.now(timezone.utc).isoformat()
try:
sb = _sb()
sb.table("weights").update({
"momentum_w": new_weights["momentum"],
"volume_w": new_weights["volume"],
"volatility_w": new_weights["volatility"],
"last_updated": now,
}).eq("strategy", strategy).execute()
sb.table("weights_history").insert({
"timestamp": now,
"strategy": strategy,
"momentum_w": new_weights["momentum"],
"volume_w": new_weights["volume"],
"volatility_w": new_weights["volatility"],
"trigger_event": trigger_event,
"trades_count": trades_count,
}).execute()
return True
except Exception as e:
logger.error("update_weights failed for %s: %s", strategy, e)
return False
def reset_weights_to_base() -> bool:
return all(
update_weights(s, w, trigger_event="manual_reset", trades_count=0)
for s, w in config.BASE_WEIGHTS.items()
)
def get_weights_history(strategy: Optional[str] = None, limit: int = 100) -> List[Dict[str, Any]]:
try:
q = _sb().table("weights_history").select("*")
if strategy:
q = q.eq("strategy", strategy)
res = q.order("timestamp", desc=True).limit(limit).execute()
return res.data or []
except Exception as e:
logger.error("get_weights_history failed: %s", e)
return []
# ── SPY context ───────────────────────────────────────────────────────────────
def save_spy_context(ctx: Dict[str, Any]) -> bool:
try:
_sb().table("spy_context").upsert({
"id": 1,
"date": ctx.get("date"),
"spy_price": ctx.get("spy_price"),
"spy_20dma": ctx.get("spy_20dma"),
"spy_vol_20d": ctx.get("spy_vol_20d"),
"spy_vol_80pct": ctx.get("spy_vol_80pct"),
"bearish_flag": ctx.get("bearish_flag", 0),
"high_vol_flag": ctx.get("high_vol_flag", 0),
}).execute()
return True
except Exception as e:
logger.error("save_spy_context failed: %s", e)
return False
def get_spy_context() -> Dict[str, Any]:
try:
res = _sb().table("spy_context").select("*").eq("id", 1).execute()
if res.data:
return res.data[0]
except Exception as e:
logger.error("get_spy_context failed: %s", e)
return {"bearish_flag": 0, "high_vol_flag": 0, "date": None}
# ── Metrics ───────────────────────────────────────────────────────────────────
def save_metrics(m: Dict[str, Any]) -> bool:
try:
_sb().table("metrics").insert({
"date": m["date"],
"strategy": m["strategy"],
"win_rate": m.get("win_rate"),
"avg_return": m.get("avg_return"),
"max_drawdown": m.get("max_drawdown"),
"trades_sampled": m.get("trades_sampled"),
"source": m.get("source", "live"),
}).execute()
return True
except Exception as e:
logger.error("save_metrics failed: %s", e)
return False
def get_latest_metrics(strategy: str, source: str = "live") -> Dict[str, Any]:
try:
res = (
_sb().table("metrics")
.select("*")
.eq("strategy", strategy)
.eq("source", source)
.order("date", desc=True)
.limit(1)
.execute()
)
return res.data[0] if res.data else {}
except Exception as e:
logger.error("get_latest_metrics failed: %s", e)
return {}
# ── Watchlist ─────────────────────────────────────────────────────────────────
def add_to_watchlist(ticker: str, notes: str = "") -> bool:
try:
_sb().table("watchlist").upsert({
"ticker": ticker.upper(),
"date_added": date.today().isoformat(),
"active_status": 1,
"notes": notes,
}).execute()
return True
except Exception as e:
logger.error("add_to_watchlist failed for %s: %s", ticker, e)
return False
def remove_from_watchlist(ticker: str) -> bool:
try:
_sb().table("watchlist").update(
{"active_status": 0}
).eq("ticker", ticker.upper()).execute()
return True
except Exception as e:
logger.error("remove_from_watchlist failed for %s: %s", ticker, e)
return False
def get_watchlist(active_only: bool = True) -> List[Dict[str, Any]]:
try:
q = _sb().table("watchlist").select("*")
if active_only:
q = q.eq("active_status", 1)
res = q.order("date_added", desc=True).execute()
return res.data or []
except Exception as e:
logger.error("get_watchlist failed: %s", e)
return []
# ── Health check ──────────────────────────────────────────────────────────────
def db_health_check() -> Dict[str, Any]:
result = {
"ok": False, "path": "Supabase",
"trade_count": 0, "active_trades": 0, "error": None,
}
try:
r1 = _sb().table("trades").select("id", count="exact").execute()
r2 = (
_sb().table("trades")
.select("id", count="exact")
.in_("status", ["Pending", "Executed"])
.execute()
)
result.update({
"ok": True,
"trade_count": r1.count or 0,
"active_trades": r2.count or 0,
})
except Exception as e:
result["error"] = str(e)
logger.error("db_health_check failed: %s", e)
return result
# ── Self-test ─────────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("database.py (Supabase client) β€” self-test")
print("Needs SUPABASE_URL + SUPABASE_KEY in .env")
print("=" * 50)
print(f"init_db(): {'βœ…' if init_db() else '❌'}")
tid = insert_trade({
"date": date.today().isoformat(), "ticker": "TEST",
"strategy": "filter_a", "score": 72.5,
"entry": 100.0, "stop": 98.0, "target": 104.0,
"position_size": 50, "momentum": 0.03,
"volume_spike": 1.8, "volatility": 0.012,
"atr": 2.0, "sector": "Technology",
"explanation": "Supabase client self-test",
})
print(f"insert_trade(): {'βœ…' if tid else '❌'} id={tid}")
print(f"update_status(): {'βœ…' if update_trade_status(tid, 'Success', 104.5) else '❌'}")
print(f"get_weights(): {get_weights('filter_a')}")
print(f"spy_context(): {get_spy_context()}")
print(f"health_check(): {db_health_check()}")
# Cleanup
_sb().table("trades").delete().eq("ticker", "TEST").execute()
print("Cleaned up. Self-test complete.")