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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user