Ramkumar Shanmugam
feat: add AI-powered Stock Picks tab with Gemini search grounding
5c68d47
Raw
History Blame Contribute Delete
19.5 kB
"""
PortIQ Database Utility Module.
Handles database connections and CRUD operations for daily briefings,
portfolio holdings, and stock signals.
"""
import os
import re
import json
import psycopg2
from psycopg2.extras import RealDictCursor, Json
from dotenv import load_dotenv
load_dotenv()
# Global Connection Pool or connection factory
def get_db_connection():
"""
Creates and returns a connection to the PostgreSQL database.
Caller must close the connection.
"""
db_url = os.getenv("DATABASE_URL")
if db_url:
return psycopg2.connect(db_url)
host = os.getenv("DB_HOST", "localhost")
port = os.getenv("DB_PORT", "5432")
dbname = os.getenv("DB_NAME", "portiq_db")
user = os.getenv("DB_USER", "postgres")
password = os.getenv("DB_PASSWORD", "postgres")
return psycopg2.connect(
host=host,
port=port,
dbname=dbname,
user=user,
password=password
)
def get_owner_user_id():
"""
Returns the UUID of the owner user (owner@portiq.com).
Creates it if it does not exist.
"""
email = "owner@portiq.com"
owner_hash = os.getenv("OWNER_PASSWORD_HASH")
if not owner_hash:
owner_hash = "084f7fa87d1dfcbb3965db0183b544b60a3cc180c59800a6e30018f70094770e" # fallback
conn = get_db_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT id FROM users WHERE email = %s;", (email,))
row = cur.fetchone()
if row:
return row[0]
# If not found, create it
cur.execute(
"""
INSERT INTO users (email, password_hash, role)
VALUES (%s, %s, 'owner')
RETURNING id;
""",
(email, owner_hash)
)
owner_id = cur.fetchone()[0]
conn.commit()
return owner_id
finally:
conn.close()
def clean_nans(val):
"""Recursively replaces float('nan')/NaN values with None (standard null)."""
import math
if isinstance(val, float) and math.isnan(val):
return None
elif isinstance(val, dict):
return {k: clean_nans(v) for k, v in val.items()}
elif isinstance(val, list):
return [clean_nans(v) for v in val]
return val
def save_briefing_to_db(date_str, data_dict, portfolio_df=None):
"""
Saves a daily briefing JSON and its associated stock signals to the database.
Overwrites if a briefing already exists for this date.
"""
owner_id = get_owner_user_id()
conn = get_db_connection()
try:
with conn.cursor() as cur:
# 1. Delete existing briefing for this date to support overwrites
cur.execute(
"DELETE FROM daily_briefings WHERE user_id = %s AND date = %s RETURNING id;",
(owner_id, date_str)
)
cur.fetchone() # clear result if any
# 2. Extract fields from briefing JSON and clean NaN values
mood = clean_nans(data_dict.get("mood", {}))
news_summary = clean_nans(data_dict.get("news_summary", []))
top_picks = clean_nans(data_dict.get("top_picks", []))
avoid_today = clean_nans(data_dict.get("avoid_today", []))
market_summary = clean_nans(data_dict.get("market_summary", []))
news = clean_nans(data_dict.get("news", []))
dividends = clean_nans(data_dict.get("dividends", []))
# 3. Insert new daily briefing record
portfolio = clean_nans(data_dict.get("portfolio", []))
portfolio_snapshot = clean_nans(data_dict.get("_portfolio_snapshot", []))
if not portfolio_snapshot and portfolio_df is not None and not portfolio_df.empty:
portfolio_snapshot = clean_nans(portfolio_df.to_dict(orient="records"))
cur.execute(
"""
INSERT INTO daily_briefings (
user_id, date, mood, news_summary, portfolio, top_picks,
avoid_today, market_summary, news, dividends, portfolio_snapshot
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id;
""",
(
owner_id,
date_str,
Json(mood),
Json(news_summary),
Json(portfolio),
Json(top_picks),
Json(avoid_today),
Json(market_summary),
Json(news),
Json(dividends),
Json(portfolio_snapshot)
)
)
briefing_id = cur.fetchone()[0]
# 4. Process and insert stock signals
# Look at _signal_prices or derive from snapshot/portfolio
signal_prices = data_dict.get("_signal_prices")
if not signal_prices:
signal_prices = _derive_signals(data_dict, portfolio_df)
for symbol, sp in signal_prices.items():
sig = sp.get("signal")
price = sp.get("price_on_day", 0)
qty = sp.get("qty", 1.0)
nifty = sp.get("nifty_on_day")
if sig and price > 0:
cur.execute(
"""
INSERT INTO stock_signals (
briefing_id, symbol, signal, price_on_day, qty, nifty_on_day
) VALUES (%s, %s, %s, %s, %s, %s);
""",
(briefing_id, symbol, sig, price, qty, nifty)
)
# 5. Update portfolio_holdings in the DB if portfolio_df is provided
if portfolio_df is not None and not portfolio_df.empty:
for _, row in portfolio_df.iterrows():
sym = str(row.get("symbol", "")).strip().upper()
qty = float(row.get("qty", 0))
avg_cost = float(row.get("avg_cost", 0))
if sym and qty > 0:
cur.execute(
"""
INSERT INTO portfolio_holdings (user_id, symbol, qty, avg_cost, last_updated)
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, symbol) DO UPDATE
SET qty = EXCLUDED.qty,
avg_cost = EXCLUDED.avg_cost,
last_updated = CURRENT_TIMESTAMP;
""",
(owner_id, sym, qty, avg_cost)
)
conn.commit()
return True
except Exception as e:
conn.rollback()
print(f"[db] Failed to save briefing: {e}")
return False
finally:
conn.close()
def get_briefing_from_db(date_str):
"""
Fetches a daily briefing JSON and its portfolio snapshot (if available)
from the database and returns it as a dict.
"""
owner_id = get_owner_user_id()
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, mood, news_summary, portfolio, top_picks, avoid_today,
market_summary, news, dividends, portfolio_snapshot
FROM daily_briefings
WHERE user_id = %s AND date = %s;
""",
(owner_id, date_str)
)
row = cur.fetchone()
if not row:
return None
# Convert RealDictRow to standard dict
briefing_data = dict(row)
briefing_id = briefing_data.pop("id")
# Map database keys to expected dictionary keys
if "portfolio_snapshot" in briefing_data:
briefing_data["_portfolio_snapshot"] = briefing_data.pop("portfolio_snapshot")
# Fetch the associated signal prices to rebuild _signal_prices
cur.execute(
"""
SELECT symbol, signal, price_on_day, qty, nifty_on_day
FROM stock_signals
WHERE briefing_id = %s;
""",
(briefing_id,)
)
signals_rows = cur.fetchall()
signal_prices = {}
for s in signals_rows:
signal_prices[s["symbol"]] = {
"signal": s["signal"],
"price_on_day": float(s["price_on_day"]),
"qty": float(s["qty"]),
"nifty_on_day": float(s["nifty_on_day"]) if s["nifty_on_day"] is not None else None
}
briefing_data["_signal_prices"] = signal_prices
# Reconstruct _meta
briefing_data["_meta"] = {
"timestamp": date_str + "T00:00:00.000000",
"date": date_str
}
briefing_data["date"] = date_str
return briefing_data
except Exception as e:
print(f"[db] Failed to fetch briefing: {e}")
return None
finally:
conn.close()
def get_available_briefing_dates():
"""
Returns a list of date strings (sorted descending)
representing all available briefings in the database.
"""
owner_id = get_owner_user_id()
conn = get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT date::text
FROM daily_briefings
WHERE user_id = %s
ORDER BY date::text DESC;
""",
(owner_id,)
)
rows = cur.fetchall()
return [r[0] for r in rows]
except Exception as e:
print(f"[db] Failed to fetch dates: {e}")
return []
finally:
conn.close()
def load_all_buy_signals_from_db():
"""
Fetches all historical BUY signals from the database.
Returns a list of dicts.
"""
owner_id = get_owner_user_id()
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT db.date::text as date, s.symbol, s.signal,
s.price_on_day, s.qty, s.nifty_on_day
FROM stock_signals s
JOIN daily_briefings db ON s.briefing_id = db.id
WHERE db.user_id = %s AND s.signal = 'BUY'
ORDER BY db.date ASC;
""",
(owner_id,)
)
rows = cur.fetchall()
cleaned_rows = []
for r in rows:
row_dict = dict(r)
row_dict["price_on_day"] = float(row_dict["price_on_day"]) if row_dict["price_on_day"] is not None else 0.0
row_dict["qty"] = float(row_dict["qty"]) if row_dict["qty"] is not None else 0.0
row_dict["nifty_on_day"] = float(row_dict["nifty_on_day"]) if row_dict["nifty_on_day"] is not None else None
cleaned_rows.append(row_dict)
return cleaned_rows
except Exception as e:
print(f"[db] Failed to fetch signals: {e}")
return []
finally:
conn.close()
def _derive_signals(data_dict, portfolio_df):
"""Helper to derive signals from snapshot / data_dict if _signal_prices is missing."""
import re
if portfolio_df is None or portfolio_df.empty:
return {}
nifty_on_day = None
market_summary = data_dict.get("market_summary", [])
for item in market_summary:
label = item.get("label", "").lower()
if "nifty 50" in label or "nifty50" in label:
raw = item.get("value", "").replace(",", "")
match = re.search(r"[\d]+\.?\d*", raw)
if match:
try:
nifty_on_day = float(match.group())
except ValueError:
pass
break
ltp_map = {}
qty_map = {}
for _, row in portfolio_df.iterrows():
sym = row.get("symbol")
if sym:
ltp_map[sym] = float(row.get("ltp") or 0)
qty_map[sym] = float(row.get("qty") or 0)
signal_prices = {}
portfolio_signals = data_dict.get("portfolio", [])
for entry in portfolio_signals:
sym = entry.get("symbol")
sig = entry.get("signal", "")
if sym and sig in ["BUY", "AVOID", "HOLD", "WATCH"] and sym in ltp_map and ltp_map[sym] > 0:
signal_prices[sym] = {
"signal": sig,
"price_on_day": ltp_map[sym],
"qty": qty_map.get(sym, 1.0),
"nifty_on_day": nifty_on_day
}
return signal_prices
def snapshot_holdings(owner_id, snapshot_date: str):
"""
Saves current portfolio_holdings as a holdings_snapshot before they are overwritten.
Idempotent — only snapshots once per date.
"""
conn = get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM holdings_snapshots WHERE user_id = %s AND snapshot_date = %s;",
(owner_id, snapshot_date)
)
count = cur.fetchone()[0]
if count > 0:
print(f"[db] Snapshot for {snapshot_date} already exists, skipping.")
return
cur.execute(
"SELECT symbol, qty, avg_cost, current_price FROM portfolio_holdings WHERE user_id = %s;",
(owner_id,)
)
rows = cur.fetchall()
if not rows:
print("[db] No holdings to snapshot.")
return
for r in rows:
cur.execute(
"""
INSERT INTO holdings_snapshots (user_id, snapshot_date, symbol, qty, avg_cost, ltp)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (user_id, snapshot_date, symbol) DO NOTHING;
""",
(owner_id, snapshot_date, r[0], r[1], r[2], r[3])
)
conn.commit()
print(f"[db] Saved holdings snapshot for {snapshot_date} ({len(rows)} symbols).")
except Exception as e:
conn.rollback()
print(f"[db] snapshot_holdings failed: {e}")
finally:
conn.close()
def detect_and_save_actual_trades(owner_id, new_holdings: list, trade_date: str):
"""
Compares new_holdings against the most recent holdings snapshot.
Symbols where new_qty > snapshot_qty = new purchase. Saves deltas to actual_trades.
new_holdings: list of dicts with keys: symbol, qty, avg_cost, ltp
"""
conn = get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT ON (symbol) symbol, qty
FROM holdings_snapshots
WHERE user_id = %s
ORDER BY symbol, snapshot_date DESC;
""",
(owner_id,)
)
snap_rows = cur.fetchall()
if not snap_rows:
print("[db] No snapshot found - cannot detect trades. Snapshot saved next upload.")
return 0
snap_map = {r[0]: float(r[1]) for r in snap_rows}
new_map = {
h["symbol"]: (float(h["qty"]), float(h.get("avg_cost", 0)), float(h.get("ltp") or 0))
for h in new_holdings
}
nifty_price = None
try:
from modules.price_fetcher import fetch_nifty50_current
nifty_price = fetch_nifty50_current()
except Exception as nifty_err:
print(f"[db] Could not fetch Nifty: {nifty_err}")
trades_inserted = 0
for symbol, (new_qty, avg_cost, ltp) in new_map.items():
old_qty = snap_map.get(symbol, 0.0)
delta_qty = new_qty - old_qty
if delta_qty > 0.001 and avg_cost > 0:
cur.execute(
"""
INSERT INTO actual_trades (user_id, trade_date, symbol, qty_bought, buy_price, nifty_on_trade_day)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (user_id, trade_date, symbol) DO UPDATE
SET qty_bought = EXCLUDED.qty_bought,
buy_price = EXCLUDED.buy_price,
nifty_on_trade_day = EXCLUDED.nifty_on_trade_day;
""",
(owner_id, trade_date, symbol, delta_qty, avg_cost, nifty_price)
)
trades_inserted += 1
print(f"[db] Actual trade: {symbol} +{delta_qty:.2f} @ Rs {avg_cost:.2f}")
conn.commit()
print(f"[db] Saved {trades_inserted} actual trade(s) for {trade_date}.")
return trades_inserted
except Exception as e:
conn.rollback()
print(f"[db] detect_and_save_actual_trades failed: {e}")
return 0
finally:
conn.close()
def load_actual_trades_from_db(owner_id) -> list:
"""Returns all actual trades for the owner, newest first."""
conn = get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT trade_date::text, symbol, qty_bought, buy_price, nifty_on_trade_day
FROM actual_trades
WHERE user_id = %s
ORDER BY trade_date DESC, symbol ASC;
""",
(owner_id,)
)
rows = cur.fetchall()
return [
{
"trade_date": r[0],
"symbol": r[1],
"qty_bought": float(r[2]),
"buy_price": float(r[3]),
"nifty_on_trade_day": float(r[4]) if r[4] is not None else None,
}
for r in rows
]
except Exception as e:
print(f"[db] load_actual_trades_from_db failed: {e}")
return []
finally:
conn.close()
def get_ai_suggestions_for_date(owner_id, trade_date: str) -> list:
"""Returns AI BUY signals from the briefing on trade_date for comparison table."""
conn = get_db_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT s.symbol, s.signal, s.price_on_day
FROM stock_signals s
JOIN daily_briefings db ON s.briefing_id = db.id
WHERE db.user_id = %s AND db.date = %s
ORDER BY s.signal, s.symbol;
""",
(owner_id, trade_date)
)
rows = cur.fetchall()
return [
{"symbol": r[0], "signal": r[1], "price_on_day": float(r[2])}
for r in rows
]
except Exception as e:
print(f"[db] get_ai_suggestions_for_date failed: {e}")
return []
finally:
conn.close()