""" Database Migration Script for PortIQ. Loads local JSON history files into the PostgreSQL database. Requirements: pip install psycopg2-binary python-dotenv pandas Run from the project root: python scripts/migrate_to_postgres.py """ import os import sys import json import re from datetime import datetime import pandas as pd from dotenv import load_dotenv if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") # Ensure we can load packages and configs from project root sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Load env variables load_dotenv() # Verify psycopg2 installation try: import psycopg2 from psycopg2.extras import Json except ImportError: print("āŒ Error: psycopg2 is not installed.") print("šŸ‘‰ Please run: pip install psycopg2-binary") sys.exit(1) HISTORY_DIR = "history" SCHEMA_FILE = os.path.join("config", "schema.sql") HOLDINGS_FILE = "holdings.csv" def get_db_connection(): """Establishes connection to the PostgreSQL database.""" db_url = os.getenv("DATABASE_URL") if db_url: print(f"Connecting to database using DATABASE_URL...") return psycopg2.connect(db_url) # Try connecting using individual variables 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") print(f"Connecting to database {dbname} on {host}:{port}...") return psycopg2.connect( host=host, port=port, dbname=dbname, user=user, password=password ) def initialize_schema(conn): """Executes the DDL schema in config/schema.sql to create tables.""" if not os.path.exists(SCHEMA_FILE): print(f"āŒ Schema file {SCHEMA_FILE} not found!") return False print("šŸ› ļø Initializing database tables...") with open(SCHEMA_FILE, "r", encoding="utf-8") as f: schema_ddl = f.read() with conn.cursor() as cur: cur.execute(schema_ddl) # Schema upgrade guard: Ensure new columns exist on existing databases try: cur.execute("ALTER TABLE daily_briefings ADD COLUMN IF NOT EXISTS portfolio JSONB DEFAULT '[]'::jsonb;") cur.execute("ALTER TABLE daily_briefings ADD COLUMN IF NOT EXISTS portfolio_snapshot JSONB DEFAULT '[]'::jsonb;") except Exception as alter_err: print(f"āš ļø Note: daily_briefings alteration skipped: {alter_err}") # Schema upgrade guard: portfolio_holdings price columns (added in v2) for col_ddl in [ "ALTER TABLE portfolio_holdings ADD COLUMN IF NOT EXISTS current_price NUMERIC(12,4);", "ALTER TABLE portfolio_holdings ADD COLUMN IF NOT EXISTS close_price_prev NUMERIC(12,4);", "ALTER TABLE portfolio_holdings ADD COLUMN IF NOT EXISTS csv_price NUMERIC(12,4);", "ALTER TABLE portfolio_holdings ADD COLUMN IF NOT EXISTS csv_close_prev NUMERIC(12,4);", ]: try: cur.execute(col_ddl) except Exception as col_err: print(f"āš ļø Column alteration skipped: {col_err}") conn.commit() print("āœ… Database schema initialized successfully.") return True def get_or_create_owner_user(conn): """Creates a default owner user account matching the env credentials.""" owner_hash = os.getenv("OWNER_PASSWORD_HASH") if not owner_hash: # Fallback owner hash of 'tcr-owner' owner_hash = "084f7fa87d1dfcbb3965db0183b544b60a3cc180c59800a6e30018f70094770e" email = "owner@portiq.com" with conn.cursor() as cur: # Check if owner already exists cur.execute("SELECT id FROM users WHERE email = %s;", (email,)) row = cur.fetchone() if row: print(f"šŸ‘¤ Found existing Owner user: {email} (ID: {row[0]})") return row[0] # Create default owner user print(f"šŸ‘¤ Creating default Owner user profile: {email}...") 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() print(f"šŸ‘¤ Created Owner user with UUID: {owner_id}") return owner_id def parse_nifty_from_market_summary(market_summary) -> float | None: """Helper to parse nifty 50 numeric value from market summary.""" if not isinstance(market_summary, list): return None 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: return float(match.group()) except ValueError: pass return None def get_signal_prices_from_snapshot(data: dict) -> dict: """Derives signal prices if _signal_prices field is missing from JSON.""" snapshot = data.get("_portfolio_snapshot", []) portfolio_signals = data.get("portfolio", []) market_summary = data.get("market_summary", []) ltp_map = {} qty_map = {} for item in snapshot: sym = item.get("symbol") if sym: ltp_map[sym] = float(item.get("ltp") or 0) qty_map[sym] = float(item.get("qty") or 0) nifty_on_day = parse_nifty_from_market_summary(market_summary) result = {} for entry in portfolio_signals: sym = entry.get("symbol") sig = entry.get("signal", "") # Capture all signals to record history if sym and sig in ["BUY", "AVOID", "HOLD", "WATCH"] and sym in ltp_map and ltp_map[sym] > 0: result[sym] = { "signal": sig, "price_on_day": ltp_map[sym], "qty": qty_map.get(sym, 1.0), "nifty_on_day": nifty_on_day, } return result 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 migrate_history_briefings(conn, owner_id): """Reads history JSON files and inserts them into DB.""" if not os.path.exists(HISTORY_DIR): print(f"āš ļø History directory '{HISTORY_DIR}' not found. Skipping briefings migration.") return files = sorted([f for f in os.listdir(HISTORY_DIR) if f.endswith(".json") and f != "performance_cache.json"]) if not files: print("ā„¹ļø No JSON briefings found in history folder.") return print(f"šŸ”„ Found {len(files)} briefing files. Migrating to database...") migrated_count = 0 skipped_count = 0 for filename in files: date_str = filename.replace(".json", "") filepath = os.path.join(HISTORY_DIR, filename) try: with open(filepath, "r", encoding="utf-8") as f: data = json.load(f) except Exception as e: print(f" āŒ Error reading {filename}: {e}") continue # Parse fields from the briefing json and clean NaN values mood = clean_nans(data.get("mood", {})) news_summary = clean_nans(data.get("news_summary", [])) portfolio = clean_nans(data.get("portfolio", [])) top_picks = clean_nans(data.get("top_picks", [])) avoid_today = clean_nans(data.get("avoid_today", [])) market_summary = clean_nans(data.get("market_summary", [])) news = clean_nans(data.get("news", [])) dividends = clean_nans(data.get("dividends", [])) portfolio_snapshot = clean_nans(data.get("_portfolio_snapshot", [])) # Overwrite existing records to ensure new schema columns (portfolio & snapshot) are backfilled with conn.cursor() as cur: cur.execute( "DELETE FROM daily_briefings WHERE user_id = %s AND date = %s RETURNING id;", (owner_id, date_str) ) overwritten = cur.fetchone() if overwritten: print(f" šŸ”„ Overwriting briefing for {date_str} to update schema fields...") # Insert daily briefing 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] # Extract and migrate signal history signal_prices = data.get("_signal_prices") if not signal_prices: signal_prices = get_signal_prices_from_snapshot(data) 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) ) conn.commit() print(f" āœ… Migrated briefing for {date_str} (Created {len(signal_prices)} signals)") migrated_count += 1 print(f"šŸŽ‰ Briefings migration completed: {migrated_count} inserted, {skipped_count} skipped.") def migrate_holdings(conn, owner_id): """Loads CSV holdings (if present) and updates the portfolio_holdings table.""" if not os.path.exists(HOLDINGS_FILE): print(f"āš ļø CSV Holdings file '{HOLDINGS_FILE}' not found. Skipping holdings migration.") return try: df = pd.read_csv(HOLDINGS_FILE) # Clean columns: rename typical Zerodha columns to lowercase keys df.columns = [c.strip().lower() for c in df.columns] except Exception as e: print(f"āŒ Error loading holdings CSV: {e}") return synonyms = { 'instrument': 'symbol', 'quantity': 'qty', 'qty.': 'qty', 'average cost': 'avg_cost', 'avg. cost': 'avg_cost', 'buy average': 'avg_cost', 'ltp': 'ltp', 'day chg.': 'day_chg', 'day chg': 'day_chg' } df = df.rename(columns=synonyms) required_cols = {'symbol', 'qty', 'avg_cost'} if not required_cols.issubset(df.columns): print(f"āŒ Holdings CSV is missing required columns (symbol, qty, avg_cost). Found: {list(df.columns)}") return print(f"šŸ“‹ Found holdings CSV with {len(df)} stocks. Migrating to database...") inserted_count = 0 with conn.cursor() as cur: for _, row in df.iterrows(): symbol = str(row['symbol']).strip().upper() qty = float(row['qty']) avg_cost = float(row['avg_cost']) ltp = float(row.get('ltp', 0.0)) day_chg = str(row.get('day_chg', '0.0')).replace('%', '').strip() try: day_chg = float(day_chg) except ValueError: day_chg = 0.0 # Calculate close_price_prev if ltp > 0: close_prev = ltp / (1.0 + day_chg / 100.0) else: close_prev = 0.0 if not symbol or qty <= 0: continue cur.execute( """ INSERT INTO portfolio_holdings (user_id, symbol, qty, avg_cost, current_price, close_price_prev, last_updated) VALUES (%s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP) ON CONFLICT (user_id, symbol) DO UPDATE SET qty = EXCLUDED.qty, avg_cost = EXCLUDED.avg_cost, current_price = EXCLUDED.current_price, close_price_prev = EXCLUDED.close_price_prev, last_updated = CURRENT_TIMESTAMP; """, (owner_id, symbol, qty, avg_cost, ltp, close_prev) ) inserted_count += 1 conn.commit() print(f"āœ… Successfully migrated {inserted_count} holding assets to database portfolio.") def main(): print("šŸš€ Starting PortIQ PostgreSQL Database Migration...") conn = None try: conn = get_db_connection() except Exception as e: print(f"āŒ Database Connection Failed: {e}") print("šŸ‘‰ Make sure your PostgreSQL server is running and .env configuration is correct.") sys.exit(1) try: # 1. Initialize schema tables if not initialize_schema(conn): print("āŒ Failed to initialize schema. Exiting.") sys.exit(1) # 2. Setup user role owner_id = get_or_create_owner_user(conn) # 3. Migrate historical daily reports migrate_history_briefings(conn, owner_id) # 4. Migrate current holdings csv migrate_holdings(conn, owner_id) print("\nšŸ† Migration script completed successfully!") except Exception as e: print(f"\nāŒ Error occurred during migration: {e}") if conn: conn.rollback() finally: if conn: conn.close() print("šŸ”Œ Database connection closed.") if __name__ == "__main__": main()