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:
shawnkim1997
2026-03-21 02:10:10 +00:00
co-authored by Claude Opus 4.6
parent 56a9561f71
commit b2acda81ee
111 changed files with 13883 additions and 270 deletions
+221
View File
@@ -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()
+159
View File
@@ -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
+122
View File
@@ -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
+62
View File
@@ -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
+115
View File
@@ -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
+222
View File
@@ -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
+68
View File
@@ -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()
+95
View File
@@ -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()