mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-24 16:08:04 +00:00
feat: add Atlas Terminal — Next.js 14 + FastAPI full-stack migration
Complete migration from Streamlit to Next.js 14 App Router + FastAPI backend. Frontend (Next.js 14): - 10 pages: Overview, Research, Valuation, Technical, Markets, Earnings, News, Portfolio, Filings, Settings - Terminal Noir dark theme with custom Tailwind config - TradingView Lightweight Charts for candlestick/volume - Valuation: DCF, Sensitivity Matrix, Monte Carlo, Tornado, Reverse DCF - Financial Statements table with YoY growth badges and margin rows - SEC EDGAR inline filing viewer with section tabs - News split-view with iframe article embedding - Technical Analysis with RSI, MACD, Bollinger, Fibonacci, Moving Averages - Earnings beat/miss visualization - AI Copilot chat panel with Gemini integration Backend (FastAPI): - 13 routers: market_data, financials, valuation, technical, earnings, insider, edgar, news, portfolio, analysis, chat, estimates, fx - Services: DCF engine, Monte Carlo simulation, sensitivity analysis, risk metrics, SEC parser, technical indicators - yfinance + yahooquery data sources with fallback pattern - SQLite caching layer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
56a9561f71
commit
b2acda81ee
@@ -0,0 +1,221 @@
|
||||
"""Two-tier caching system for ATLAS Terminal.
|
||||
|
||||
Tier 1 – ``MemoryCache``: fast in-process dict with per-key TTL.
|
||||
Tier 2 – ``DBCache``: durable SQLite-backed cache with per-key TTL.
|
||||
``CacheManager`` orchestrates both tiers (memory-first, DB-second).
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from server.db.database import get_db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 1: In-memory cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_MEMORY_TTL: int = 300 # 5 minutes
|
||||
|
||||
|
||||
class MemoryCache:
|
||||
"""Thread-*unsafe* in-memory cache backed by a plain dict.
|
||||
|
||||
Each entry stores ``(value, expiry_timestamp)``. Expired entries are
|
||||
lazily evicted on ``get()``.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: Dict[str, tuple[Any, float]] = {}
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""Return the cached value for *key*, or ``None`` if missing/expired.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
|
||||
Returns:
|
||||
The stored value, or ``None``.
|
||||
"""
|
||||
entry = self._store.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
value, expires_at = entry
|
||||
if time.time() > expires_at:
|
||||
del self._store[key]
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int = _DEFAULT_MEMORY_TTL) -> None:
|
||||
"""Store *value* under *key* with the given TTL in seconds.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
value: Arbitrary Python object to cache.
|
||||
ttl: Time-to-live in seconds (default 300).
|
||||
"""
|
||||
self._store[key] = (value, time.time() + ttl)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Remove *key* from the cache (no-op if absent).
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
"""
|
||||
self._store.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all entries from the cache."""
|
||||
self._store.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 2: SQLite-backed cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DEFAULT_DB_TTL: int = 86400 # 1 day
|
||||
|
||||
|
||||
class DBCache:
|
||||
"""Durable cache that persists entries in the ``cache`` SQLite table.
|
||||
|
||||
Values are stored as JSON-encoded text so that structured data survives
|
||||
a round-trip.
|
||||
"""
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
"""Return the cached value for *key*, or ``None`` if missing/expired.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
|
||||
Returns:
|
||||
The stored value string, or ``None``.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT value, expires_at FROM cache WHERE key = ?",
|
||||
(key,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
value: str = row[0]
|
||||
expires_at: float = row[1]
|
||||
|
||||
if time.time() > expires_at:
|
||||
await db.execute("DELETE FROM cache WHERE key = ?", (key,))
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
async def set(self, key: str, value: str, ttl: int = _DEFAULT_DB_TTL) -> None:
|
||||
"""Store *value* under *key* with the given TTL in seconds.
|
||||
|
||||
Uses ``INSERT OR REPLACE`` so existing entries are overwritten.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
value: String value to persist.
|
||||
ttl: Time-to-live in seconds (default 86400).
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
expires_at = time.time() + ttl
|
||||
await db.execute(
|
||||
"INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)",
|
||||
(key, value, expires_at),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Delete all expired entries from the cache table."""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
await db.execute(
|
||||
"DELETE FROM cache WHERE expires_at < ?",
|
||||
(time.time(),),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Two-tier cache that checks memory first, then SQLite.
|
||||
|
||||
Usage::
|
||||
|
||||
value = await cache_manager.get("key")
|
||||
await cache_manager.set("key", payload, memory_ttl=60, db_ttl=3600)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.memory = MemoryCache()
|
||||
self.db = DBCache()
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
"""Look up *key* in memory, then in the DB cache.
|
||||
|
||||
If the value is found only in the DB tier it is promoted back into
|
||||
memory with the default memory TTL.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
|
||||
Returns:
|
||||
The cached value (deserialized from JSON when coming from DB),
|
||||
or ``None``.
|
||||
"""
|
||||
# Tier 1
|
||||
mem_value = self.memory.get(key)
|
||||
if mem_value is not None:
|
||||
return mem_value
|
||||
|
||||
# Tier 2
|
||||
db_value = await self.db.get(key)
|
||||
if db_value is not None:
|
||||
try:
|
||||
deserialized = json.loads(db_value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
deserialized = db_value
|
||||
|
||||
# Promote to memory for faster subsequent access
|
||||
self.memory.set(key, deserialized)
|
||||
return deserialized
|
||||
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
memory_ttl: int = _DEFAULT_MEMORY_TTL,
|
||||
db_ttl: int = _DEFAULT_DB_TTL,
|
||||
) -> None:
|
||||
"""Write *value* to both cache tiers.
|
||||
|
||||
The value is JSON-serialized before writing to the DB tier.
|
||||
|
||||
Args:
|
||||
key: Cache key.
|
||||
value: Arbitrary Python object to cache.
|
||||
memory_ttl: TTL for the in-memory tier (default 300s).
|
||||
db_ttl: TTL for the SQLite tier (default 86400s).
|
||||
"""
|
||||
self.memory.set(key, value, ttl=memory_ttl)
|
||||
|
||||
serialized = json.dumps(value, default=str)
|
||||
await self.db.set(key, serialized, ttl=db_ttl)
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
cache_manager: CacheManager = CacheManager()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Dashboard layout persistence backed by SQLite.
|
||||
|
||||
All functions operate on the ``dashboards`` table and return plain dicts.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from server.db.database import get_db
|
||||
|
||||
|
||||
def _row_to_dict(row: aiosqlite.Row) -> Dict[str, Any]:
|
||||
"""Convert an ``aiosqlite.Row`` to a plain dict.
|
||||
|
||||
Args:
|
||||
row: A database row.
|
||||
|
||||
Returns:
|
||||
A dict keyed by column name.
|
||||
"""
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""Return the current UTC time as an ISO-8601 string.
|
||||
|
||||
Returns:
|
||||
e.g. ``'2026-03-20T12:34:56'``
|
||||
"""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
|
||||
async def get_all_dashboards() -> List[Dict[str, Any]]:
|
||||
"""Return all saved dashboards ordered by id.
|
||||
|
||||
Returns:
|
||||
A list of dashboard dicts.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute("SELECT * FROM dashboards ORDER BY id")
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def get_dashboard(dashboard_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch a single dashboard by its primary key.
|
||||
|
||||
Args:
|
||||
dashboard_id: The id of the dashboard.
|
||||
|
||||
Returns:
|
||||
A dashboard dict, or ``None`` if not found.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM dashboards WHERE id = ?", (dashboard_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_dict(row) if row else None
|
||||
|
||||
|
||||
async def save_dashboard(name: str, layout_json: str) -> Dict[str, Any]:
|
||||
"""Create a new dashboard.
|
||||
|
||||
Args:
|
||||
name: Human-readable dashboard name.
|
||||
layout_json: JSON string describing the widget layout.
|
||||
|
||||
Returns:
|
||||
The newly created dashboard dict.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
now = _now_iso()
|
||||
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO dashboards (name, layout_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(name, layout_json, now, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
new_id = cursor.lastrowid
|
||||
result_cursor = await db.execute(
|
||||
"SELECT * FROM dashboards WHERE id = ?", (new_id,)
|
||||
)
|
||||
row = await result_cursor.fetchone()
|
||||
return _row_to_dict(row) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def update_dashboard(
|
||||
dashboard_id: int,
|
||||
name: Optional[str] = None,
|
||||
layout_json: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an existing dashboard's name and/or layout.
|
||||
|
||||
At least one of *name* or *layout_json* must be provided.
|
||||
``updated_at`` is set automatically.
|
||||
|
||||
Args:
|
||||
dashboard_id: The id of the dashboard to update.
|
||||
name: New dashboard name (or ``None`` to leave unchanged).
|
||||
layout_json: New layout JSON (or ``None`` to leave unchanged).
|
||||
|
||||
Returns:
|
||||
The updated dashboard dict.
|
||||
|
||||
Raises:
|
||||
ValueError: If the dashboard does not exist or no fields given.
|
||||
"""
|
||||
fields: Dict[str, Any] = {}
|
||||
if name is not None:
|
||||
fields["name"] = name
|
||||
if layout_json is not None:
|
||||
fields["layout_json"] = layout_json
|
||||
|
||||
if not fields:
|
||||
raise ValueError("At least one of 'name' or 'layout_json' must be provided")
|
||||
|
||||
fields["updated_at"] = _now_iso()
|
||||
|
||||
set_clause = ", ".join(f"{col} = ?" for col in fields)
|
||||
values = list(fields.values()) + [dashboard_id]
|
||||
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
await db.execute(
|
||||
f"UPDATE dashboards SET {set_clause} WHERE id = ?", # noqa: S608
|
||||
values,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM dashboards WHERE id = ?", (dashboard_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise ValueError(f"Dashboard with id={dashboard_id} not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
async def delete_dashboard(dashboard_id: int) -> bool:
|
||||
"""Delete a dashboard by id.
|
||||
|
||||
Args:
|
||||
dashboard_id: The id to delete.
|
||||
|
||||
Returns:
|
||||
``True`` if a row was deleted, ``False`` otherwise.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM dashboards WHERE id = ?", (dashboard_id,)
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
@@ -0,0 +1,122 @@
|
||||
"""SQLite database manager for ATLAS Terminal.
|
||||
|
||||
Provides async database access via aiosqlite with a singleton connection
|
||||
pattern. Replaces the previous Supabase dependency with local-first SQLite.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
# Resolve the database file path relative to this module
|
||||
_DB_DIR: Path = Path(__file__).resolve().parent.parent / "data"
|
||||
_DB_PATH: str = str(_DB_DIR / "atlas.db")
|
||||
|
||||
# Singleton connection holder
|
||||
_connection: Optional[aiosqlite.Connection] = None
|
||||
|
||||
|
||||
async def get_db() -> aiosqlite.Connection:
|
||||
"""Return the singleton async SQLite connection.
|
||||
|
||||
Creates the connection (and the data/ directory) on first call.
|
||||
Enables WAL mode and foreign keys for better concurrency and integrity.
|
||||
|
||||
Returns:
|
||||
An open ``aiosqlite.Connection`` ready for queries.
|
||||
"""
|
||||
global _connection
|
||||
|
||||
if _connection is not None:
|
||||
return _connection
|
||||
|
||||
_DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_connection = await aiosqlite.connect(_DB_PATH)
|
||||
_connection.row_factory = aiosqlite.Row
|
||||
await _connection.execute("PRAGMA journal_mode=WAL")
|
||||
await _connection.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
return _connection
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all application tables if they do not already exist.
|
||||
|
||||
Should be called once during application startup (e.g. in a FastAPI
|
||||
``lifespan`` handler).
|
||||
"""
|
||||
db = await get_db()
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
shares REAL NOT NULL,
|
||||
avg_cost REAL NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'USD',
|
||||
broker TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS watchlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL UNIQUE,
|
||||
added_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dashboards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
layout_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""Close the singleton database connection.
|
||||
|
||||
Safe to call even if the connection was never opened.
|
||||
"""
|
||||
global _connection
|
||||
|
||||
if _connection is not None:
|
||||
await _connection.close()
|
||||
_connection = None
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
PostgreSQL cache repository — persistent cache with TTL.
|
||||
"""
|
||||
import json
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from server.db.pg_database import get_pg_pool
|
||||
|
||||
|
||||
async def pg_cache_get(key: str) -> Optional[Any]:
|
||||
"""Get a cached value. Returns None if expired or not found."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return None
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT value FROM cache WHERE key = $1 AND expires_at > NOW()",
|
||||
key,
|
||||
)
|
||||
if row and row["value"] is not None:
|
||||
return row["value"] # JSONB auto-deserializes
|
||||
return None
|
||||
|
||||
|
||||
async def pg_cache_set(key: str, value: Any, ttl_seconds: int = 86400) -> None:
|
||||
"""Set a cache value with TTL."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return
|
||||
expires = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""INSERT INTO cache (key, value, expires_at)
|
||||
VALUES ($1, $2::jsonb, $3)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $2::jsonb, expires_at = $3""",
|
||||
key, json.dumps(value), expires,
|
||||
)
|
||||
|
||||
|
||||
async def pg_cache_delete(key: str) -> None:
|
||||
"""Delete a specific cache entry."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("DELETE FROM cache WHERE key = $1", key)
|
||||
|
||||
|
||||
async def pg_cache_cleanup() -> int:
|
||||
"""Remove expired cache entries. Returns count of deleted rows."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return 0
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM cache WHERE expires_at < NOW()"
|
||||
)
|
||||
# Parse "DELETE N" result
|
||||
try:
|
||||
return int(result.split()[-1])
|
||||
except (IndexError, ValueError):
|
||||
return 0
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
PostgreSQL async connection manager for ATLAS Terminal.
|
||||
Uses asyncpg for high-performance async PostgreSQL operations.
|
||||
Configurable via DATABASE_URL environment variable.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import asyncpg
|
||||
try:
|
||||
import asyncpg
|
||||
HAS_ASYNCPG = True
|
||||
except ImportError:
|
||||
HAS_ASYNCPG = False
|
||||
asyncpg = None
|
||||
|
||||
_pool: Optional[Any] = None
|
||||
|
||||
|
||||
async def get_pg_pool() -> Optional[Any]:
|
||||
"""Get or create the PostgreSQL connection pool."""
|
||||
global _pool
|
||||
if not HAS_ASYNCPG:
|
||||
logger.warning("asyncpg not installed. Run: pip install asyncpg")
|
||||
return None
|
||||
if _pool is not None:
|
||||
return _pool
|
||||
|
||||
database_url = os.getenv("DATABASE_URL", "")
|
||||
if not database_url:
|
||||
logger.info("No DATABASE_URL set, PostgreSQL disabled.")
|
||||
return None
|
||||
|
||||
try:
|
||||
_pool = await asyncpg.create_pool(
|
||||
database_url,
|
||||
min_size=2,
|
||||
max_size=10,
|
||||
command_timeout=30,
|
||||
)
|
||||
logger.info("PostgreSQL connection pool created.")
|
||||
return _pool
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create PostgreSQL pool: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def init_pg_tables() -> None:
|
||||
"""Create tables if they don't exist in PostgreSQL."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_positions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticker VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(200),
|
||||
shares DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
avg_cost DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
currency VARCHAR(10) DEFAULT 'USD',
|
||||
broker VARCHAR(100),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS watchlist (
|
||||
id SERIAL PRIMARY KEY,
|
||||
ticker VARCHAR(20) NOT NULL UNIQUE,
|
||||
added_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS dashboards (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
layout_json JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
key VARCHAR(500) PRIMARY KEY,
|
||||
value JSONB,
|
||||
expires_at TIMESTAMPTZ
|
||||
);
|
||||
""")
|
||||
# Index for cache expiry cleanup
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
|
||||
""")
|
||||
logger.info("PostgreSQL tables initialized.")
|
||||
|
||||
|
||||
async def close_pg_pool() -> None:
|
||||
"""Close the PostgreSQL connection pool."""
|
||||
global _pool
|
||||
if _pool:
|
||||
await _pool.close()
|
||||
_pool = None
|
||||
logger.info("PostgreSQL pool closed.")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
PostgreSQL portfolio repository — full CRUD for portfolio positions.
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from server.db.pg_database import get_pg_pool
|
||||
|
||||
|
||||
async def pg_get_all_positions() -> list[dict]:
|
||||
"""Get all portfolio positions from PostgreSQL."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return []
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM portfolio_positions ORDER BY updated_at DESC"
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def pg_add_position(
|
||||
ticker: str,
|
||||
name: str = "",
|
||||
shares: float = 0.0,
|
||||
avg_cost: float = 0.0,
|
||||
currency: str = "USD",
|
||||
broker: str = "",
|
||||
) -> Optional[dict]:
|
||||
"""Add a new position to PostgreSQL."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return None
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO portfolio_positions (ticker, name, shares, avg_cost, currency, broker)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *""",
|
||||
ticker.upper(), name, shares, avg_cost, currency, broker,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def pg_update_position(position_id: int, **fields) -> Optional[dict]:
|
||||
"""Update a position by ID."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return None
|
||||
|
||||
allowed = {"ticker", "name", "shares", "avg_cost", "currency", "broker"}
|
||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not updates:
|
||||
return None
|
||||
|
||||
updates["updated_at"] = datetime.now(timezone.utc)
|
||||
set_clauses = ", ".join(f"{k} = ${i+2}" for i, k in enumerate(updates.keys()))
|
||||
values = [position_id] + list(updates.values())
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"UPDATE portfolio_positions SET {set_clauses} WHERE id = $1 RETURNING *",
|
||||
*values,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def pg_delete_position(position_id: int) -> bool:
|
||||
"""Delete a position by ID."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return False
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM portfolio_positions WHERE id = $1", position_id
|
||||
)
|
||||
return result == "DELETE 1"
|
||||
|
||||
|
||||
async def pg_get_position_by_ticker(ticker: str) -> Optional[dict]:
|
||||
"""Get a position by ticker symbol."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return None
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM portfolio_positions WHERE ticker = $1", ticker.upper()
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def pg_bulk_add_positions(positions: list[dict]) -> list[dict]:
|
||||
"""Bulk add positions (from OCR screenshot)."""
|
||||
pool = await get_pg_pool()
|
||||
if not pool:
|
||||
return []
|
||||
results = []
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
for p in positions:
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO portfolio_positions (ticker, name, shares, avg_cost, currency, broker)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *""",
|
||||
p.get("ticker", "").upper(),
|
||||
p.get("name", ""),
|
||||
float(p.get("shares", 0)),
|
||||
float(p.get("avg_cost", 0)),
|
||||
p.get("currency", "USD"),
|
||||
p.get("broker", ""),
|
||||
)
|
||||
if row:
|
||||
results.append(dict(row))
|
||||
return results
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Portfolio CRUD operations backed by SQLite.
|
||||
|
||||
All functions operate on the ``portfolio_positions`` table and return plain
|
||||
dicts so they can be serialized directly by FastAPI.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from server.db.database import get_db
|
||||
|
||||
|
||||
def _row_to_dict(row: aiosqlite.Row) -> Dict[str, Any]:
|
||||
"""Convert an ``aiosqlite.Row`` to a plain dict.
|
||||
|
||||
Args:
|
||||
row: A database row returned with ``row_factory = aiosqlite.Row``.
|
||||
|
||||
Returns:
|
||||
A dict keyed by column name.
|
||||
"""
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""Return the current UTC time as an ISO-8601 string.
|
||||
|
||||
Returns:
|
||||
e.g. ``'2026-03-20T12:34:56'``
|
||||
"""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
|
||||
async def get_all_positions() -> List[Dict[str, Any]]:
|
||||
"""Return every row in ``portfolio_positions`` ordered by id.
|
||||
|
||||
Returns:
|
||||
A list of position dicts.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM portfolio_positions ORDER BY id"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def add_position(
|
||||
ticker: str,
|
||||
name: str,
|
||||
shares: float,
|
||||
avg_cost: float,
|
||||
currency: str = "USD",
|
||||
broker: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Insert a new portfolio position.
|
||||
|
||||
Args:
|
||||
ticker: Stock ticker symbol (e.g. ``'AAPL'``).
|
||||
name: Human-readable security name.
|
||||
shares: Number of shares held.
|
||||
avg_cost: Average cost basis per share.
|
||||
currency: ISO currency code (default ``'USD'``).
|
||||
broker: Optional broker name.
|
||||
|
||||
Returns:
|
||||
The newly created position as a dict (including generated id).
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
now = _now_iso()
|
||||
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_positions
|
||||
(ticker, name, shares, avg_cost, currency, broker, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(ticker, name, shares, avg_cost, currency, broker, now, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
new_id = cursor.lastrowid
|
||||
result_cursor = await db.execute(
|
||||
"SELECT * FROM portfolio_positions WHERE id = ?", (new_id,)
|
||||
)
|
||||
row = await result_cursor.fetchone()
|
||||
return _row_to_dict(row) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def update_position(position_id: int, **kwargs: Any) -> Dict[str, Any]:
|
||||
"""Update an existing portfolio position.
|
||||
|
||||
Only the supplied keyword arguments are modified; all others remain
|
||||
unchanged. ``updated_at`` is set automatically.
|
||||
|
||||
Args:
|
||||
position_id: The primary-key id of the position to update.
|
||||
**kwargs: Column names and their new values.
|
||||
|
||||
Returns:
|
||||
The updated position dict.
|
||||
|
||||
Raises:
|
||||
ValueError: If *position_id* does not exist or no fields are given.
|
||||
"""
|
||||
if not kwargs:
|
||||
raise ValueError("No fields provided for update")
|
||||
|
||||
allowed_fields = {"ticker", "name", "shares", "avg_cost", "currency", "broker"}
|
||||
fields = {k: v for k, v in kwargs.items() if k in allowed_fields}
|
||||
|
||||
if not fields:
|
||||
raise ValueError(
|
||||
f"No valid fields to update. Allowed: {allowed_fields}"
|
||||
)
|
||||
|
||||
fields["updated_at"] = _now_iso()
|
||||
|
||||
set_clause = ", ".join(f"{col} = ?" for col in fields)
|
||||
values = list(fields.values()) + [position_id]
|
||||
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
await db.execute(
|
||||
f"UPDATE portfolio_positions SET {set_clause} WHERE id = ?", # noqa: S608
|
||||
values,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM portfolio_positions WHERE id = ?", (position_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise ValueError(f"Position with id={position_id} not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
async def delete_position(position_id: int) -> bool:
|
||||
"""Delete a portfolio position by id.
|
||||
|
||||
Args:
|
||||
position_id: The primary-key id to delete.
|
||||
|
||||
Returns:
|
||||
``True`` if a row was deleted, ``False`` if no matching row existed.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM portfolio_positions WHERE id = ?", (position_id,)
|
||||
)
|
||||
await db.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
|
||||
async def get_position_by_ticker(ticker: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up a position by ticker symbol.
|
||||
|
||||
If multiple positions share the same ticker (e.g. different brokers),
|
||||
the first one (lowest id) is returned.
|
||||
|
||||
Args:
|
||||
ticker: The ticker to search for (case-sensitive).
|
||||
|
||||
Returns:
|
||||
A position dict, or ``None`` if not found.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM portfolio_positions WHERE ticker = ? ORDER BY id LIMIT 1",
|
||||
(ticker,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_dict(row) if row else None
|
||||
|
||||
|
||||
async def bulk_add_positions(
|
||||
positions: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Insert multiple positions in a single transaction.
|
||||
|
||||
Each dict in *positions* must contain at least ``ticker``, ``name``,
|
||||
``shares``, and ``avg_cost``. Optional keys: ``currency``, ``broker``.
|
||||
|
||||
Args:
|
||||
positions: A list of position dicts.
|
||||
|
||||
Returns:
|
||||
A list of the newly created position dicts.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
now = _now_iso()
|
||||
created: List[Dict[str, Any]] = []
|
||||
|
||||
for pos in positions:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_positions
|
||||
(ticker, name, shares, avg_cost, currency, broker, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
pos["ticker"],
|
||||
pos["name"],
|
||||
pos["shares"],
|
||||
pos["avg_cost"],
|
||||
pos.get("currency", "USD"),
|
||||
pos.get("broker"),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
result_cursor = await db.execute(
|
||||
"SELECT * FROM portfolio_positions WHERE id = ?", (new_id,)
|
||||
)
|
||||
row = await result_cursor.fetchone()
|
||||
created.append(_row_to_dict(row)) # type: ignore[arg-type]
|
||||
|
||||
await db.commit()
|
||||
return created
|
||||
@@ -0,0 +1,68 @@
|
||||
"""User settings persistence backed by SQLite.
|
||||
|
||||
Provides a simple key-value store for application settings such as API keys
|
||||
and user preferences, using the ``settings`` table.
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from server.db.database import get_db
|
||||
|
||||
|
||||
async def get_setting(key: str) -> Optional[str]:
|
||||
"""Retrieve a single setting by key.
|
||||
|
||||
Args:
|
||||
key: The setting key to look up.
|
||||
|
||||
Returns:
|
||||
The setting value, or ``None`` if the key does not exist.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT value FROM settings WHERE key = ?", (key,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def set_setting(key: str, value: str) -> None:
|
||||
"""Create or update a setting.
|
||||
|
||||
Uses ``INSERT OR REPLACE`` so the call is idempotent.
|
||||
|
||||
Args:
|
||||
key: The setting key.
|
||||
value: The setting value.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
await db.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_all_settings() -> Dict[str, str]:
|
||||
"""Return every setting as a ``{key: value}`` dict.
|
||||
|
||||
Returns:
|
||||
A dict mapping all stored keys to their values.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
cursor = await db.execute("SELECT key, value FROM settings ORDER BY key")
|
||||
rows = await cursor.fetchall()
|
||||
return {row[0]: row[1] for row in rows}
|
||||
|
||||
|
||||
async def delete_setting(key: str) -> None:
|
||||
"""Remove a setting by key (no-op if the key does not exist).
|
||||
|
||||
Args:
|
||||
key: The setting key to delete.
|
||||
"""
|
||||
db: aiosqlite.Connection = await get_db()
|
||||
await db.execute("DELETE FROM settings WHERE key = ?", (key,))
|
||||
await db.commit()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Unified repository — routes operations to PostgreSQL or SQLite
|
||||
based on DATABASE_URL environment variable.
|
||||
|
||||
Usage:
|
||||
from server.db.unified_repo import repo
|
||||
positions = await repo.get_all_positions()
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _use_postgres() -> bool:
|
||||
"""Check if PostgreSQL should be used."""
|
||||
return bool(os.getenv("DATABASE_URL", ""))
|
||||
|
||||
|
||||
class UnifiedRepo:
|
||||
"""Routes database operations to the appropriate backend."""
|
||||
|
||||
async def get_all_positions(self) -> list[dict]:
|
||||
if _use_postgres():
|
||||
from server.db.pg_portfolio_repo import pg_get_all_positions
|
||||
return await pg_get_all_positions()
|
||||
from server.db.portfolio_repo import get_all_positions
|
||||
return await get_all_positions()
|
||||
|
||||
async def add_position(self, **kwargs) -> Optional[dict]:
|
||||
if _use_postgres():
|
||||
from server.db.pg_portfolio_repo import pg_add_position
|
||||
return await pg_add_position(**kwargs)
|
||||
from server.db.portfolio_repo import add_position
|
||||
return await add_position(**kwargs)
|
||||
|
||||
async def update_position(self, position_id: int, **kwargs) -> Optional[dict]:
|
||||
if _use_postgres():
|
||||
from server.db.pg_portfolio_repo import pg_update_position
|
||||
return await pg_update_position(position_id, **kwargs)
|
||||
from server.db.portfolio_repo import update_position
|
||||
return await update_position(position_id, **kwargs)
|
||||
|
||||
async def delete_position(self, position_id: int) -> bool:
|
||||
if _use_postgres():
|
||||
from server.db.pg_portfolio_repo import pg_delete_position
|
||||
return await pg_delete_position(position_id)
|
||||
from server.db.portfolio_repo import delete_position
|
||||
return await delete_position(position_id)
|
||||
|
||||
async def bulk_add_positions(self, positions: list[dict]) -> list[dict]:
|
||||
if _use_postgres():
|
||||
from server.db.pg_portfolio_repo import pg_bulk_add_positions
|
||||
return await pg_bulk_add_positions(positions)
|
||||
from server.db.portfolio_repo import bulk_add_positions
|
||||
return await bulk_add_positions(positions)
|
||||
|
||||
async def cache_get(self, key: str) -> Optional[Any]:
|
||||
if _use_postgres():
|
||||
from server.db.pg_cache_repo import pg_cache_get
|
||||
return await pg_cache_get(key)
|
||||
from server.db.cache import cache_manager
|
||||
return await cache_manager.get(key)
|
||||
|
||||
async def cache_set(self, key: str, value: Any, ttl: int = 86400) -> None:
|
||||
if _use_postgres():
|
||||
from server.db.pg_cache_repo import pg_cache_set
|
||||
return await pg_cache_set(key, value, ttl)
|
||||
from server.db.cache import cache_manager
|
||||
await cache_manager.set(key, value, ttl)
|
||||
|
||||
async def init_db(self) -> None:
|
||||
"""Initialize the appropriate database."""
|
||||
if _use_postgres():
|
||||
from server.db.pg_database import init_pg_tables
|
||||
await init_pg_tables()
|
||||
logger.info("Using PostgreSQL backend.")
|
||||
else:
|
||||
from server.db.database import init_db
|
||||
await init_db()
|
||||
logger.info("Using SQLite backend.")
|
||||
|
||||
async def close_db(self) -> None:
|
||||
"""Close database connections."""
|
||||
if _use_postgres():
|
||||
from server.db.pg_database import close_pg_pool
|
||||
await close_pg_pool()
|
||||
else:
|
||||
from server.db.database import close_db
|
||||
await close_db()
|
||||
|
||||
|
||||
# Singleton
|
||||
repo = UnifiedRepo()
|
||||
Reference in New Issue
Block a user