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:
16
backend/app/api/__init__.py
Normal file
16
backend/app/api/__init__.py
Normal 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"])
|
||||
1
backend/app/api/endpoints/__init__.py
Normal file
1
backend/app/api/endpoints/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API endpoints module."""
|
||||
140
backend/app/api/endpoints/analytics.py
Normal file
140
backend/app/api/endpoints/analytics.py
Normal 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",
|
||||
}
|
||||
122
backend/app/api/endpoints/news.py
Normal file
122
backend/app/api/endpoints/news.py
Normal 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
|
||||
123
backend/app/api/endpoints/signals.py
Normal file
123
backend/app/api/endpoints/signals.py
Normal 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}
|
||||
131
backend/app/api/endpoints/stocks.py
Normal file
131
backend/app/api/endpoints/stocks.py
Normal 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"}
|
||||
141
backend/app/api/endpoints/watchlist.py
Normal file
141
backend/app/api/endpoints/watchlist.py
Normal 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"}
|
||||
Reference in New Issue
Block a user