phase 2: symbol registry validation, seed data, nix dev shell

- Enhanced CompanyCreate with ticker format validation (1-10 uppercase letters)
- Enhanced SourceCreate with pydantic validators for source_type, access_policy, config URLs
- Added /health endpoint to symbol registry
- Seed data: 10 companies (AAPL, MSFT, NVDA, AMZN, GOOGL, JPM, JNJ, XOM, TSLA, META)
- Seed sources: Alpha Vantage (market), NewsAPI (news), SEC EDGAR (filings), Alpaca (paper trading)
- Seed watchlist: 'Starter 10' with all companies and aliases
- Added flake.nix dev shell (nixos-25.11) with Python 3.12, ruff, pytest, kubectl, helm
- 30 passing tests, lint clean, Docker build verified
This commit is contained in:
Celes Renata
2026-04-11 03:41:41 -07:00
parent ebea70573b
commit 7394d241c9
7 changed files with 480 additions and 10 deletions
+53 -3
View File
@@ -1,10 +1,12 @@
"""Symbol Registry API - FastAPI application."""
import re
from contextlib import asynccontextmanager
from typing import List, Optional
from urllib.parse import urlparse
import asyncpg
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from services.shared.config import load_config
from services.shared.db import get_pg_pool
@@ -24,6 +26,19 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Stonks Oracle - Symbol Registry", lifespan=lifespan)
@app.get("/health")
async def health():
try:
await pool.fetchval("SELECT 1")
return {"status": "ok"}
except Exception:
raise HTTPException(503, "Database unavailable")
TICKER_PATTERN = re.compile(r"^[A-Z]{1,10}$")
VALID_SOURCE_TYPES = {"market_api", "news_api", "filings_api", "web_scrape", "broker"}
VALID_ACCESS_POLICIES = {"internal", "public", "restricted"}
# --- Request/Response Models ---
class CompanyCreate(BaseModel):
@@ -34,6 +49,14 @@ class CompanyCreate(BaseModel):
industry: Optional[str] = None
market_cap_bucket: Optional[str] = None
@field_validator("ticker")
@classmethod
def validate_ticker(cls, v: str) -> str:
v = v.upper().strip()
if not TICKER_PATTERN.match(v):
raise ValueError(f"Ticker must be 1-10 uppercase letters, got: {v}")
return v
class CompanyResponse(BaseModel):
id: str
@@ -64,6 +87,31 @@ class SourceCreate(BaseModel):
retention_days: int = 365
access_policy: str = "internal"
@field_validator("source_type")
@classmethod
def validate_source_type(cls, v: str) -> str:
if v not in VALID_SOURCE_TYPES:
raise ValueError(f"source_type must be one of {VALID_SOURCE_TYPES}")
return v
@field_validator("access_policy")
@classmethod
def validate_access_policy(cls, v: str) -> str:
if v not in VALID_ACCESS_POLICIES:
raise ValueError(f"access_policy must be one of {VALID_ACCESS_POLICIES}")
return v
@field_validator("config")
@classmethod
def validate_config_urls(cls, v: dict) -> dict:
"""Validate any URL fields in the config dict."""
for key in ("base_url", "endpoint", "url"):
if key in v and v[key]:
parsed = urlparse(str(v[key]))
if key == "base_url" and parsed.scheme not in ("http", "https"):
raise ValueError(f"config.{key} must be a valid HTTP(S) URL")
return v
VALID_SOURCE_TYPES = {"market_api", "news_api", "filings_api", "web_scrape", "broker"}
@@ -188,8 +236,10 @@ async def list_watchlist_members(watchlist_id: str):
@app.post("/companies/{company_id}/sources", status_code=201)
async def add_source(company_id: str, body: SourceCreate):
if body.source_type not in VALID_SOURCE_TYPES:
raise HTTPException(400, f"Invalid source_type. Must be one of: {VALID_SOURCE_TYPES}")
# Verify company exists
exists = await pool.fetchval("SELECT 1 FROM companies WHERE id = $1", company_id)
if not exists:
raise HTTPException(404, "Company not found")
row = await pool.fetchrow(
"""INSERT INTO sources (company_id, source_type, source_name, config, credibility_score, retention_days, access_policy)
VALUES ($1, $2, $3, $4, $5, $6, $7)