Files
stonks-oracle/services/api/app.py
T

1508 lines
56 KiB
Python

"""Query API - FastAPI application for analytics, evidence drill-down, and admin controls.
Exposes read-only endpoints for:
- Companies and watchlists (proxied from symbol registry data)
- Document timelines with intelligence
- Trend summaries
- Recommendation history with evidence
- Order history with audit trails
Requirements: 11.1, 11.2, 11.3
Design: Section 9.1 (Operational API)
"""
from __future__ import annotations
import json
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any, Optional
import asyncpg
from fastapi import FastAPI, HTTPException, Query, Request
from starlette.middleware.base import BaseHTTPMiddleware
from services.shared.audit import get_entity_audit_trail, get_order_audit_trail
from services.shared.config import load_config
from services.shared.db import get_pg_pool
from services.shared.logging import new_trace_id, set_trace_context, setup_logging
from services.extractor.metrics import get_model_performance_summary
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
from starlette.responses import Response
logger = logging.getLogger("query_api")
config = load_config()
pool: Optional[asyncpg.Pool] = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global pool
setup_logging("query_api", level=config.log_level, json_output=config.json_logs)
pool = await get_pg_pool(config)
yield
await pool.close()
app = FastAPI(title="Stonks Oracle - Query API", lifespan=lifespan)
class TraceMiddleware(BaseHTTPMiddleware):
"""Inject trace context for every incoming HTTP request."""
async def dispatch(self, request: Request, call_next):
trace_id = request.headers.get("x-trace-id") or new_trace_id()
set_trace_context(trace_id=trace_id)
response = await call_next(request)
response.headers["x-trace-id"] = trace_id
return response
app.add_middleware(TraceMiddleware)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _row_to_dict(row: asyncpg.Record) -> dict[str, Any]:
"""Convert an asyncpg Record to a JSON-safe dict."""
d: dict[str, Any] = {}
for key, val in dict(row).items():
if isinstance(val, datetime):
d[key] = val.isoformat()
elif hasattr(val, "__str__") and not isinstance(val, (str, int, float, bool, list, dict, type(None))):
d[key] = str(val)
else:
d[key] = val
return d
def _parse_jsonb(val: Any) -> Any:
"""Parse a JSONB value that may come back as str or already-decoded."""
if val is None:
return None
if isinstance(val, (dict, list)):
return val
try:
return json.loads(val)
except (json.JSONDecodeError, TypeError):
return val
# ---------------------------------------------------------------------------
# Health
# ---------------------------------------------------------------------------
@app.get("/health")
async def health():
try:
await pool.fetchval("SELECT 1")
return {"status": "ok"}
except Exception:
raise HTTPException(503, "Database unavailable")
@app.get("/metrics")
async def metrics():
"""Expose Prometheus metrics for scraping.
Requirements: 12.1, 12.2
"""
return Response(
content=generate_latest(),
media_type=CONTENT_TYPE_LATEST,
)
# ---------------------------------------------------------------------------
# Companies (Requirement 11.1)
# ---------------------------------------------------------------------------
@app.get("/api/companies")
async def list_companies(
active: bool = True,
sector: Optional[str] = None,
ticker: Optional[str] = None,
):
"""List tracked companies with optional filters."""
conditions = ["c.active = $1"]
params: list[Any] = [active]
idx = 2
if sector:
conditions.append(f"c.sector = ${idx}")
params.append(sector)
idx += 1
if ticker:
conditions.append(f"c.ticker = ${idx}")
params.append(ticker.upper())
idx += 1
where = " AND ".join(conditions)
rows = await pool.fetch(
f"""SELECT c.id, c.ticker, c.legal_name, c.exchange, c.sector,
c.industry, c.market_cap_bucket, c.active,
c.created_at, c.updated_at
FROM companies c
WHERE {where}
ORDER BY c.ticker""",
*params,
)
return [_row_to_dict(r) for r in rows]
@app.get("/api/companies/{company_id}")
async def get_company(company_id: str):
"""Get a single company with aliases and source count."""
row = await pool.fetchrow(
"""SELECT id, ticker, legal_name, exchange, sector, industry,
market_cap_bucket, active, created_at, updated_at
FROM companies WHERE id = $1""",
company_id,
)
if not row:
raise HTTPException(404, "Company not found")
result = _row_to_dict(row)
aliases = await pool.fetch(
"SELECT id, alias, alias_type FROM company_aliases WHERE company_id = $1",
company_id,
)
result["aliases"] = [dict(a) for a in aliases]
source_count = await pool.fetchval(
"SELECT COUNT(*) FROM sources WHERE company_id = $1 AND active = true",
company_id,
)
result["active_source_count"] = source_count
return result
@app.get("/api/companies/{company_id}/sources")
async def list_company_sources(company_id: str):
"""List sources configured for a company."""
rows = await pool.fetch(
"""SELECT id, source_type, source_name, config, credibility_score,
retention_days, access_policy, active
FROM sources WHERE company_id = $1 ORDER BY source_type""",
company_id,
)
return [_row_to_dict(r) for r in rows]
# ---------------------------------------------------------------------------
# Document Timelines (Requirement 11.1, 11.2)
# ---------------------------------------------------------------------------
@app.get("/api/documents")
async def list_documents(
ticker: Optional[str] = None,
company_id: Optional[str] = None,
document_type: Optional[str] = None,
status: Optional[str] = None,
since: Optional[str] = None,
limit: int = Query(default=50, le=200),
offset: int = 0,
):
"""List documents with optional filters, ordered by published_at desc."""
conditions: list[str] = []
params: list[Any] = []
idx = 1
if ticker:
conditions.append(f"""d.id IN (
SELECT document_id FROM document_company_mentions WHERE ticker = ${idx}
)""")
params.append(ticker.upper())
idx += 1
if company_id:
conditions.append(f"""d.id IN (
SELECT document_id FROM document_company_mentions WHERE company_id = ${idx}
)""")
params.append(company_id)
idx += 1
if document_type:
conditions.append(f"d.document_type = ${idx}")
params.append(document_type)
idx += 1
if status:
conditions.append(f"d.status = ${idx}")
params.append(status)
idx += 1
if since:
conditions.append(f"d.published_at >= ${idx}::timestamptz")
params.append(since)
idx += 1
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows = await pool.fetch(
f"""SELECT d.id, d.document_type, d.source_type, d.publisher, d.url,
d.title, d.published_at, d.retrieved_at, d.language,
d.content_hash, d.parse_quality_score, d.parse_confidence,
d.status, d.created_at
FROM documents d
{where}
ORDER BY d.published_at DESC NULLS LAST
LIMIT ${idx} OFFSET ${idx + 1}""",
*params, limit, offset,
)
return [_row_to_dict(r) for r in rows]
@app.get("/api/documents/{document_id}")
async def get_document(document_id: str):
"""Get a single document with its intelligence extraction and company mentions."""
row = await pool.fetchrow(
"""SELECT id, document_type, source_type, publisher, url, canonical_url,
title, published_at, retrieved_at, language, content_hash,
raw_storage_ref, normalized_storage_ref,
parse_quality_score, parse_confidence, status,
created_at, updated_at
FROM documents WHERE id = $1""",
document_id,
)
if not row:
raise HTTPException(404, "Document not found")
result = _row_to_dict(row)
# Company mentions
mentions = await pool.fetch(
"""SELECT dcm.company_id, dcm.ticker, dcm.mention_type, dcm.confidence,
c.legal_name
FROM document_company_mentions dcm
JOIN companies c ON c.id = dcm.company_id
WHERE dcm.document_id = $1""",
document_id,
)
result["company_mentions"] = [_row_to_dict(m) for m in mentions]
# Intelligence extraction
intel = await pool.fetchrow(
"""SELECT id, summary, macro_themes, novelty_score, source_credibility,
extraction_warnings, confidence, model_provider, model_name,
prompt_version, schema_version, validation_status,
validation_errors, created_at
FROM document_intelligence WHERE document_id = $1
ORDER BY created_at DESC LIMIT 1""",
document_id,
)
if intel:
intel_dict = _row_to_dict(intel)
intel_dict["macro_themes"] = _parse_jsonb(intel_dict.get("macro_themes"))
intel_dict["extraction_warnings"] = _parse_jsonb(intel_dict.get("extraction_warnings"))
intel_dict["validation_errors"] = _parse_jsonb(intel_dict.get("validation_errors"))
# Impact records per company
impacts = await pool.fetch(
"""SELECT dir.company_id, dir.ticker, dir.relevance, dir.sentiment,
dir.impact_score, dir.impact_horizon, dir.catalyst_type,
dir.key_facts, dir.risks, dir.evidence_spans,
c.legal_name
FROM document_impact_records dir
JOIN companies c ON c.id = dir.company_id
WHERE dir.intelligence_id = $1""",
intel["id"],
)
impact_list = []
for imp in impacts:
imp_dict = _row_to_dict(imp)
imp_dict["key_facts"] = _parse_jsonb(imp_dict.get("key_facts"))
imp_dict["risks"] = _parse_jsonb(imp_dict.get("risks"))
imp_dict["evidence_spans"] = _parse_jsonb(imp_dict.get("evidence_spans"))
impact_list.append(imp_dict)
intel_dict["company_impacts"] = impact_list
result["intelligence"] = intel_dict
else:
result["intelligence"] = None
return result
# ---------------------------------------------------------------------------
# Trend Summaries (Requirement 11.1)
# ---------------------------------------------------------------------------
@app.get("/api/trends")
async def list_trends(
ticker: Optional[str] = None,
entity_type: str = "company",
window: Optional[str] = None,
limit: int = Query(default=50, le=200),
offset: int = 0,
):
"""List trend summaries with optional filters."""
conditions = [f"entity_type = $1"]
params: list[Any] = [entity_type]
idx = 2
if ticker:
conditions.append(f"entity_id = ${idx}")
params.append(ticker.upper())
idx += 1
if window:
conditions.append(f"window = ${idx}")
params.append(window)
idx += 1
where = " AND ".join(conditions)
rows = await pool.fetch(
f"""SELECT id, entity_type, entity_id, window, trend_direction,
trend_strength, confidence, top_supporting_evidence,
top_opposing_evidence, dominant_catalysts, material_risks,
contradiction_score, market_context, generated_at
FROM trend_windows
WHERE {where}
ORDER BY generated_at DESC
LIMIT ${idx} OFFSET ${idx + 1}""",
*params, limit, offset,
)
results = []
for r in rows:
d = _row_to_dict(r)
for jsonb_field in (
"top_supporting_evidence", "top_opposing_evidence",
"dominant_catalysts", "material_risks", "market_context",
):
d[jsonb_field] = _parse_jsonb(d.get(jsonb_field))
results.append(d)
return results
@app.get("/api/trends/{trend_id}")
async def get_trend(trend_id: str):
"""Get a single trend summary by ID."""
row = await pool.fetchrow(
"""SELECT id, entity_type, entity_id, window, trend_direction,
trend_strength, confidence, top_supporting_evidence,
top_opposing_evidence, dominant_catalysts, material_risks,
contradiction_score, market_context, generated_at, created_at
FROM trend_windows WHERE id = $1""",
trend_id,
)
if not row:
raise HTTPException(404, "Trend not found")
d = _row_to_dict(row)
for jsonb_field in (
"top_supporting_evidence", "top_opposing_evidence",
"dominant_catalysts", "material_risks", "market_context",
):
d[jsonb_field] = _parse_jsonb(d.get(jsonb_field))
return d
# ---------------------------------------------------------------------------
# Recommendations (Requirement 11.1, 11.2)
# ---------------------------------------------------------------------------
@app.get("/api/recommendations")
async def list_recommendations(
ticker: Optional[str] = None,
action: Optional[str] = None,
mode: Optional[str] = None,
since: Optional[str] = None,
limit: int = Query(default=50, le=200),
offset: int = 0,
):
"""List recommendations with optional filters."""
conditions: list[str] = []
params: list[Any] = []
idx = 1
if ticker:
conditions.append(f"r.ticker = ${idx}")
params.append(ticker.upper())
idx += 1
if action:
conditions.append(f"r.action = ${idx}")
params.append(action)
idx += 1
if mode:
conditions.append(f"r.mode = ${idx}")
params.append(mode)
idx += 1
if since:
conditions.append(f"r.generated_at >= ${idx}::timestamptz")
params.append(since)
idx += 1
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows = await pool.fetch(
f"""SELECT r.id, r.ticker, r.action, r.mode, r.confidence,
r.time_horizon, r.thesis, r.invalidation_conditions,
r.portfolio_pct, r.max_loss_pct, r.model_version,
r.risk_classification, r.generated_at
FROM recommendations r
{where}
ORDER BY r.generated_at DESC
LIMIT ${idx} OFFSET ${idx + 1}""",
*params, limit, offset,
)
results = []
for r in rows:
d = _row_to_dict(r)
d["invalidation_conditions"] = _parse_jsonb(d.get("invalidation_conditions"))
results.append(d)
return results
@app.get("/api/recommendations/{recommendation_id}")
async def get_recommendation(recommendation_id: str):
"""Get a single recommendation with evidence and risk evaluation.
Requirement 11.2: display contributing intelligence objects, raw sources,
and market context that influenced the decision.
"""
row = await pool.fetchrow(
"""SELECT r.id, r.ticker, r.company_id, r.action, r.mode, r.confidence,
r.time_horizon, r.thesis, r.invalidation_conditions,
r.portfolio_pct, r.max_loss_pct, r.model_version,
r.model_provider, r.prompt_version, r.schema_version,
r.risk_classification, r.generated_at, r.created_at
FROM recommendations r WHERE r.id = $1""",
recommendation_id,
)
if not row:
raise HTTPException(404, "Recommendation not found")
result = _row_to_dict(row)
result["invalidation_conditions"] = _parse_jsonb(result.get("invalidation_conditions"))
# Evidence: linked documents and intelligence objects
evidence_rows = await pool.fetch(
"""SELECT re.id, re.document_id, re.intelligence_id, re.evidence_type, re.weight,
d.title, d.document_type, d.source_type, d.publisher, d.url,
d.published_at
FROM recommendation_evidence re
LEFT JOIN documents d ON d.id = re.document_id
WHERE re.recommendation_id = $1
ORDER BY re.weight DESC""",
recommendation_id,
)
result["evidence"] = [_row_to_dict(e) for e in evidence_rows]
# Risk evaluation
risk_row = await pool.fetchrow(
"""SELECT id, eligible, allowed_mode, rejection_reasons, risk_checks, evaluated_at
FROM risk_evaluations WHERE recommendation_id = $1
ORDER BY evaluated_at DESC LIMIT 1""",
recommendation_id,
)
if risk_row:
risk_dict = _row_to_dict(risk_row)
risk_dict["rejection_reasons"] = _parse_jsonb(risk_dict.get("rejection_reasons"))
risk_dict["risk_checks"] = _parse_jsonb(risk_dict.get("risk_checks"))
result["risk_evaluation"] = risk_dict
else:
result["risk_evaluation"] = None
return result
# ---------------------------------------------------------------------------
# Evidence Drill-Down (Requirement 11.2, 10.4)
# ---------------------------------------------------------------------------
@app.get("/api/recommendations/{recommendation_id}/evidence")
async def get_recommendation_evidence_drilldown(recommendation_id: str):
"""Full evidence drill-down linking a recommendation to source documents and raw artifacts.
Returns the complete provenance chain for each piece of evidence:
recommendation_evidence → document (with storage refs) → document_intelligence
→ document_impact_records, plus the trend window that fed the recommendation.
Requirements: 11.2, 10.4
Design: Section 9.1 (evidence drill-down and audit views)
"""
# Verify recommendation exists and get basic info
rec_row = await pool.fetchrow(
"""SELECT id, ticker, company_id, action, mode, confidence,
time_horizon, thesis, model_version, model_provider,
prompt_version, schema_version, generated_at
FROM recommendations WHERE id = $1""",
recommendation_id,
)
if not rec_row:
raise HTTPException(404, "Recommendation not found")
result: dict[str, Any] = {
"recommendation": _row_to_dict(rec_row),
"evidence": [],
"trend_window": None,
}
# Fetch evidence rows with full document details including storage refs
evidence_rows = await pool.fetch(
"""SELECT re.id AS evidence_id,
re.document_id,
re.intelligence_id,
re.evidence_type,
re.weight,
d.document_type,
d.source_type,
d.publisher,
d.url,
d.canonical_url,
d.title,
d.published_at,
d.retrieved_at,
d.language,
d.content_hash,
d.raw_storage_ref,
d.normalized_storage_ref,
d.parse_quality_score,
d.parse_confidence,
d.status AS document_status
FROM recommendation_evidence re
LEFT JOIN documents d ON d.id = re.document_id
WHERE re.recommendation_id = $1
ORDER BY re.weight DESC""",
recommendation_id,
)
for ev in evidence_rows:
ev_dict = _row_to_dict(ev)
ev_dict["intelligence"] = None
ev_dict["company_impacts"] = []
# Fetch intelligence extraction for this evidence
intel_id = ev["intelligence_id"]
doc_id = ev["document_id"]
# Use the linked intelligence_id if available, otherwise look up by document_id
intel_row = None
if intel_id:
intel_row = await pool.fetchrow(
"""SELECT id, document_id, summary, macro_themes, novelty_score,
source_credibility, extraction_warnings, confidence,
model_provider, model_name, prompt_version, schema_version,
raw_output_ref, prompt_ref, validation_status,
validation_errors, created_at
FROM document_intelligence WHERE id = $1""",
intel_id,
)
elif doc_id:
intel_row = await pool.fetchrow(
"""SELECT id, document_id, summary, macro_themes, novelty_score,
source_credibility, extraction_warnings, confidence,
model_provider, model_name, prompt_version, schema_version,
raw_output_ref, prompt_ref, validation_status,
validation_errors, created_at
FROM document_intelligence WHERE document_id = $1
ORDER BY created_at DESC LIMIT 1""",
doc_id,
)
if intel_row:
intel_dict = _row_to_dict(intel_row)
for jf in ("macro_themes", "extraction_warnings", "validation_errors"):
intel_dict[jf] = _parse_jsonb(intel_dict.get(jf))
ev_dict["intelligence"] = intel_dict
# Fetch per-company impact records for this intelligence
impacts = await pool.fetch(
"""SELECT dir.company_id, dir.ticker, dir.relevance, dir.sentiment,
dir.impact_score, dir.impact_horizon, dir.catalyst_type,
dir.key_facts, dir.risks, dir.evidence_spans,
c.legal_name
FROM document_impact_records dir
JOIN companies c ON c.id = dir.company_id
WHERE dir.intelligence_id = $1""",
intel_row["id"],
)
impact_list = []
for imp in impacts:
imp_dict = _row_to_dict(imp)
for jf in ("key_facts", "risks", "evidence_spans"):
imp_dict[jf] = _parse_jsonb(imp_dict.get(jf))
impact_list.append(imp_dict)
ev_dict["company_impacts"] = impact_list
result["evidence"].append(ev_dict)
# Fetch the most recent trend window for this ticker to show market context
ticker = rec_row["ticker"]
generated_at = rec_row["generated_at"]
if ticker and generated_at:
trend_row = await pool.fetchrow(
"""SELECT id, entity_type, entity_id, window, trend_direction,
trend_strength, confidence, top_supporting_evidence,
top_opposing_evidence, dominant_catalysts, material_risks,
contradiction_score, market_context, generated_at
FROM trend_windows
WHERE entity_id = $1 AND entity_type = 'company'
AND generated_at <= $2
ORDER BY generated_at DESC LIMIT 1""",
ticker, generated_at,
)
if trend_row:
trend_dict = _row_to_dict(trend_row)
for jf in (
"top_supporting_evidence", "top_opposing_evidence",
"dominant_catalysts", "material_risks", "market_context",
):
trend_dict[jf] = _parse_jsonb(trend_dict.get(jf))
# Include trend evidence linkage: documents that contributed to this trend
trend_ev_rows = await pool.fetch(
"""SELECT te.id, te.document_id, te.evidence_type, te.rank_score,
te.weight_component, te.impact_component,
te.recency_component, te.confidence_component,
te.sentiment_value,
d.title, d.document_type, d.source_type, d.publisher,
d.url, d.published_at, d.raw_storage_ref,
d.normalized_storage_ref
FROM trend_evidence te
LEFT JOIN documents d ON d.id = te.document_id
WHERE te.trend_window_id = $1
ORDER BY te.rank_score DESC""",
trend_row["id"],
)
trend_dict["evidence"] = [_row_to_dict(te) for te in trend_ev_rows]
result["trend_window"] = trend_dict
return result
# ---------------------------------------------------------------------------
# Trend Evidence Drill-Down (Requirement 10.4)
# ---------------------------------------------------------------------------
@app.get("/api/trends/{trend_id}/evidence")
async def get_trend_evidence_drilldown(trend_id: str):
"""Drill down from a trend window to its contributing documents and raw artifacts.
Returns the trend summary plus each contributing document with storage refs,
intelligence extraction, and impact records — full provenance chain.
Requirements: 10.4, 6.5
"""
trend_row = await pool.fetchrow(
"""SELECT id, entity_type, entity_id, window, trend_direction,
trend_strength, confidence, top_supporting_evidence,
top_opposing_evidence, dominant_catalysts, material_risks,
contradiction_score, market_context, generated_at
FROM trend_windows WHERE id = $1""",
trend_id,
)
if not trend_row:
raise HTTPException(404, "Trend not found")
trend_dict = _row_to_dict(trend_row)
for jf in (
"top_supporting_evidence", "top_opposing_evidence",
"dominant_catalysts", "material_risks", "market_context",
):
trend_dict[jf] = _parse_jsonb(trend_dict.get(jf))
# Fetch trend evidence with full document details
evidence_rows = await pool.fetch(
"""SELECT te.id AS evidence_id,
te.document_id,
te.evidence_type,
te.rank_score,
te.weight_component,
te.impact_component,
te.recency_component,
te.confidence_component,
te.sentiment_value,
d.document_type,
d.source_type,
d.publisher,
d.url,
d.canonical_url,
d.title,
d.published_at,
d.retrieved_at,
d.content_hash,
d.raw_storage_ref,
d.normalized_storage_ref,
d.parse_quality_score,
d.parse_confidence,
d.status AS document_status
FROM trend_evidence te
LEFT JOIN documents d ON d.id = te.document_id
WHERE te.trend_window_id = $1
ORDER BY te.rank_score DESC""",
trend_id,
)
evidence_list = []
for ev in evidence_rows:
ev_dict = _row_to_dict(ev)
ev_dict["intelligence"] = None
ev_dict["company_impacts"] = []
doc_id = ev["document_id"]
if doc_id:
intel_row = await pool.fetchrow(
"""SELECT id, document_id, summary, macro_themes, novelty_score,
source_credibility, extraction_warnings, confidence,
model_provider, model_name, prompt_version, schema_version,
raw_output_ref, prompt_ref, validation_status,
validation_errors, created_at
FROM document_intelligence WHERE document_id = $1
ORDER BY created_at DESC LIMIT 1""",
doc_id,
)
if intel_row:
intel_dict = _row_to_dict(intel_row)
for jf in ("macro_themes", "extraction_warnings", "validation_errors"):
intel_dict[jf] = _parse_jsonb(intel_dict.get(jf))
ev_dict["intelligence"] = intel_dict
impacts = await pool.fetch(
"""SELECT dir.company_id, dir.ticker, dir.relevance, dir.sentiment,
dir.impact_score, dir.impact_horizon, dir.catalyst_type,
dir.key_facts, dir.risks, dir.evidence_spans,
c.legal_name
FROM document_impact_records dir
JOIN companies c ON c.id = dir.company_id
WHERE dir.intelligence_id = $1""",
intel_row["id"],
)
for imp in impacts:
imp_dict = _row_to_dict(imp)
for jf in ("key_facts", "risks", "evidence_spans"):
imp_dict[jf] = _parse_jsonb(imp_dict.get(jf))
ev_dict["company_impacts"].append(imp_dict)
evidence_list.append(ev_dict)
return {
"trend": trend_dict,
"evidence": evidence_list,
}
# ---------------------------------------------------------------------------
# Order History (Requirement 11.1, 11.3)
# ---------------------------------------------------------------------------
@app.get("/api/orders")
async def list_orders(
ticker: Optional[str] = None,
status: Optional[str] = None,
side: Optional[str] = None,
since: Optional[str] = None,
limit: int = Query(default=50, le=200),
offset: int = 0,
):
"""List orders with optional filters."""
conditions: list[str] = []
params: list[Any] = []
idx = 1
if ticker:
conditions.append(f"o.ticker = ${idx}")
params.append(ticker.upper())
idx += 1
if status:
conditions.append(f"o.status = ${idx}")
params.append(status)
idx += 1
if side:
conditions.append(f"o.side = ${idx}")
params.append(side)
idx += 1
if since:
conditions.append(f"o.created_at >= ${idx}::timestamptz")
params.append(since)
idx += 1
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows = await pool.fetch(
f"""SELECT o.id, o.recommendation_id, o.broker_account_id, o.ticker,
o.side, o.order_type, o.quantity, o.limit_price, o.stop_price,
o.status, o.broker_order_id, o.submitted_at, o.acknowledged_at,
o.filled_at, o.cancelled_at, o.rejected_at, o.rejection_reason,
o.fill_price, o.fill_quantity, o.created_at
FROM orders o
{where}
ORDER BY o.created_at DESC
LIMIT ${idx} OFFSET ${idx + 1}""",
*params, limit, offset,
)
return [_row_to_dict(r) for r in rows]
@app.get("/api/orders/{order_id}")
async def get_order(order_id: str):
"""Get a single order with its events, decision trace, and full audit trail.
Requirement 11.3: expose full audit trail from ingestion through broker
execution and eventual market outcome.
"""
row = await pool.fetchrow(
"""SELECT o.id, o.recommendation_id, o.broker_account_id, o.ticker,
o.side, o.order_type, o.quantity, o.limit_price, o.stop_price,
o.status, o.idempotency_key, o.broker_order_id,
o.decision_trace, o.submitted_at, o.acknowledged_at,
o.filled_at, o.cancelled_at, o.rejected_at, o.rejection_reason,
o.fill_price, o.fill_quantity, o.created_at, o.updated_at
FROM orders o WHERE o.id = $1""",
order_id,
)
if not row:
raise HTTPException(404, "Order not found")
result = _row_to_dict(row)
result["decision_trace"] = _parse_jsonb(result.get("decision_trace"))
# Order events
events = await pool.fetch(
"""SELECT id, event_type, data, broker_timestamp, created_at
FROM order_events WHERE order_id = $1 ORDER BY created_at ASC""",
order_id,
)
result["events"] = []
for ev in events:
ev_dict = _row_to_dict(ev)
ev_dict["data"] = _parse_jsonb(ev_dict.get("data"))
result["events"].append(ev_dict)
# Full audit trail (Requirement 11.3)
recommendation_id = str(row["recommendation_id"]) if row["recommendation_id"] else None
result["audit_trail"] = await get_order_audit_trail(pool, order_id, recommendation_id)
return result
# ---------------------------------------------------------------------------
# Positions (Requirement 11.1)
# ---------------------------------------------------------------------------
@app.get("/api/positions")
async def list_positions(
ticker: Optional[str] = None,
):
"""List current positions."""
if ticker:
rows = await pool.fetch(
"""SELECT p.id, p.broker_account_id, p.ticker, p.quantity,
p.avg_entry_price, p.current_price,
p.unrealized_pnl, p.realized_pnl, p.updated_at
FROM positions p WHERE p.ticker = $1 ORDER BY p.ticker""",
ticker.upper(),
)
else:
rows = await pool.fetch(
"""SELECT p.id, p.broker_account_id, p.ticker, p.quantity,
p.avg_entry_price, p.current_price,
p.unrealized_pnl, p.realized_pnl, p.updated_at
FROM positions p ORDER BY p.ticker""",
)
return [_row_to_dict(r) for r in rows]
# ---------------------------------------------------------------------------
# Audit Trail (Requirement 11.3)
# ---------------------------------------------------------------------------
@app.get("/api/audit/{entity_type}/{entity_id}")
async def get_audit_trail(entity_type: str, entity_id: str):
"""Get audit events for any entity type and ID."""
events = await get_entity_audit_trail(pool, entity_type, entity_id)
if not events:
raise HTTPException(404, "No audit events found")
return events
# ---------------------------------------------------------------------------
# Admin: Source Health (Requirement 11.1 - source health)
# ---------------------------------------------------------------------------
@app.get("/api/admin/sources/health")
async def get_source_health(
source_type: Optional[str] = None,
company_id: Optional[str] = None,
active_only: bool = True,
):
"""Source health overview: each source with its latest ingestion status and failure counts.
Design: Section 9.1 (source health and job state)
"""
conditions = []
params: list[Any] = []
idx = 1
if active_only:
conditions.append(f"s.active = ${idx}")
params.append(True)
idx += 1
if source_type:
conditions.append(f"s.source_type = ${idx}")
params.append(source_type)
idx += 1
if company_id:
conditions.append(f"s.company_id = ${idx}")
params.append(company_id)
idx += 1
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
rows = await pool.fetch(
f"""SELECT s.id AS source_id, s.source_type, s.source_name,
s.credibility_score, s.active,
c.ticker, c.legal_name, c.id AS company_id,
latest.status AS last_run_status,
latest.started_at AS last_run_at,
latest.error_message AS last_error,
latest.items_fetched AS last_items_fetched,
latest.items_new AS last_items_new,
COALESCE(stats.total_runs, 0) AS total_runs_24h,
COALESCE(stats.failed_runs, 0) AS failed_runs_24h,
COALESCE(stats.total_items, 0) AS total_items_24h
FROM sources s
JOIN companies c ON c.id = s.company_id
LEFT JOIN LATERAL (
SELECT ir.status, ir.started_at, ir.error_message,
ir.items_fetched, ir.items_new
FROM ingestion_runs ir
WHERE ir.source_id = s.id
ORDER BY ir.started_at DESC
LIMIT 1
) latest ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(*) AS total_runs,
COUNT(*) FILTER (WHERE ir2.status = 'failed') AS failed_runs,
COALESCE(SUM(ir2.items_fetched), 0) AS total_items
FROM ingestion_runs ir2
WHERE ir2.source_id = s.id
AND ir2.started_at >= NOW() - INTERVAL '24 hours'
) stats ON TRUE
{where}
ORDER BY c.ticker, s.source_type""",
*params,
)
return [_row_to_dict(r) for r in rows]
@app.get("/api/admin/sources/{source_id}/runs")
async def get_source_runs(
source_id: str,
limit: int = Query(default=20, le=100),
offset: int = 0,
):
"""Recent ingestion runs for a specific source."""
rows = await pool.fetch(
"""SELECT id, source_id, company_id, source_type, status,
started_at, completed_at, items_fetched, items_new,
error_message, retry_count, next_retry_at
FROM ingestion_runs
WHERE source_id = $1
ORDER BY started_at DESC
LIMIT $2 OFFSET $3""",
source_id, limit, offset,
)
return [_row_to_dict(r) for r in rows]
@app.put("/api/admin/sources/{source_id}/toggle")
async def toggle_source(source_id: str, active: bool = True):
"""Enable or disable a source."""
row = await pool.fetchrow(
"""UPDATE sources SET active = $2, updated_at = NOW()
WHERE id = $1
RETURNING id, source_type, source_name, active""",
source_id, active,
)
if not row:
raise HTTPException(404, "Source not found")
return _row_to_dict(row)
@app.put("/api/admin/sources/{source_id}/credibility")
async def update_source_credibility(source_id: str, credibility_score: float = Query(ge=0.0, le=1.0)):
"""Update a source's credibility score."""
row = await pool.fetchrow(
"""UPDATE sources SET credibility_score = $2, updated_at = NOW()
WHERE id = $1
RETURNING id, source_type, source_name, credibility_score""",
source_id, credibility_score,
)
if not row:
raise HTTPException(404, "Source not found")
return _row_to_dict(row)
# ---------------------------------------------------------------------------
# Admin: Symbol Configs (Requirement 11.1 - symbol configs)
# ---------------------------------------------------------------------------
@app.put("/api/admin/companies/{company_id}/toggle")
async def toggle_company(company_id: str, active: bool = True):
"""Enable or disable a tracked company."""
row = await pool.fetchrow(
"""UPDATE companies SET active = $2, updated_at = NOW()
WHERE id = $1
RETURNING id, ticker, legal_name, active""",
company_id, active,
)
if not row:
raise HTTPException(404, "Company not found")
return _row_to_dict(row)
@app.put("/api/admin/companies/{company_id}/sector")
async def update_company_sector(
company_id: str,
sector: str = Query(...),
industry: Optional[str] = None,
):
"""Update a company's sector and industry classification."""
if industry is not None:
row = await pool.fetchrow(
"""UPDATE companies SET sector = $2, industry = $3, updated_at = NOW()
WHERE id = $1
RETURNING id, ticker, legal_name, sector, industry""",
company_id, sector, industry,
)
else:
row = await pool.fetchrow(
"""UPDATE companies SET sector = $2, updated_at = NOW()
WHERE id = $1
RETURNING id, ticker, legal_name, sector, industry""",
company_id, sector,
)
if not row:
raise HTTPException(404, "Company not found")
return _row_to_dict(row)
@app.get("/api/admin/companies/coverage")
async def get_symbol_coverage():
"""Overview of source coverage per active company.
Shows how many active sources of each type are configured per symbol,
useful for identifying coverage gaps.
"""
rows = await pool.fetch(
"""SELECT c.id AS company_id, c.ticker, c.legal_name, c.sector,
c.active,
COUNT(s.id) FILTER (WHERE s.active) AS active_sources,
COUNT(s.id) FILTER (WHERE s.source_type = 'market_api' AND s.active) AS market_sources,
COUNT(s.id) FILTER (WHERE s.source_type = 'news_api' AND s.active) AS news_sources,
COUNT(s.id) FILTER (WHERE s.source_type = 'filings_api' AND s.active) AS filings_sources,
COUNT(s.id) FILTER (WHERE s.source_type = 'web_scrape' AND s.active) AS web_scrape_sources,
COUNT(s.id) FILTER (WHERE s.source_type = 'broker' AND s.active) AS broker_sources
FROM companies c
LEFT JOIN sources s ON s.company_id = c.id
WHERE c.active = TRUE
GROUP BY c.id, c.ticker, c.legal_name, c.sector, c.active
ORDER BY c.ticker""",
)
return [_row_to_dict(r) for r in rows]
# ---------------------------------------------------------------------------
# Admin: Trading Mode (Requirement 8.1, 8.2, 11.1)
# ---------------------------------------------------------------------------
@app.get("/api/admin/trading/config")
async def get_trading_config():
"""Get the current active risk/trading configuration."""
row = await pool.fetchrow(
"""SELECT id, name, trading_mode, config, active, created_at, updated_at
FROM risk_configs
WHERE active = TRUE
ORDER BY updated_at DESC
LIMIT 1""",
)
if not row:
return {"trading_mode": "paper", "config": {}, "message": "No active config found, using defaults"}
result = _row_to_dict(row)
result["config"] = _parse_jsonb(result.get("config"))
return result
@app.put("/api/admin/trading/mode")
async def set_trading_mode(mode: str = Query(..., pattern="^(paper|live|disabled)$")):
"""Switch the active trading mode.
Requirement 8.1: support paper and live as separate execution environments.
Requirement 8.2: live mode requires operator approval controls.
"""
row = await pool.fetchrow(
"""UPDATE risk_configs SET trading_mode = $1, updated_at = NOW()
WHERE active = TRUE
RETURNING id, name, trading_mode""",
mode,
)
if not row:
# No active config exists yet — create one with the requested mode
row = await pool.fetchrow(
"""INSERT INTO risk_configs (name, trading_mode, config, active)
VALUES ('default', $1, '{}', TRUE)
RETURNING id, name, trading_mode""",
mode,
)
return _row_to_dict(row)
@app.put("/api/admin/trading/config")
async def update_trading_config(config: dict[str, Any]):
"""Update the active risk configuration JSON.
Accepts a partial or full risk config object. The config is stored
as JSONB alongside the trading_mode in risk_configs.
"""
config_json = json.dumps(config)
row = await pool.fetchrow(
"""UPDATE risk_configs SET config = $1::jsonb, updated_at = NOW()
WHERE active = TRUE
RETURNING id, name, trading_mode, config""",
config_json,
)
if not row:
row = await pool.fetchrow(
"""INSERT INTO risk_configs (name, trading_mode, config, active)
VALUES ('default', 'paper', $1::jsonb, TRUE)
RETURNING id, name, trading_mode, config""",
config_json,
)
result = _row_to_dict(row)
result["config"] = _parse_jsonb(result.get("config"))
return result
@app.get("/api/admin/trading/approvals")
async def list_pending_approvals():
"""List pending operator approval requests for live trading orders."""
rows = await pool.fetch(
"""SELECT id, order_job, recommendation_id, ticker, side, quantity,
estimated_value, status, risk_evaluation_id, requested_by,
reviewed_by, review_note, expires_at, requested_at, reviewed_at
FROM operator_approvals
WHERE status = 'pending'
ORDER BY requested_at ASC""",
)
results = []
for r in rows:
d = _row_to_dict(r)
d["order_job"] = _parse_jsonb(d.get("order_job"))
results.append(d)
return results
@app.put("/api/admin/trading/approvals/{approval_id}")
async def review_approval_request(
approval_id: str,
approved: bool = Query(...),
reviewed_by: str = "operator",
review_note: str = "",
):
"""Approve or reject a pending operator approval request.
Requirement 8.2: live orders require operator approval controls.
"""
now = datetime.now(timezone.utc)
new_status = "approved" if approved else "rejected"
row = await pool.fetchrow(
"""UPDATE operator_approvals
SET status = $2, reviewed_by = $3, review_note = $4,
reviewed_at = $5, updated_at = NOW()
WHERE id = $1::uuid AND status = 'pending'
RETURNING id, ticker, status, reviewed_by""",
approval_id, new_status, reviewed_by, review_note, now,
)
if not row:
raise HTTPException(404, "Approval not found or no longer pending")
return _row_to_dict(row)
@app.get("/api/admin/trading/lockouts")
async def list_active_lockouts():
"""List active symbol lockouts (news-shock, cooldown)."""
rows = await pool.fetch(
"""SELECT id, ticker, lockout_type, reason, expires_at, created_at
FROM symbol_lockouts
WHERE expires_at > NOW()
ORDER BY expires_at ASC""",
)
return [_row_to_dict(r) for r in rows]
# ---------------------------------------------------------------------------
# Operational Dashboard (Requirement 12.1, 12.2, 12.3)
# ---------------------------------------------------------------------------
@app.get("/api/ops/ingestion/throughput")
async def get_ingestion_throughput(
hours: int = Query(default=24, ge=1, le=168),
bucket: str = Query(default="1h", pattern="^(15m|1h|6h|1d)$"),
):
"""Ingestion throughput over time, bucketed by interval.
Returns document counts and item counts per time bucket, broken down
by source type. Powers the ingestion throughput chart.
Requirements: 12.1, 12.3
"""
bucket_interval = {
"15m": "15 minutes",
"1h": "1 hour",
"6h": "6 hours",
"1d": "1 day",
}[bucket]
rows = await pool.fetch(
f"""SELECT
date_trunc('hour', ir.started_at)
- (EXTRACT(minute FROM ir.started_at)::int
% EXTRACT(epoch FROM INTERVAL '{bucket_interval}')::int / 60)
* INTERVAL '1 minute' AS bucket_start,
ir.source_type,
COUNT(*) AS run_count,
COUNT(*) FILTER (WHERE ir.status = 'completed') AS completed,
COUNT(*) FILTER (WHERE ir.status = 'failed') AS failed,
COALESCE(SUM(ir.items_fetched), 0) AS items_fetched,
COALESCE(SUM(ir.items_new), 0) AS items_new
FROM ingestion_runs ir
WHERE ir.started_at >= NOW() - INTERVAL '1 hour' * $1
GROUP BY bucket_start, ir.source_type
ORDER BY bucket_start DESC, ir.source_type""",
hours,
)
return [_row_to_dict(r) for r in rows]
@app.get("/api/ops/ingestion/summary")
async def get_ingestion_summary(
hours: int = Query(default=24, ge=1, le=168),
):
"""High-level ingestion summary for the operational dashboard.
Returns total runs, success/failure counts, items processed, and
per-source-type breakdown for the given time window.
Requirements: 12.1
"""
row = await pool.fetchrow(
"""SELECT
COUNT(*) AS total_runs,
COUNT(*) FILTER (WHERE status = 'completed') AS completed,
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
COUNT(*) FILTER (WHERE status = 'running') AS running,
COALESCE(SUM(items_fetched), 0) AS total_items_fetched,
COALESCE(SUM(items_new), 0) AS total_items_new,
COUNT(DISTINCT source_id) AS active_sources,
COUNT(DISTINCT company_id) AS active_companies
FROM ingestion_runs
WHERE started_at >= NOW() - INTERVAL '1 hour' * $1""",
hours,
)
by_type = await pool.fetch(
"""SELECT
source_type,
COUNT(*) AS runs,
COUNT(*) FILTER (WHERE status = 'completed') AS completed,
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
COALESCE(SUM(items_fetched), 0) AS items_fetched,
COALESCE(SUM(items_new), 0) AS items_new
FROM ingestion_runs
WHERE started_at >= NOW() - INTERVAL '1 hour' * $1
GROUP BY source_type
ORDER BY runs DESC""",
hours,
)
result = _row_to_dict(row) if row else {}
result["by_source_type"] = [_row_to_dict(r) for r in by_type]
result["hours"] = hours
return result
@app.get("/api/ops/model/failures")
async def get_model_failures(
hours: int = Query(default=24, ge=1, le=168),
limit: int = Query(default=50, le=200),
):
"""Recent model extraction failures with error details.
Returns individual failed extraction attempts for debugging.
Requirements: 12.2
"""
rows = await pool.fetch(
"""SELECT
mpm.id, mpm.document_id, mpm.ticker, mpm.model_name,
mpm.prompt_version, mpm.schema_version,
mpm.attempt_count, mpm.total_duration_ms,
mpm.validation_status, mpm.validation_error_count,
mpm.validation_errors, mpm.retry_count,
mpm.confidence, mpm.recorded_at,
d.title AS document_title, d.document_type, d.source_type
FROM model_performance_metrics mpm
LEFT JOIN documents d ON d.id = mpm.document_id
WHERE mpm.success = FALSE
AND mpm.recorded_at >= NOW() - INTERVAL '1 hour' * $1
ORDER BY mpm.recorded_at DESC
LIMIT $2""",
hours, limit,
)
results = []
for r in rows:
d = _row_to_dict(r)
d["validation_errors"] = _parse_jsonb(d.get("validation_errors"))
results.append(d)
return results
@app.get("/api/ops/model/performance")
async def get_model_performance(
hours: int = Query(default=24, ge=1, le=168),
model_name: Optional[str] = None,
):
"""Aggregated model performance metrics for the operational dashboard.
Returns success rate, latency percentiles, retry rate, confidence
distribution, and token usage for the given time window.
Requirements: 12.2
"""
return await get_model_performance_summary(
pool,
model_name=model_name,
hours=hours,
)
@app.get("/api/ops/pipeline/health")
async def get_pipeline_health(
hours: int = Query(default=24, ge=1, le=168),
):
"""Pipeline stage health summary across ingestion, parsing, extraction, and aggregation.
Shows document counts at each processing stage and identifies bottlenecks.
Requirements: 12.1
"""
# Document status distribution (pipeline stages)
doc_stages = await pool.fetch(
"""SELECT
status,
COUNT(*) AS doc_count
FROM documents
WHERE created_at >= NOW() - INTERVAL '1 hour' * $1
GROUP BY status
ORDER BY doc_count DESC""",
hours,
)
# Parsing quality distribution
parse_quality = await pool.fetchrow(
"""SELECT
COUNT(*) AS total_parsed,
COUNT(*) FILTER (WHERE parse_confidence = 'high') AS high_confidence,
COUNT(*) FILTER (WHERE parse_confidence = 'medium') AS medium_confidence,
COUNT(*) FILTER (WHERE parse_confidence = 'low') AS low_confidence,
COUNT(*) FILTER (WHERE parse_confidence = 'unknown' OR parse_confidence IS NULL) AS unknown_confidence,
ROUND(AVG(parse_quality_score)::numeric, 3) AS avg_quality_score
FROM documents
WHERE created_at >= NOW() - INTERVAL '1 hour' * $1
AND status IN ('parsed', 'extracted', 'aggregated')""",
hours,
)
# Extraction validation distribution
extraction_stats = await pool.fetchrow(
"""SELECT
COUNT(*) AS total_extractions,
COUNT(*) FILTER (WHERE validation_status = 'valid') AS valid,
COUNT(*) FILTER (WHERE validation_status = 'failed') AS failed,
COUNT(*) FILTER (WHERE validation_status = 'pending') AS pending,
ROUND(AVG(confidence)::numeric, 3) AS avg_confidence,
ROUND(AVG(retry_count)::numeric, 2) AS avg_retries
FROM document_intelligence
WHERE created_at >= NOW() - INTERVAL '1 hour' * $1""",
hours,
)
# Aggregation output (trend windows generated)
trend_stats = await pool.fetchrow(
"""SELECT
COUNT(*) AS trends_generated,
COUNT(DISTINCT entity_id) AS symbols_covered,
ROUND(AVG(confidence)::numeric, 3) AS avg_trend_confidence,
ROUND(AVG(contradiction_score)::numeric, 3) AS avg_contradiction
FROM trend_windows
WHERE created_at >= NOW() - INTERVAL '1 hour' * $1""",
hours,
)
return {
"hours": hours,
"document_stages": [_row_to_dict(r) for r in doc_stages],
"parsing": _row_to_dict(parse_quality) if parse_quality else {},
"extraction": _row_to_dict(extraction_stats) if extraction_stats else {},
"aggregation": _row_to_dict(trend_stats) if trend_stats else {},
}
@app.get("/api/ops/sources/coverage-gaps")
async def get_source_coverage_gaps():
"""Identify symbols with missing or insufficient source coverage.
Returns companies that lack one or more expected source types
(market_api, news_api, filings_api), or have sources that haven't
produced successful ingestion runs recently.
Requirements: 12.3
"""
# Companies missing expected source types
missing_types = await pool.fetch(
"""SELECT
c.id AS company_id, c.ticker, c.legal_name, c.sector,
ARRAY_AGG(DISTINCT s.source_type) FILTER (WHERE s.active) AS active_types,
ARRAY['market_api', 'news_api', 'filings_api'] AS expected_types
FROM companies c
LEFT JOIN sources s ON s.company_id = c.id AND s.active = TRUE
WHERE c.active = TRUE
GROUP BY c.id, c.ticker, c.legal_name, c.sector
HAVING NOT ARRAY['market_api', 'news_api', 'filings_api'] <@ ARRAY_AGG(DISTINCT s.source_type) FILTER (WHERE s.active)
OR ARRAY_AGG(DISTINCT s.source_type) FILTER (WHERE s.active) IS NULL
ORDER BY c.ticker""",
)
# Sources with no successful runs in the last 24 hours
stale_sources = await pool.fetch(
"""SELECT
s.id AS source_id, s.source_type, s.source_name,
c.ticker, c.legal_name,
MAX(ir.started_at) FILTER (WHERE ir.status = 'completed') AS last_success,
MAX(ir.started_at) AS last_attempt,
COUNT(*) FILTER (WHERE ir.status = 'failed'
AND ir.started_at >= NOW() - INTERVAL '24 hours') AS recent_failures
FROM sources s
JOIN companies c ON c.id = s.company_id
LEFT JOIN ingestion_runs ir ON ir.source_id = s.id
WHERE s.active = TRUE AND c.active = TRUE
GROUP BY s.id, s.source_type, s.source_name, c.ticker, c.legal_name
HAVING MAX(ir.started_at) FILTER (WHERE ir.status = 'completed')
< NOW() - INTERVAL '24 hours'
OR MAX(ir.started_at) FILTER (WHERE ir.status = 'completed') IS NULL
ORDER BY c.ticker, s.source_type""",
)
return {
"missing_source_types": [_row_to_dict(r) for r in missing_types],
"stale_sources": [_row_to_dict(r) for r in stale_sources],
}