first commit
This commit is contained in:
1
app/routers/__init__.py
Normal file
1
app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
193
app/routers/admin.py
Normal file
193
app/routers/admin.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Admin router: user management, system settings, data cleanup, job control.
|
||||
|
||||
All endpoints require admin role.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_admin
|
||||
from app.models.user import User
|
||||
from app.schemas.admin import (
|
||||
CreateUserRequest,
|
||||
DataCleanupRequest,
|
||||
JobToggle,
|
||||
PasswordReset,
|
||||
RegistrationToggle,
|
||||
SystemSettingUpdate,
|
||||
UserManagement,
|
||||
)
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.services import admin_service
|
||||
|
||||
router = APIRouter(tags=["admin"])
|
||||
|
||||
|
||||
def _user_dict(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"has_access": user.has_access,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/admin/users", response_model=APIEnvelope)
|
||||
async def list_users(
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all user accounts."""
|
||||
users = await admin_service.list_users(db)
|
||||
return APIEnvelope(status="success", data=[_user_dict(u) for u in users])
|
||||
|
||||
|
||||
@router.post("/admin/users", response_model=APIEnvelope, status_code=201)
|
||||
async def create_user(
|
||||
body: CreateUserRequest,
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new user account."""
|
||||
user = await admin_service.create_user(
|
||||
db, body.username, body.password, body.role, body.has_access
|
||||
)
|
||||
return APIEnvelope(status="success", data=_user_dict(user))
|
||||
|
||||
|
||||
@router.put("/admin/users/{user_id}/access", response_model=APIEnvelope)
|
||||
async def set_user_access(
|
||||
user_id: int,
|
||||
body: UserManagement,
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Grant or revoke API access for a user."""
|
||||
user = await admin_service.set_user_access(db, user_id, body.has_access)
|
||||
return APIEnvelope(status="success", data=_user_dict(user))
|
||||
|
||||
|
||||
@router.put("/admin/users/{user_id}/password", response_model=APIEnvelope)
|
||||
async def reset_password(
|
||||
user_id: int,
|
||||
body: PasswordReset,
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reset a user's password."""
|
||||
user = await admin_service.reset_password(db, user_id, body.new_password)
|
||||
return APIEnvelope(status="success", data=_user_dict(user))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration toggle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.put("/admin/settings/registration", response_model=APIEnvelope)
|
||||
async def toggle_registration(
|
||||
body: RegistrationToggle,
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Enable or disable user registration."""
|
||||
setting = await admin_service.toggle_registration(db, body.enabled)
|
||||
return APIEnvelope(
|
||||
status="success",
|
||||
data={"key": setting.key, "value": setting.value},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/admin/settings", response_model=APIEnvelope)
|
||||
async def list_settings(
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all system settings."""
|
||||
settings_list = await admin_service.list_settings(db)
|
||||
return APIEnvelope(
|
||||
status="success",
|
||||
data=[
|
||||
{"key": s.key, "value": s.value, "updated_at": s.updated_at.isoformat() if s.updated_at else None}
|
||||
for s in settings_list
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/admin/settings/{key}", response_model=APIEnvelope)
|
||||
async def update_setting(
|
||||
key: str,
|
||||
body: SystemSettingUpdate,
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create or update a system setting."""
|
||||
setting = await admin_service.update_setting(db, key, body.value)
|
||||
return APIEnvelope(
|
||||
status="success",
|
||||
data={"key": setting.key, "value": setting.value, "updated_at": setting.updated_at.isoformat() if setting.updated_at else None},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/admin/data/cleanup", response_model=APIEnvelope)
|
||||
async def cleanup_data(
|
||||
body: DataCleanupRequest,
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete OHLCV, sentiment, and fundamental data older than N days."""
|
||||
counts = await admin_service.cleanup_data(db, body.older_than_days)
|
||||
return APIEnvelope(status="success", data=counts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Job control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/admin/jobs", response_model=APIEnvelope)
|
||||
async def list_jobs(
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all scheduled jobs with their current status."""
|
||||
jobs = await admin_service.list_jobs(db)
|
||||
return APIEnvelope(status="success", data=jobs)
|
||||
|
||||
|
||||
@router.post("/admin/jobs/{job_name}/trigger", response_model=APIEnvelope)
|
||||
async def trigger_job(
|
||||
job_name: str,
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Trigger a manual job run (placeholder)."""
|
||||
result = await admin_service.trigger_job(db, job_name)
|
||||
return APIEnvelope(status="success", data=result)
|
||||
|
||||
|
||||
@router.put("/admin/jobs/{job_name}/toggle", response_model=APIEnvelope)
|
||||
async def toggle_job(
|
||||
job_name: str,
|
||||
body: JobToggle,
|
||||
_admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Enable or disable a scheduled job (placeholder)."""
|
||||
setting = await admin_service.toggle_job(db, job_name, body.enabled)
|
||||
return APIEnvelope(
|
||||
status="success",
|
||||
data={"key": setting.key, "value": setting.value},
|
||||
)
|
||||
34
app/routers/auth.py
Normal file
34
app/routers/auth.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Auth router: registration and login endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.services import auth_service
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/auth/register", response_model=APIEnvelope)
|
||||
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Public endpoint — register a new user."""
|
||||
user = await auth_service.register(db, body.username, body.password)
|
||||
return APIEnvelope(
|
||||
status="success",
|
||||
data={
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"has_access": user.has_access,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=APIEnvelope)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Public endpoint — login and receive a JWT."""
|
||||
token = await auth_service.login(db, body.username, body.password)
|
||||
token_resp = TokenResponse(access_token=token)
|
||||
return APIEnvelope(status="success", data=token_resp.model_dump())
|
||||
35
app/routers/fundamentals.py
Normal file
35
app/routers/fundamentals.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Fundamentals router — fundamental data endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.schemas.fundamental import FundamentalResponse
|
||||
from app.services.fundamental_service import get_fundamental
|
||||
|
||||
router = APIRouter(tags=["fundamentals"])
|
||||
|
||||
|
||||
@router.get("/fundamentals/{symbol}", response_model=APIEnvelope)
|
||||
async def read_fundamentals(
|
||||
symbol: str,
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Get latest fundamental data for a symbol."""
|
||||
record = await get_fundamental(db, symbol)
|
||||
|
||||
if record is None:
|
||||
data = FundamentalResponse(symbol=symbol.strip().upper())
|
||||
else:
|
||||
data = FundamentalResponse(
|
||||
symbol=symbol.strip().upper(),
|
||||
pe_ratio=record.pe_ratio,
|
||||
revenue_growth=record.revenue_growth,
|
||||
earnings_surprise=record.earnings_surprise,
|
||||
market_cap=record.market_cap,
|
||||
fetched_at=record.fetched_at,
|
||||
)
|
||||
|
||||
return APIEnvelope(status="success", data=data.model_dump())
|
||||
36
app/routers/health.py
Normal file
36
app/routers/health.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Health check endpoint — unauthenticated."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db
|
||||
from app.schemas.common import APIEnvelope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check(db: AsyncSession = Depends(get_db)) -> APIEnvelope:
|
||||
"""Return service health including database connectivity."""
|
||||
try:
|
||||
await db.execute(text("SELECT 1"))
|
||||
return APIEnvelope(
|
||||
status="success",
|
||||
data={"status": "healthy", "database": "connected"},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Health check: database unreachable")
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"status": "error",
|
||||
"data": None,
|
||||
"error": "Database unreachable",
|
||||
},
|
||||
)
|
||||
64
app/routers/indicators.py
Normal file
64
app/routers/indicators.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Indicators router — technical analysis endpoints."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.schemas.indicator import (
|
||||
EMACrossResponse,
|
||||
EMACrossResult,
|
||||
IndicatorResponse,
|
||||
IndicatorResult,
|
||||
)
|
||||
from app.services.indicator_service import get_ema_cross, get_indicator
|
||||
|
||||
router = APIRouter(tags=["indicators"])
|
||||
|
||||
|
||||
# NOTE: ema-cross must be registered BEFORE {indicator_type} to avoid
|
||||
# FastAPI matching "ema-cross" as an indicator_type path parameter.
|
||||
|
||||
|
||||
@router.get("/indicators/{symbol}/ema-cross", response_model=APIEnvelope)
|
||||
async def read_ema_cross(
|
||||
symbol: str,
|
||||
start_date: date | None = Query(None),
|
||||
end_date: date | None = Query(None),
|
||||
short_period: int = Query(20),
|
||||
long_period: int = Query(50),
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Compute EMA cross signal for a symbol."""
|
||||
result = await get_ema_cross(
|
||||
db, symbol, start_date, end_date, short_period, long_period
|
||||
)
|
||||
data = EMACrossResponse(
|
||||
symbol=symbol.upper(),
|
||||
ema_cross=EMACrossResult(**result),
|
||||
)
|
||||
return APIEnvelope(status="success", data=data.model_dump())
|
||||
|
||||
|
||||
@router.get("/indicators/{symbol}/{indicator_type}", response_model=APIEnvelope)
|
||||
async def read_indicator(
|
||||
symbol: str,
|
||||
indicator_type: str,
|
||||
start_date: date | None = Query(None),
|
||||
end_date: date | None = Query(None),
|
||||
period: int | None = Query(None),
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Compute a technical indicator for a symbol."""
|
||||
result = await get_indicator(
|
||||
db, symbol, indicator_type, start_date, end_date, period
|
||||
)
|
||||
data = IndicatorResponse(
|
||||
symbol=symbol.upper(),
|
||||
indicator=IndicatorResult(**result),
|
||||
)
|
||||
return APIEnvelope(status="success", data=data.model_dump())
|
||||
127
app/routers/ingestion.py
Normal file
127
app/routers/ingestion.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Ingestion router: trigger data fetches from the market data provider.
|
||||
|
||||
Provides both a single-source OHLCV endpoint and a comprehensive
|
||||
fetch-all endpoint that collects OHLCV + sentiment + fundamentals
|
||||
in one call with per-source status reporting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.exceptions import ProviderError
|
||||
from app.models.user import User
|
||||
from app.providers.alpaca import AlpacaOHLCVProvider
|
||||
from app.providers.fmp import FMPFundamentalProvider
|
||||
from app.providers.gemini_sentiment import GeminiSentimentProvider
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.services import fundamental_service, ingestion_service, sentiment_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["ingestion"])
|
||||
|
||||
|
||||
def _get_provider() -> AlpacaOHLCVProvider:
|
||||
"""Build the OHLCV provider from current settings."""
|
||||
if not settings.alpaca_api_key or not settings.alpaca_api_secret:
|
||||
raise ProviderError("Alpaca API credentials not configured")
|
||||
return AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret)
|
||||
|
||||
|
||||
@router.post("/ingestion/fetch/{symbol}", response_model=APIEnvelope)
|
||||
async def fetch_symbol(
|
||||
symbol: str,
|
||||
start_date: date | None = Query(None, description="Start date (YYYY-MM-DD)"),
|
||||
end_date: date | None = Query(None, description="End date (YYYY-MM-DD)"),
|
||||
_user: User = Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Fetch all data sources for a ticker: OHLCV, sentiment, and fundamentals.
|
||||
|
||||
Returns a per-source breakdown so the frontend can show exactly what
|
||||
succeeded and what failed.
|
||||
"""
|
||||
symbol_upper = symbol.strip().upper()
|
||||
sources: dict[str, dict] = {}
|
||||
|
||||
# --- OHLCV ---
|
||||
try:
|
||||
provider = _get_provider()
|
||||
result = await ingestion_service.fetch_and_ingest(
|
||||
db, provider, symbol_upper, start_date, end_date
|
||||
)
|
||||
sources["ohlcv"] = {
|
||||
"status": "ok" if result.status in ("complete", "partial") else "error",
|
||||
"records": result.records_ingested,
|
||||
"message": result.message,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error("OHLCV fetch failed for %s: %s", symbol_upper, exc)
|
||||
sources["ohlcv"] = {"status": "error", "records": 0, "message": str(exc)}
|
||||
|
||||
# --- Sentiment ---
|
||||
if settings.gemini_api_key:
|
||||
try:
|
||||
sent_provider = GeminiSentimentProvider(
|
||||
settings.gemini_api_key, settings.gemini_model
|
||||
)
|
||||
data = await sent_provider.fetch_sentiment(symbol_upper)
|
||||
await sentiment_service.store_sentiment(
|
||||
db,
|
||||
symbol=symbol_upper,
|
||||
classification=data.classification,
|
||||
confidence=data.confidence,
|
||||
source=data.source,
|
||||
timestamp=data.timestamp,
|
||||
)
|
||||
sources["sentiment"] = {
|
||||
"status": "ok",
|
||||
"classification": data.classification,
|
||||
"confidence": data.confidence,
|
||||
"message": None,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error("Sentiment fetch failed for %s: %s", symbol_upper, exc)
|
||||
sources["sentiment"] = {"status": "error", "message": str(exc)}
|
||||
else:
|
||||
sources["sentiment"] = {
|
||||
"status": "skipped",
|
||||
"message": "Gemini API key not configured",
|
||||
}
|
||||
|
||||
# --- Fundamentals ---
|
||||
if settings.fmp_api_key:
|
||||
try:
|
||||
fmp_provider = FMPFundamentalProvider(settings.fmp_api_key)
|
||||
fdata = await fmp_provider.fetch_fundamentals(symbol_upper)
|
||||
await fundamental_service.store_fundamental(
|
||||
db,
|
||||
symbol=symbol_upper,
|
||||
pe_ratio=fdata.pe_ratio,
|
||||
revenue_growth=fdata.revenue_growth,
|
||||
earnings_surprise=fdata.earnings_surprise,
|
||||
market_cap=fdata.market_cap,
|
||||
)
|
||||
sources["fundamentals"] = {"status": "ok", "message": None}
|
||||
except Exception as exc:
|
||||
logger.error("Fundamentals fetch failed for %s: %s", symbol_upper, exc)
|
||||
sources["fundamentals"] = {"status": "error", "message": str(exc)}
|
||||
else:
|
||||
sources["fundamentals"] = {
|
||||
"status": "skipped",
|
||||
"message": "FMP API key not configured",
|
||||
}
|
||||
|
||||
# Always return success — per-source breakdown tells the full story
|
||||
return APIEnvelope(
|
||||
status="success",
|
||||
data={"symbol": symbol_upper, "sources": sources},
|
||||
error=None,
|
||||
)
|
||||
56
app/routers/ohlcv.py
Normal file
56
app/routers/ohlcv.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""OHLCV router: endpoints for storing and querying price data."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.models.user import User
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.schemas.ohlcv import OHLCVCreate, OHLCVResponse
|
||||
from app.services import price_service
|
||||
|
||||
router = APIRouter(tags=["ohlcv"])
|
||||
|
||||
|
||||
@router.post("/ohlcv", response_model=APIEnvelope)
|
||||
async def create_ohlcv(
|
||||
body: OHLCVCreate,
|
||||
_user: User = Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upsert an OHLCV record for a ticker and date."""
|
||||
record = await price_service.upsert_ohlcv(
|
||||
db,
|
||||
symbol=body.symbol,
|
||||
record_date=body.date,
|
||||
open_=body.open,
|
||||
high=body.high,
|
||||
low=body.low,
|
||||
close=body.close,
|
||||
volume=body.volume,
|
||||
)
|
||||
return APIEnvelope(
|
||||
status="success",
|
||||
data=OHLCVResponse.model_validate(record).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ohlcv/{symbol}", response_model=APIEnvelope)
|
||||
async def get_ohlcv(
|
||||
symbol: str,
|
||||
start_date: date | None = Query(None, description="Start date (YYYY-MM-DD)"),
|
||||
end_date: date | None = Query(None, description="End date (YYYY-MM-DD)"),
|
||||
_user: User = Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Query OHLCV records for a ticker, optionally filtered by date range."""
|
||||
records = await price_service.query_ohlcv(db, symbol, start_date, end_date)
|
||||
return APIEnvelope(
|
||||
status="success",
|
||||
data=[
|
||||
OHLCVResponse.model_validate(r).model_dump(mode="json")
|
||||
for r in records
|
||||
],
|
||||
)
|
||||
75
app/routers/scores.py
Normal file
75
app/routers/scores.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Scores router — scoring engine endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.schemas.score import (
|
||||
DimensionScoreResponse,
|
||||
RankingEntry,
|
||||
RankingResponse,
|
||||
ScoreResponse,
|
||||
WeightUpdateRequest,
|
||||
)
|
||||
from app.services.scoring_service import get_rankings, get_score, update_weights
|
||||
|
||||
router = APIRouter(tags=["scores"])
|
||||
|
||||
|
||||
@router.get("/scores/{symbol}", response_model=APIEnvelope)
|
||||
async def read_score(
|
||||
symbol: str,
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Get composite + dimension scores for a symbol. Recomputes stale scores."""
|
||||
result = await get_score(db, symbol)
|
||||
|
||||
data = ScoreResponse(
|
||||
symbol=result["symbol"],
|
||||
composite_score=result["composite_score"],
|
||||
composite_stale=result["composite_stale"],
|
||||
weights=result["weights"],
|
||||
dimensions=[
|
||||
DimensionScoreResponse(**d) for d in result["dimensions"]
|
||||
],
|
||||
missing_dimensions=result["missing_dimensions"],
|
||||
computed_at=result["computed_at"],
|
||||
)
|
||||
return APIEnvelope(status="success", data=data.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/rankings", response_model=APIEnvelope)
|
||||
async def read_rankings(
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Get all tickers ranked by composite score descending."""
|
||||
result = await get_rankings(db)
|
||||
|
||||
data = RankingResponse(
|
||||
rankings=[
|
||||
RankingEntry(
|
||||
symbol=r["symbol"],
|
||||
composite_score=r["composite_score"],
|
||||
dimensions=[
|
||||
DimensionScoreResponse(**d) for d in r["dimensions"]
|
||||
],
|
||||
)
|
||||
for r in result["rankings"]
|
||||
],
|
||||
weights=result["weights"],
|
||||
)
|
||||
return APIEnvelope(status="success", data=data.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.put("/scores/weights", response_model=APIEnvelope)
|
||||
async def update_score_weights(
|
||||
body: WeightUpdateRequest,
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Update dimension weights and recompute all composite scores."""
|
||||
new_weights = await update_weights(db, body.weights)
|
||||
return APIEnvelope(status="success", data={"weights": new_weights})
|
||||
46
app/routers/sentiment.py
Normal file
46
app/routers/sentiment.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Sentiment router — sentiment data endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.schemas.sentiment import SentimentResponse, SentimentScoreResult
|
||||
from app.services.sentiment_service import (
|
||||
compute_sentiment_dimension_score,
|
||||
get_sentiment_scores,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["sentiment"])
|
||||
|
||||
|
||||
@router.get("/sentiment/{symbol}", response_model=APIEnvelope)
|
||||
async def read_sentiment(
|
||||
symbol: str,
|
||||
lookback_hours: float = Query(24, gt=0, description="Lookback window in hours"),
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Get recent sentiment scores and computed dimension score for a symbol."""
|
||||
scores = await get_sentiment_scores(db, symbol, lookback_hours)
|
||||
dimension_score = await compute_sentiment_dimension_score(
|
||||
db, symbol, lookback_hours
|
||||
)
|
||||
|
||||
data = SentimentResponse(
|
||||
symbol=symbol.strip().upper(),
|
||||
scores=[
|
||||
SentimentScoreResult(
|
||||
id=s.id,
|
||||
classification=s.classification,
|
||||
confidence=s.confidence,
|
||||
source=s.source,
|
||||
timestamp=s.timestamp,
|
||||
)
|
||||
for s in scores
|
||||
],
|
||||
count=len(scores),
|
||||
dimension_score=round(dimension_score, 2) if dimension_score is not None else None,
|
||||
lookback_hours=lookback_hours,
|
||||
)
|
||||
return APIEnvelope(status="success", data=data.model_dump())
|
||||
38
app/routers/sr_levels.py
Normal file
38
app/routers/sr_levels.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""S/R Levels router — support/resistance detection endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.schemas.sr_level import SRLevelResponse, SRLevelResult
|
||||
from app.services.sr_service import get_sr_levels
|
||||
|
||||
router = APIRouter(tags=["sr-levels"])
|
||||
|
||||
|
||||
@router.get("/sr-levels/{symbol}", response_model=APIEnvelope)
|
||||
async def read_sr_levels(
|
||||
symbol: str,
|
||||
tolerance: float = Query(0.005, ge=0, le=0.1, description="Merge tolerance (default 0.5%)"),
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Get support/resistance levels for a symbol, sorted by strength descending."""
|
||||
levels = await get_sr_levels(db, symbol, tolerance)
|
||||
data = SRLevelResponse(
|
||||
symbol=symbol.upper(),
|
||||
levels=[
|
||||
SRLevelResult(
|
||||
id=lvl.id,
|
||||
price_level=lvl.price_level,
|
||||
type=lvl.type,
|
||||
strength=lvl.strength,
|
||||
detection_method=lvl.detection_method,
|
||||
created_at=lvl.created_at,
|
||||
)
|
||||
for lvl in levels
|
||||
],
|
||||
count=len(levels),
|
||||
)
|
||||
return APIEnvelope(status="success", data=data.model_dump())
|
||||
53
app/routers/tickers.py
Normal file
53
app/routers/tickers.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Tickers router: CRUD endpoints for the Ticker Registry."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.models.user import User
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.schemas.ticker import TickerCreate, TickerResponse
|
||||
from app.services import ticker_service
|
||||
|
||||
router = APIRouter(tags=["tickers"])
|
||||
|
||||
|
||||
@router.post("/tickers", response_model=APIEnvelope)
|
||||
async def create_ticker(
|
||||
body: TickerCreate,
|
||||
_user: User = Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a new ticker to the registry."""
|
||||
ticker = await ticker_service.add_ticker(db, body.symbol)
|
||||
return APIEnvelope(
|
||||
status="success",
|
||||
data=TickerResponse.model_validate(ticker).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tickers", response_model=APIEnvelope)
|
||||
async def list_tickers(
|
||||
_user: User = Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all tracked tickers sorted alphabetically."""
|
||||
tickers = await ticker_service.list_tickers(db)
|
||||
return APIEnvelope(
|
||||
status="success",
|
||||
data=[
|
||||
TickerResponse.model_validate(t).model_dump(mode="json")
|
||||
for t in tickers
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tickers/{symbol}", response_model=APIEnvelope)
|
||||
async def delete_ticker(
|
||||
symbol: str,
|
||||
_user: User = Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a ticker and all associated data."""
|
||||
await ticker_service.delete_ticker(db, symbol)
|
||||
return APIEnvelope(status="success", data=None)
|
||||
28
app/routers/trades.py
Normal file
28
app/routers/trades.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Trades router — R:R scanner trade setup endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.schemas.trade_setup import TradeSetupResponse
|
||||
from app.services.rr_scanner_service import get_trade_setups
|
||||
|
||||
router = APIRouter(tags=["trades"])
|
||||
|
||||
|
||||
@router.get("/trades", response_model=APIEnvelope)
|
||||
async def list_trade_setups(
|
||||
direction: str | None = Query(
|
||||
None, description="Filter by direction: long or short"
|
||||
),
|
||||
_user=Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Get all trade setups sorted by R:R desc, secondary composite desc.
|
||||
|
||||
Optional direction filter (long/short).
|
||||
"""
|
||||
rows = await get_trade_setups(db, direction=direction)
|
||||
data = [TradeSetupResponse(**r).model_dump(mode="json") for r in rows]
|
||||
return APIEnvelope(status="success", data=data)
|
||||
59
app/routers/watchlist.py
Normal file
59
app/routers/watchlist.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Watchlist router — manage user's curated watchlist."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, require_access
|
||||
from app.models.user import User
|
||||
from app.schemas.common import APIEnvelope
|
||||
from app.schemas.watchlist import WatchlistEntryResponse
|
||||
from app.services.watchlist_service import (
|
||||
add_manual_entry,
|
||||
get_watchlist,
|
||||
remove_entry,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["watchlist"])
|
||||
|
||||
|
||||
@router.get("/watchlist", response_model=APIEnvelope)
|
||||
async def list_watchlist(
|
||||
sort_by: str = Query(
|
||||
"composite",
|
||||
description=(
|
||||
"Sort by: composite, rr, or a dimension name "
|
||||
"(technical, sr_quality, sentiment, fundamental, momentum)"
|
||||
),
|
||||
),
|
||||
user: User = Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Get current user's watchlist with enriched data."""
|
||||
rows = await get_watchlist(db, user.id, sort_by=sort_by)
|
||||
data = [WatchlistEntryResponse(**r).model_dump(mode="json") for r in rows]
|
||||
return APIEnvelope(status="success", data=data)
|
||||
|
||||
|
||||
@router.post("/watchlist/{symbol}", response_model=APIEnvelope)
|
||||
async def add_to_watchlist(
|
||||
symbol: str,
|
||||
user: User = Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Add a manual entry to the watchlist."""
|
||||
entry = await add_manual_entry(db, user.id, symbol)
|
||||
return APIEnvelope(
|
||||
status="success",
|
||||
data={"symbol": symbol.strip().upper(), "entry_type": entry.entry_type},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/watchlist/{symbol}", response_model=APIEnvelope)
|
||||
async def remove_from_watchlist(
|
||||
symbol: str,
|
||||
user: User = Depends(require_access),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> APIEnvelope:
|
||||
"""Remove an entry from the watchlist."""
|
||||
await remove_entry(db, user.id, symbol)
|
||||
return APIEnvelope(status="success", data=None)
|
||||
Reference in New Issue
Block a user