Initial project structure: MarketScanner - Fear-to-Fortune Trading Intelligence

Features:
- FastAPI backend with stocks, news, signals, watchlist, analytics endpoints
- React frontend with TailwindCSS dark mode trading dashboard
- Celery workers for news fetching, sentiment analysis, pattern detection
- TimescaleDB schema for time-series stock data
- Docker Compose setup for all services
- OpenAI integration for sentiment analysis
This commit is contained in:
mindesbunister
2026-01-08 14:15:51 +01:00
commit 074787f067
58 changed files with 4864 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
"""
API Router - Main entry point for all API routes
"""
from fastapi import APIRouter
from app.api.endpoints import stocks, news, signals, watchlist, analytics
router = APIRouter()
# Include all endpoint routers
router.include_router(stocks.router, prefix="/stocks", tags=["Stocks"])
router.include_router(news.router, prefix="/news", tags=["News"])
router.include_router(signals.router, prefix="/signals", tags=["Buy Signals"])
router.include_router(watchlist.router, prefix="/watchlist", tags=["Watchlist"])
router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"])

View File

@@ -0,0 +1 @@
"""API endpoints module."""

View File

@@ -0,0 +1,140 @@
"""
Analytics API Endpoints
"""
from typing import Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.models.stock import Stock
from app.models.news import NewsArticle
from app.models.signal import BuySignal
from app.models.panic import PanicEvent
router = APIRouter()
@router.get("/dashboard")
async def get_dashboard_stats(db: AsyncSession = Depends(get_db)):
"""Get overview statistics for the dashboard."""
now = datetime.utcnow()
last_24h = now - timedelta(hours=24)
last_7d = now - timedelta(days=7)
# Count stocks
stocks_count = await db.execute(
select(func.count(Stock.id)).where(Stock.is_active == True)
)
# Count news (last 24h)
news_count = await db.execute(
select(func.count(NewsArticle.id))
.where(NewsArticle.published_at >= last_24h)
)
# Count active signals
signals_count = await db.execute(
select(func.count(BuySignal.id))
.where(BuySignal.status == "active")
)
# Average sentiment (last 24h)
avg_sentiment = await db.execute(
select(func.avg(NewsArticle.sentiment_score))
.where(NewsArticle.published_at >= last_24h)
.where(NewsArticle.sentiment_score.isnot(None))
)
return {
"stocks_tracked": stocks_count.scalar() or 0,
"news_last_24h": news_count.scalar() or 0,
"active_signals": signals_count.scalar() or 0,
"avg_sentiment_24h": round(avg_sentiment.scalar() or 0, 2),
"timestamp": now.isoformat(),
}
@router.get("/sentiment/trend")
async def get_sentiment_trend(
db: AsyncSession = Depends(get_db),
days: int = Query(7, ge=1, le=30, description="Number of days"),
):
"""Get sentiment trend over time."""
# This would aggregate sentiment by day
# Placeholder for now
return {
"days": days,
"trend": [],
"message": "Sentiment trend data will be populated by the workers",
}
@router.get("/sector/panic")
async def get_sector_panic_levels(db: AsyncSession = Depends(get_db)):
"""Get current panic levels by sector."""
# This would calculate average sentiment by sector
# Placeholder for now
return {
"sectors": [],
"message": "Sector panic data will be populated by the workers",
}
@router.get("/patterns/top")
async def get_top_patterns(
db: AsyncSession = Depends(get_db),
limit: int = Query(10, ge=1, le=50),
):
"""Get top historical patterns with best recovery rates."""
# This would query historical_patterns table
# Placeholder for now
return {
"patterns": [],
"message": "Pattern data will be populated by the pattern matcher",
}
@router.get("/panic-events/recent")
async def get_recent_panic_events(
db: AsyncSession = Depends(get_db),
days: int = Query(30, ge=1, le=90, description="Number of days"),
limit: int = Query(20, ge=1, le=50),
):
"""Get recent panic events."""
since = datetime.utcnow() - timedelta(days=days)
query = (
select(PanicEvent)
.where(PanicEvent.start_time >= since)
.order_by(PanicEvent.start_time.desc())
.limit(limit)
)
result = await db.execute(query)
events = result.scalars().all()
return {
"days": days,
"count": len(events),
"events": events,
}
@router.get("/performance")
async def get_signal_performance(
db: AsyncSession = Depends(get_db),
days: int = Query(90, ge=30, le=365, description="Number of days to analyze"),
):
"""Get performance metrics for past signals."""
# This would analyze triggered signals and their outcomes
# Placeholder for now
return {
"days": days,
"total_signals": 0,
"triggered_signals": 0,
"avg_return": 0,
"win_rate": 0,
"message": "Performance data requires historical signal outcomes",
}

View File

@@ -0,0 +1,122 @@
"""
News API Endpoints
"""
from typing import List, Optional
from datetime import datetime, timedelta
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.models.news import NewsArticle
from app.schemas.news import NewsResponse, NewsWithSentiment
router = APIRouter()
@router.get("/", response_model=List[NewsResponse])
async def list_news(
db: AsyncSession = Depends(get_db),
source: Optional[str] = Query(None, description="Filter by source"),
sentiment: Optional[str] = Query(None, description="Filter by sentiment: positive, negative, neutral"),
hours: int = Query(24, ge=1, le=168, description="News from last N hours"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
):
"""List recent news articles."""
since = datetime.utcnow() - timedelta(hours=hours)
query = select(NewsArticle).where(NewsArticle.published_at >= since)
if source:
query = query.where(NewsArticle.source == source)
if sentiment:
query = query.where(NewsArticle.sentiment_label == sentiment)
query = query.order_by(desc(NewsArticle.published_at)).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@router.get("/sources")
async def list_sources(db: AsyncSession = Depends(get_db)):
"""Get list of all news sources."""
query = select(NewsArticle.source).distinct()
result = await db.execute(query)
sources = [row[0] for row in result.fetchall() if row[0]]
return {"sources": sorted(sources)}
@router.get("/stock/{symbol}", response_model=List[NewsWithSentiment])
async def get_news_for_stock(
symbol: str,
db: AsyncSession = Depends(get_db),
hours: int = Query(72, ge=1, le=720, description="News from last N hours"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
):
"""Get news articles mentioning a specific stock."""
# This would use the news_stock_mentions join table
# For now, we search in title/content
since = datetime.utcnow() - timedelta(hours=hours)
search_term = f"%{symbol.upper()}%"
query = (
select(NewsArticle)
.where(NewsArticle.published_at >= since)
.where(
(NewsArticle.title.ilike(search_term)) |
(NewsArticle.content.ilike(search_term))
)
.order_by(desc(NewsArticle.published_at))
.offset(skip)
.limit(limit)
)
result = await db.execute(query)
return result.scalars().all()
@router.get("/panic")
async def get_panic_news(
db: AsyncSession = Depends(get_db),
threshold: float = Query(-50.0, ge=-100, le=0, description="Sentiment threshold"),
hours: int = Query(24, ge=1, le=168, description="News from last N hours"),
limit: int = Query(20, ge=1, le=50),
):
"""Get the most panic-inducing news articles."""
since = datetime.utcnow() - timedelta(hours=hours)
query = (
select(NewsArticle)
.where(NewsArticle.published_at >= since)
.where(NewsArticle.sentiment_score <= threshold)
.order_by(NewsArticle.sentiment_score.asc())
.limit(limit)
)
result = await db.execute(query)
articles = result.scalars().all()
return {
"threshold": threshold,
"hours": hours,
"count": len(articles),
"articles": articles,
}
@router.get("/{article_id}", response_model=NewsWithSentiment)
async def get_article(
article_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""Get a specific news article with full details."""
query = select(NewsArticle).where(NewsArticle.id == article_id)
result = await db.execute(query)
article = result.scalar_one_or_none()
if not article:
raise HTTPException(status_code=404, detail="Article not found")
return article

View File

@@ -0,0 +1,123 @@
"""
Buy Signals API Endpoints
"""
from typing import List, Optional
from datetime import datetime, timedelta
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.core.database import get_db
from app.models.signal import BuySignal
from app.schemas.signal import SignalResponse, SignalWithDetails
router = APIRouter()
@router.get("/", response_model=List[SignalResponse])
async def list_signals(
db: AsyncSession = Depends(get_db),
status: Optional[str] = Query("active", description="Signal status: active, triggered, expired"),
min_confidence: float = Query(0.5, ge=0, le=1, description="Minimum confidence score"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
):
"""List buy signals ordered by confidence."""
query = select(BuySignal).where(BuySignal.confidence_score >= min_confidence)
if status:
query = query.where(BuySignal.status == status)
query = query.order_by(desc(BuySignal.confidence_score)).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@router.get("/top")
async def get_top_signals(
db: AsyncSession = Depends(get_db),
limit: int = Query(10, ge=1, le=20),
):
"""Get top buy signals by confidence score."""
query = (
select(BuySignal)
.where(BuySignal.status == "active")
.order_by(desc(BuySignal.confidence_score))
.limit(limit)
)
result = await db.execute(query)
signals = result.scalars().all()
return {
"count": len(signals),
"signals": signals,
}
@router.get("/stock/{symbol}", response_model=List[SignalResponse])
async def get_signals_for_stock(
symbol: str,
db: AsyncSession = Depends(get_db),
include_historical: bool = Query(False, description="Include past signals"),
):
"""Get buy signals for a specific stock."""
# We need to join with stocks table
# For now, placeholder
return []
@router.get("/{signal_id}", response_model=SignalWithDetails)
async def get_signal(
signal_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""Get detailed information about a specific signal."""
query = select(BuySignal).where(BuySignal.id == signal_id)
result = await db.execute(query)
signal = result.scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
return signal
@router.post("/{signal_id}/trigger")
async def trigger_signal(
signal_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""Mark a signal as triggered (you bought the stock)."""
query = select(BuySignal).where(BuySignal.id == signal_id)
result = await db.execute(query)
signal = result.scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
signal.status = "triggered"
signal.triggered_at = datetime.utcnow()
await db.commit()
return {"message": "Signal marked as triggered", "signal_id": signal_id}
@router.post("/{signal_id}/dismiss")
async def dismiss_signal(
signal_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""Dismiss a signal (not interested)."""
query = select(BuySignal).where(BuySignal.id == signal_id)
result = await db.execute(query)
signal = result.scalar_one_or_none()
if not signal:
raise HTTPException(status_code=404, detail="Signal not found")
signal.status = "cancelled"
await db.commit()
return {"message": "Signal dismissed", "signal_id": signal_id}

View File

@@ -0,0 +1,131 @@
"""
Stocks API Endpoints
"""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.models.stock import Stock
from app.schemas.stock import StockResponse, StockCreate, StockWithPrice
router = APIRouter()
@router.get("/", response_model=List[StockResponse])
async def list_stocks(
db: AsyncSession = Depends(get_db),
sector: Optional[str] = Query(None, description="Filter by sector"),
industry: Optional[str] = Query(None, description="Filter by industry"),
search: Optional[str] = Query(None, description="Search by symbol or name"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
):
"""List all tracked stocks with optional filters."""
query = select(Stock).where(Stock.is_active == True)
if sector:
query = query.where(Stock.sector == sector)
if industry:
query = query.where(Stock.industry == industry)
if search:
search_term = f"%{search}%"
query = query.where(
(Stock.symbol.ilike(search_term)) |
(Stock.name.ilike(search_term))
)
query = query.offset(skip).limit(limit).order_by(Stock.symbol)
result = await db.execute(query)
return result.scalars().all()
@router.get("/sectors")
async def list_sectors(db: AsyncSession = Depends(get_db)):
"""Get list of all unique sectors."""
query = select(Stock.sector).distinct().where(Stock.is_active == True)
result = await db.execute(query)
sectors = [row[0] for row in result.fetchall() if row[0]]
return {"sectors": sorted(sectors)}
@router.get("/industries")
async def list_industries(
db: AsyncSession = Depends(get_db),
sector: Optional[str] = Query(None, description="Filter by sector"),
):
"""Get list of all unique industries."""
query = select(Stock.industry).distinct().where(Stock.is_active == True)
if sector:
query = query.where(Stock.sector == sector)
result = await db.execute(query)
industries = [row[0] for row in result.fetchall() if row[0]]
return {"industries": sorted(industries)}
@router.get("/{symbol}", response_model=StockWithPrice)
async def get_stock(
symbol: str,
db: AsyncSession = Depends(get_db),
):
"""Get detailed stock information including latest price."""
query = select(Stock).where(Stock.symbol == symbol.upper())
result = await db.execute(query)
stock = result.scalar_one_or_none()
if not stock:
raise HTTPException(status_code=404, detail=f"Stock {symbol} not found")
# TODO: Add latest price from stock_prices table
return stock
@router.post("/", response_model=StockResponse)
async def add_stock(
stock: StockCreate,
db: AsyncSession = Depends(get_db),
):
"""Add a new stock to track."""
# Check if already exists
existing = await db.execute(
select(Stock).where(Stock.symbol == stock.symbol.upper())
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Stock {stock.symbol} already exists"
)
db_stock = Stock(
symbol=stock.symbol.upper(),
name=stock.name,
sector=stock.sector,
industry=stock.industry,
exchange=stock.exchange,
country=stock.country,
)
db.add(db_stock)
await db.commit()
await db.refresh(db_stock)
return db_stock
@router.delete("/{symbol}")
async def remove_stock(
symbol: str,
db: AsyncSession = Depends(get_db),
):
"""Remove a stock from tracking (soft delete)."""
query = select(Stock).where(Stock.symbol == symbol.upper())
result = await db.execute(query)
stock = result.scalar_one_or_none()
if not stock:
raise HTTPException(status_code=404, detail=f"Stock {symbol} not found")
stock.is_active = False
await db.commit()
return {"message": f"Stock {symbol} removed from tracking"}

View File

@@ -0,0 +1,141 @@
"""
Watchlist API Endpoints
"""
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.models.watchlist import Watchlist
from app.models.stock import Stock
from app.schemas.watchlist import WatchlistResponse, WatchlistCreate, WatchlistUpdate
router = APIRouter()
@router.get("/", response_model=List[WatchlistResponse])
async def list_watchlist(
db: AsyncSession = Depends(get_db),
priority: Optional[int] = Query(None, ge=1, le=3, description="Filter by priority"),
):
"""Get all items in watchlist."""
query = select(Watchlist).where(Watchlist.is_active == True)
if priority:
query = query.where(Watchlist.priority == priority)
query = query.order_by(Watchlist.priority)
result = await db.execute(query)
return result.scalars().all()
@router.post("/", response_model=WatchlistResponse)
async def add_to_watchlist(
item: WatchlistCreate,
db: AsyncSession = Depends(get_db),
):
"""Add a stock to the watchlist."""
# Find the stock
stock_query = select(Stock).where(Stock.symbol == item.symbol.upper())
stock_result = await db.execute(stock_query)
stock = stock_result.scalar_one_or_none()
if not stock:
raise HTTPException(status_code=404, detail=f"Stock {item.symbol} not found")
# Check if already in watchlist
existing = await db.execute(
select(Watchlist).where(Watchlist.stock_id == stock.id)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=400,
detail=f"Stock {item.symbol} is already in watchlist"
)
watchlist_item = Watchlist(
stock_id=stock.id,
panic_alert_threshold=item.panic_alert_threshold,
price_alert_low=item.price_alert_low,
price_alert_high=item.price_alert_high,
priority=item.priority,
notes=item.notes,
)
db.add(watchlist_item)
await db.commit()
await db.refresh(watchlist_item)
return watchlist_item
@router.put("/{watchlist_id}", response_model=WatchlistResponse)
async def update_watchlist_item(
watchlist_id: UUID,
update: WatchlistUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update a watchlist item."""
query = select(Watchlist).where(Watchlist.id == watchlist_id)
result = await db.execute(query)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
update_data = update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(item, key, value)
await db.commit()
await db.refresh(item)
return item
@router.delete("/{watchlist_id}")
async def remove_from_watchlist(
watchlist_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""Remove a stock from the watchlist."""
query = select(Watchlist).where(Watchlist.id == watchlist_id)
result = await db.execute(query)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Watchlist item not found")
await db.delete(item)
await db.commit()
return {"message": "Removed from watchlist"}
@router.delete("/symbol/{symbol}")
async def remove_symbol_from_watchlist(
symbol: str,
db: AsyncSession = Depends(get_db),
):
"""Remove a stock from watchlist by symbol."""
# Find the stock
stock_query = select(Stock).where(Stock.symbol == symbol.upper())
stock_result = await db.execute(stock_query)
stock = stock_result.scalar_one_or_none()
if not stock:
raise HTTPException(status_code=404, detail=f"Stock {symbol} not found")
# Find and remove watchlist item
watchlist_query = select(Watchlist).where(Watchlist.stock_id == stock.id)
watchlist_result = await db.execute(watchlist_query)
item = watchlist_result.scalar_one_or_none()
if not item:
raise HTTPException(
status_code=404,
detail=f"Stock {symbol} is not in watchlist"
)
await db.delete(item)
await db.commit()
return {"message": f"Removed {symbol} from watchlist"}