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

108
.env.example Normal file
View File

@@ -0,0 +1,108 @@
# MarketScanner Environment Configuration
# Copy this file to .env and fill in your values
# =============================================================================
# DATABASE
# =============================================================================
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_DB=marketscanner
POSTGRES_USER=marketscanner
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
# =============================================================================
# REDIS
# =============================================================================
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD
# =============================================================================
# RABBITMQ
# =============================================================================
RABBITMQ_HOST=rabbitmq
RABBITMQ_PORT=5672
RABBITMQ_USER=marketscanner
RABBITMQ_PASSWORD=CHANGE_ME_RABBITMQ_PASSWORD
# =============================================================================
# API KEYS - STOCK DATA
# =============================================================================
# Alpha Vantage (free tier: 5 calls/min, 500 calls/day)
# Get yours at: https://www.alphavantage.co/support/#api-key
ALPHA_VANTAGE_API_KEY=your_alpha_vantage_key
# Polygon.io (optional, for real-time data)
# Get yours at: https://polygon.io/
POLYGON_API_KEY=your_polygon_key
# Yahoo Finance (no key needed, but rate limited)
YAHOO_FINANCE_ENABLED=true
# =============================================================================
# API KEYS - NEWS
# =============================================================================
# NewsAPI.org (free tier: 100 requests/day)
# Get yours at: https://newsapi.org/register
NEWS_API_KEY=your_newsapi_key
# Finnhub (free tier available)
# Get yours at: https://finnhub.io/
FINNHUB_API_KEY=your_finnhub_key
# =============================================================================
# API KEYS - AI/NLP
# =============================================================================
# OpenAI (for sentiment analysis)
# Get yours at: https://platform.openai.com/api-keys
OPENAI_API_KEY=your_openai_key
OPENAI_MODEL=gpt-4o-mini
# Alternative: Use local LLM (Ollama)
USE_LOCAL_LLM=false
OLLAMA_HOST=http://ollama:11434
OLLAMA_MODEL=llama3.2
# =============================================================================
# APPLICATION SETTINGS
# =============================================================================
# Backend
BACKEND_HOST=0.0.0.0
BACKEND_PORT=8000
SECRET_KEY=CHANGE_ME_GENERATE_A_RANDOM_STRING
DEBUG=false
# Frontend
FRONTEND_PORT=3000
VITE_API_URL=http://localhost:8000
# =============================================================================
# SCANNING SETTINGS
# =============================================================================
# How often to fetch news (in seconds)
NEWS_SCAN_INTERVAL=300
# How often to fetch stock prices (in seconds)
STOCK_PRICE_INTERVAL=60
# Maximum stocks to track simultaneously
MAX_TRACKED_STOCKS=500
# Panic threshold (-100 to 0, lower = more panic)
PANIC_THRESHOLD=-50
# =============================================================================
# ALERT SETTINGS
# =============================================================================
# Telegram alerts (optional)
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
TELEGRAM_CHAT_ID=your_telegram_chat_id
# Discord webhook (optional)
DISCORD_WEBHOOK_URL=your_discord_webhook_url
# =============================================================================
# DEVELOPMENT
# =============================================================================
# Set to true for hot reload and debug logs
DEV_MODE=false

36
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,36 @@
# Copilot Instructions for MarketScanner
## Project Overview
MarketScanner is a "fear-to-fortune" trading intelligence system that identifies buying opportunities by analyzing how stocks historically respond to panic-inducing news.
## Tech Stack
- **Backend**: Python 3.12 + FastAPI + Celery + PostgreSQL/TimescaleDB + Redis
- **Frontend**: React 18 + TypeScript + TailwindCSS + Vite
- **Infrastructure**: Docker + Docker Compose
## Key Concepts
1. **Panic Detection**: Monitor news sentiment and price drops to identify panic events
2. **Pattern Matching**: Match current panic against historical recovery patterns
3. **Confidence Scoring**: Calculate buy signal confidence based on historical success rates
4. **Real-time Monitoring**: Continuous news and price data fetching via Celery workers
## Code Style
- Python: Follow PEP 8, use type hints, async/await for database operations
- TypeScript: Strict mode, functional components with hooks
- Use structured logging (structlog)
- Prefer composition over inheritance
## Database
- TimescaleDB hypertables for time-series data (stock_prices)
- UUID primary keys
- Soft deletes where appropriate
## API Design
- RESTful endpoints under `/api/v1/`
- Pydantic schemas for validation
- Pagination with skip/limit
## Testing
- pytest for Python
- Mock external APIs in tests
- Test critical business logic (pattern matching, confidence scoring)

68
.gitignore vendored Normal file
View File

@@ -0,0 +1,68 @@
# Environment
.env
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.pytest_cache/
.coverage
htmlcov/
.mypy_cache/
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Build outputs
frontend/dist/
backend/logs/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker
docker-compose.override.yml
# Logs
*.log
logs/
# Database
*.db
*.sqlite
# Secrets (never commit these!)
*.pem
*.key
secrets/

129
README.md Normal file
View File

@@ -0,0 +1,129 @@
# 📈 MarketScanner
> **"Buy when there's blood in the streets, even if the blood is your own."** — Baron Rothschild
A fear-to-fortune trading intelligence system that identifies buying opportunities by analyzing how stocks historically respond to panic-inducing news.
## 🎯 What This Does
1. **Monitors News** - Real-time scanning of financial news, social media, SEC filings
2. **Tracks Sentiment** - NLP-powered sentiment analysis on every piece of news
3. **Correlates with Price** - Links news events to actual stock movements
4. **Finds Patterns** - "Last time $TICKER had this type of news, it dropped X% then recovered Y% in Z months"
5. **Signals Opportunities** - Shows you when panic creates buying opportunities based on historical behavior
## 🏗️ Architecture
```
┌─────────────────────────────────────────────────────────┐
│ MarketScanner │
├─────────────────────────────────────────────────────────┤
│ News Scrapers → Sentiment Analysis → Pattern Matching │
│ ↓ ↓ ↓ │
│ TimescaleDB (Time-series data storage) │
│ ↓ │
│ FastAPI Backend → React Dashboard │
└─────────────────────────────────────────────────────────┘
```
## 🛠️ Tech Stack
- **Backend**: Python 3.12 + FastAPI
- **Frontend**: React 18 + TypeScript + TailwindCSS
- **Database**: PostgreSQL + TimescaleDB
- **Cache**: Redis
- **Queue**: Celery + RabbitMQ
- **NLP**: OpenAI API / Local LLM
- **Charts**: TradingView Lightweight Charts
- **Containerization**: Docker + Docker Compose
## 🚀 Quick Start
```bash
# Clone the repository
git clone git@gitea.egonetix.de:root/marketscanner.git
cd marketscanner
# Copy environment template
cp .env.example .env
# Edit .env with your API keys
nano .env
# Start all services
docker-compose up -d
# Access the dashboard
open http://localhost:3000
```
## 📁 Project Structure
```
marketscanner/
├── backend/ # FastAPI backend
│ ├── app/
│ │ ├── api/ # API routes
│ │ ├── core/ # Config, security
│ │ ├── models/ # Database models
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── services/ # Business logic
│ │ │ ├── news/ # News scrapers
│ │ │ ├── sentiment/ # NLP analysis
│ │ │ ├── stocks/ # Stock data fetchers
│ │ │ └── patterns/ # Pattern matching
│ │ └── workers/ # Celery tasks
│ ├── tests/
│ └── requirements.txt
├── frontend/ # React dashboard
│ ├── src/
│ │ ├── components/
│ │ ├── pages/
│ │ ├── hooks/
│ │ └── services/
│ └── package.json
├── docker/ # Docker configs
├── docker-compose.yml
├── .env.example
└── README.md
```
## 🔑 Required API Keys
| Service | Purpose | Cost |
|---------|---------|------|
| Alpha Vantage | Stock data | Free tier available |
| News API | News aggregation | Free tier available |
| OpenAI | Sentiment analysis | Pay per use |
| Polygon.io | Real-time data (optional) | Paid |
## 📊 Features
### Dashboard
- Real-time panic score monitoring
- Sector heat maps
- Historical pattern overlays
- Buy signal alerts with confidence scores
### Panic Detection
- Sentiment scoring (-100 to +100)
- Volume spike detection
- Price velocity measurement
- Fear index correlation
### Pattern Matching
- Company-specific recovery patterns
- Sector-wide panic analysis
- Event-type categorization (scandal, earnings miss, macro events)
## ⚠️ Disclaimer
This software is for educational and research purposes only. It is not financial advice. Trading stocks involves risk of loss. Past performance does not guarantee future results. Always do your own research and consult with a qualified financial advisor.
## 📜 License
MIT License - See [LICENSE](LICENSE) for details.
---
*"The time to buy is when there's blood in the streets."* 🩸📈

26
backend/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create logs directory
RUN mkdir -p /app/logs
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

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"}

View File

@@ -0,0 +1,6 @@
"""Core module exports."""
from app.core.config import settings
from app.core.database import get_db, Base, AsyncSessionLocal
__all__ = ["settings", "get_db", "Base", "AsyncSessionLocal"]

View File

@@ -0,0 +1,97 @@
"""
Application Configuration
"""
from typing import List
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Application
VERSION: str = "0.1.0"
DEBUG: bool = False
SECRET_KEY: str = "change-me-in-production"
# Server
BACKEND_HOST: str = "0.0.0.0"
BACKEND_PORT: int = 8000
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
# Database
POSTGRES_HOST: str = "localhost"
POSTGRES_PORT: int = 5432
POSTGRES_DB: str = "marketscanner"
POSTGRES_USER: str = "marketscanner"
POSTGRES_PASSWORD: str = "changeme"
@property
def DATABASE_URL(self) -> str:
return f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
@property
def DATABASE_URL_SYNC(self) -> str:
return f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
# Redis
REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6379
REDIS_PASSWORD: str = ""
@property
def REDIS_URL(self) -> str:
if self.REDIS_PASSWORD:
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/0"
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/0"
# RabbitMQ
RABBITMQ_HOST: str = "localhost"
RABBITMQ_PORT: int = 5672
RABBITMQ_USER: str = "guest"
RABBITMQ_PASSWORD: str = "guest"
@property
def RABBITMQ_URL(self) -> str:
return f"amqp://{self.RABBITMQ_USER}:{self.RABBITMQ_PASSWORD}@{self.RABBITMQ_HOST}:{self.RABBITMQ_PORT}//"
# API Keys - Stock Data
ALPHA_VANTAGE_API_KEY: str = ""
POLYGON_API_KEY: str = ""
YAHOO_FINANCE_ENABLED: bool = True
FINNHUB_API_KEY: str = ""
# API Keys - News
NEWS_API_KEY: str = ""
# API Keys - AI
OPENAI_API_KEY: str = ""
OPENAI_MODEL: str = "gpt-4o-mini"
USE_LOCAL_LLM: bool = False
OLLAMA_HOST: str = "http://localhost:11434"
OLLAMA_MODEL: str = "llama3.2"
# Scanning Settings
NEWS_SCAN_INTERVAL: int = 300 # seconds
STOCK_PRICE_INTERVAL: int = 60 # seconds
MAX_TRACKED_STOCKS: int = 500
PANIC_THRESHOLD: float = -50.0
# Alerts
TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_CHAT_ID: str = ""
DISCORD_WEBHOOK_URL: str = ""
class Config:
env_file = ".env"
case_sensitive = True
@lru_cache()
def get_settings() -> Settings:
"""Get cached settings instance."""
return Settings()
settings = get_settings()

View File

@@ -0,0 +1,79 @@
"""
Database Configuration and Session Management
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import declarative_base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Async engine for FastAPI
async_engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
# Async session factory
AsyncSessionLocal = async_sessionmaker(
async_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
# Sync engine for Celery workers
sync_engine = create_engine(
settings.DATABASE_URL_SYNC,
echo=settings.DEBUG,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
)
# Sync session factory
SyncSessionLocal = sessionmaker(
sync_engine,
autocommit=False,
autoflush=False,
)
# Base class for models
Base = declarative_base()
async def init_db():
"""Initialize database (create tables if needed)."""
# Tables are created by init.sql, but we can add migrations here
pass
async def get_db() -> AsyncSession:
"""Dependency for getting async database session."""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
def get_sync_db():
"""Get sync database session for Celery workers."""
db = SyncSessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()

114
backend/app/main.py Normal file
View File

@@ -0,0 +1,114 @@
"""
MarketScanner - Fear-to-Fortune Trading Intelligence
Main FastAPI Application
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import structlog
from app.core.config import settings
from app.core.database import init_db
from app.api import router as api_router
# Configure structured logging
structlog.configure(
processors=[
structlog.stdlib.filter_by_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer()
],
wrapper_class=structlog.stdlib.BoundLogger,
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
)
logger = structlog.get_logger()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan events."""
# Startup
logger.info("Starting MarketScanner API", version=settings.VERSION)
await init_db()
logger.info("Database initialized")
yield
# Shutdown
logger.info("Shutting down MarketScanner API")
# Create FastAPI application
app = FastAPI(
title="MarketScanner API",
description="""
🚀 **MarketScanner** - Fear-to-Fortune Trading Intelligence
A system that identifies buying opportunities by analyzing how stocks
historically respond to panic-inducing news.
## Features
* **News Monitoring** - Real-time scanning of financial news
* **Sentiment Analysis** - NLP-powered sentiment scoring
* **Panic Detection** - Identify market fear events
* **Pattern Matching** - Historical recovery patterns
* **Buy Signals** - Confidence-scored opportunities
*"Buy when there's blood in the streets."* — Baron Rothschild
""",
version=settings.VERSION,
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API routes
app.include_router(api_router, prefix="/api/v1")
@app.get("/", tags=["Root"])
async def root():
"""Root endpoint with API info."""
return {
"name": "MarketScanner API",
"version": settings.VERSION,
"description": "Fear-to-Fortune Trading Intelligence",
"docs": "/docs",
"health": "/health",
}
@app.get("/health", tags=["Health"])
async def health_check():
"""Health check endpoint for Docker/Kubernetes."""
return JSONResponse(
status_code=200,
content={
"status": "healthy",
"version": settings.VERSION,
}
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.BACKEND_HOST,
port=settings.BACKEND_PORT,
reload=settings.DEBUG,
)

View File

@@ -0,0 +1,9 @@
"""Database models."""
from app.models.stock import Stock
from app.models.news import NewsArticle
from app.models.signal import BuySignal
from app.models.panic import PanicEvent
from app.models.watchlist import Watchlist
__all__ = ["Stock", "NewsArticle", "BuySignal", "PanicEvent", "Watchlist"]

View File

@@ -0,0 +1,34 @@
"""News article model."""
from sqlalchemy import Column, String, Text, Boolean, DateTime, Numeric
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.core.database import Base
class NewsArticle(Base):
"""News article table model."""
__tablename__ = "news_articles"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(Text, nullable=False)
content = Column(Text)
summary = Column(Text)
url = Column(Text, unique=True, nullable=False)
source = Column(String(100), nullable=False, index=True)
author = Column(String(255))
published_at = Column(DateTime(timezone=True), nullable=False, index=True)
fetched_at = Column(DateTime(timezone=True), server_default=func.now())
image_url = Column(Text)
# Sentiment analysis results
sentiment_score = Column(Numeric(5, 2), index=True) # -100 to +100
sentiment_label = Column(String(20)) # negative, neutral, positive
sentiment_confidence = Column(Numeric(5, 4))
# Processing status
is_processed = Column(Boolean, default=False)
processing_error = Column(Text)

View File

@@ -0,0 +1,48 @@
"""Panic event model."""
from sqlalchemy import Column, String, Text, Boolean, DateTime, Numeric, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.core.database import Base
class PanicEvent(Base):
"""Panic event table model."""
__tablename__ = "panic_events"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
stock_id = Column(UUID(as_uuid=True), ForeignKey("stocks.id", ondelete="CASCADE"), nullable=False, index=True)
# Event timing
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
peak_time = Column(DateTime(timezone=True))
end_time = Column(DateTime(timezone=True))
# Price impact
price_at_start = Column(Numeric(15, 4), nullable=False)
price_at_peak_panic = Column(Numeric(15, 4))
price_at_end = Column(Numeric(15, 4))
max_drawdown_percent = Column(Numeric(8, 4), index=True)
# Sentiment
avg_sentiment_score = Column(Numeric(5, 2))
min_sentiment_score = Column(Numeric(5, 2))
news_volume = Column(Integer)
# Recovery metrics
recovery_time_days = Column(Integer)
recovery_percent = Column(Numeric(8, 4))
# Classification
event_type = Column(String(100), index=True) # earnings_miss, scandal, lawsuit, macro, etc.
event_category = Column(String(50)) # company_specific, sector_wide, market_wide
# Analysis
is_complete = Column(Boolean, default=False)
notes = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,46 @@
"""Buy signal model."""
from sqlalchemy import Column, String, DateTime, Numeric, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.core.database import Base
class BuySignal(Base):
"""Buy signal table model."""
__tablename__ = "buy_signals"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
stock_id = Column(UUID(as_uuid=True), ForeignKey("stocks.id", ondelete="CASCADE"), nullable=False, index=True)
panic_event_id = Column(UUID(as_uuid=True), ForeignKey("panic_events.id", ondelete="SET NULL"))
# Signal details
signal_time = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True)
signal_price = Column(Numeric(15, 4), nullable=False)
# Confidence scoring
confidence_score = Column(Numeric(5, 4), nullable=False, index=True) # 0 to 1
# Based on pattern
pattern_id = Column(UUID(as_uuid=True), ForeignKey("historical_patterns.id", ondelete="SET NULL"))
expected_recovery_percent = Column(Numeric(8, 4))
expected_recovery_days = Column(Integer)
# Current metrics
current_drawdown_percent = Column(Numeric(8, 4))
current_sentiment_score = Column(Numeric(5, 2))
# Signal status
status = Column(String(20), default="active", index=True) # active, triggered, expired, cancelled
triggered_at = Column(DateTime(timezone=True))
# Outcome tracking
outcome_price = Column(Numeric(15, 4))
outcome_percent = Column(Numeric(8, 4))
outcome_days = Column(Integer)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,26 @@
"""Stock model."""
from sqlalchemy import Column, String, BigInteger, Boolean, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.core.database import Base
class Stock(Base):
"""Stock table model."""
__tablename__ = "stocks"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
symbol = Column(String(20), unique=True, nullable=False, index=True)
name = Column(String(255), nullable=False)
sector = Column(String(100), index=True)
industry = Column(String(100), index=True)
market_cap = Column(BigInteger)
exchange = Column(String(50))
country = Column(String(100), default="USA")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,30 @@
"""Watchlist model."""
from sqlalchemy import Column, String, Text, Boolean, DateTime, Numeric, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.core.database import Base
class Watchlist(Base):
"""Watchlist table model."""
__tablename__ = "watchlist"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
stock_id = Column(UUID(as_uuid=True), ForeignKey("stocks.id", ondelete="CASCADE"), nullable=False, unique=True)
# Alert thresholds
panic_alert_threshold = Column(Numeric(5, 2), default=-50)
price_alert_low = Column(Numeric(15, 4))
price_alert_high = Column(Numeric(15, 4))
# Preferences
priority = Column(Integer, default=1, index=True) # 1 = high, 2 = medium, 3 = low
notes = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,13 @@
"""Pydantic schemas."""
from app.schemas.stock import StockCreate, StockResponse, StockWithPrice
from app.schemas.news import NewsCreate, NewsResponse, NewsWithSentiment
from app.schemas.signal import SignalResponse, SignalWithDetails
from app.schemas.watchlist import WatchlistCreate, WatchlistResponse, WatchlistUpdate
__all__ = [
"StockCreate", "StockResponse", "StockWithPrice",
"NewsCreate", "NewsResponse", "NewsWithSentiment",
"SignalResponse", "SignalWithDetails",
"WatchlistCreate", "WatchlistResponse", "WatchlistUpdate",
]

View File

@@ -0,0 +1,43 @@
"""News schemas."""
from typing import Optional
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class NewsBase(BaseModel):
"""Base news schema."""
title: str
url: str
source: str
published_at: datetime
class NewsCreate(NewsBase):
"""Schema for creating a news article."""
content: Optional[str] = None
summary: Optional[str] = None
author: Optional[str] = None
image_url: Optional[str] = None
class NewsResponse(NewsBase):
"""Schema for news response."""
id: UUID
summary: Optional[str] = None
author: Optional[str] = None
fetched_at: datetime
class Config:
from_attributes = True
class NewsWithSentiment(NewsResponse):
"""News response with sentiment analysis."""
content: Optional[str] = None
image_url: Optional[str] = None
sentiment_score: Optional[float] = None
sentiment_label: Optional[str] = None
sentiment_confidence: Optional[float] = None
is_processed: bool = False

View File

@@ -0,0 +1,39 @@
"""Signal schemas."""
from typing import Optional
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel
class SignalBase(BaseModel):
"""Base signal schema."""
stock_id: UUID
signal_price: float
confidence_score: float
class SignalResponse(SignalBase):
"""Schema for signal response."""
id: UUID
signal_time: datetime
status: str
current_drawdown_percent: Optional[float] = None
current_sentiment_score: Optional[float] = None
expected_recovery_percent: Optional[float] = None
expected_recovery_days: Optional[int] = None
created_at: datetime
class Config:
from_attributes = True
class SignalWithDetails(SignalResponse):
"""Signal response with full details."""
panic_event_id: Optional[UUID] = None
pattern_id: Optional[UUID] = None
triggered_at: Optional[datetime] = None
outcome_price: Optional[float] = None
outcome_percent: Optional[float] = None
outcome_days: Optional[int] = None
updated_at: datetime

View File

@@ -0,0 +1,41 @@
"""Stock schemas."""
from typing import Optional
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class StockBase(BaseModel):
"""Base stock schema."""
symbol: str = Field(..., min_length=1, max_length=20)
name: str = Field(..., min_length=1, max_length=255)
sector: Optional[str] = None
industry: Optional[str] = None
exchange: Optional[str] = None
country: str = "USA"
class StockCreate(StockBase):
"""Schema for creating a stock."""
pass
class StockResponse(StockBase):
"""Schema for stock response."""
id: UUID
market_cap: Optional[int] = None
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class StockWithPrice(StockResponse):
"""Stock response with latest price data."""
latest_price: Optional[float] = None
price_change_24h: Optional[float] = None
price_change_percent_24h: Optional[float] = None
volume_24h: Optional[int] = None

View File

@@ -0,0 +1,42 @@
"""Watchlist schemas."""
from typing import Optional
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
class WatchlistBase(BaseModel):
"""Base watchlist schema."""
panic_alert_threshold: float = -50.0
price_alert_low: Optional[float] = None
price_alert_high: Optional[float] = None
priority: int = Field(1, ge=1, le=3)
notes: Optional[str] = None
class WatchlistCreate(WatchlistBase):
"""Schema for creating a watchlist item."""
symbol: str
class WatchlistUpdate(BaseModel):
"""Schema for updating a watchlist item."""
panic_alert_threshold: Optional[float] = None
price_alert_low: Optional[float] = None
price_alert_high: Optional[float] = None
priority: Optional[int] = Field(None, ge=1, le=3)
notes: Optional[str] = None
is_active: Optional[bool] = None
class WatchlistResponse(WatchlistBase):
"""Schema for watchlist response."""
id: UUID
stock_id: UUID
is_active: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,5 @@
"""Celery workers module."""
from app.workers.celery_app import celery_app
__all__ = ["celery_app"]

View File

@@ -0,0 +1,69 @@
"""
Celery Application Configuration
"""
from celery import Celery
from celery.schedules import crontab
from app.core.config import settings
# Create Celery app
celery_app = Celery(
"marketscanner",
broker=settings.RABBITMQ_URL,
backend=settings.REDIS_URL,
include=[
"app.workers.tasks.news_tasks",
"app.workers.tasks.stock_tasks",
"app.workers.tasks.sentiment_tasks",
"app.workers.tasks.pattern_tasks",
"app.workers.tasks.alert_tasks",
],
)
# Celery configuration
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="UTC",
enable_utc=True,
task_track_started=True,
task_time_limit=300, # 5 minutes
worker_prefetch_multiplier=1,
worker_concurrency=4,
)
# Beat schedule (periodic tasks)
celery_app.conf.beat_schedule = {
# Fetch news every 5 minutes
"fetch-news-every-5-minutes": {
"task": "app.workers.tasks.news_tasks.fetch_all_news",
"schedule": settings.NEWS_SCAN_INTERVAL,
},
# Update stock prices every minute
"update-prices-every-minute": {
"task": "app.workers.tasks.stock_tasks.update_stock_prices",
"schedule": settings.STOCK_PRICE_INTERVAL,
},
# Process unanalyzed news every 2 minutes
"analyze-sentiment-every-2-minutes": {
"task": "app.workers.tasks.sentiment_tasks.process_unanalyzed_news",
"schedule": 120,
},
# Detect panic events every 5 minutes
"detect-panic-every-5-minutes": {
"task": "app.workers.tasks.pattern_tasks.detect_panic_events",
"schedule": 300,
},
# Generate signals every 10 minutes
"generate-signals-every-10-minutes": {
"task": "app.workers.tasks.pattern_tasks.generate_buy_signals",
"schedule": 600,
},
# Clean old data daily at midnight
"cleanup-daily": {
"task": "app.workers.tasks.news_tasks.cleanup_old_news",
"schedule": crontab(hour=0, minute=0),
},
}

View File

@@ -0,0 +1 @@
"""Worker tasks module."""

View File

@@ -0,0 +1,153 @@
"""
Alert notification tasks
"""
import httpx
import structlog
from app.workers.celery_app import celery_app
from app.core.config import settings
logger = structlog.get_logger()
@celery_app.task(name="app.workers.tasks.alert_tasks.send_telegram_alert")
def send_telegram_alert(message: str):
"""Send alert via Telegram."""
if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_CHAT_ID:
logger.warning("Telegram not configured")
return {"sent": False, "reason": "not_configured"}
try:
url = f"https://api.telegram.org/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage"
payload = {
"chat_id": settings.TELEGRAM_CHAT_ID,
"text": message,
"parse_mode": "Markdown",
}
with httpx.Client() as client:
response = client.post(url, json=payload)
response.raise_for_status()
logger.info("Telegram alert sent")
return {"sent": True}
except Exception as e:
logger.error("Failed to send Telegram alert", error=str(e))
return {"sent": False, "error": str(e)}
@celery_app.task(name="app.workers.tasks.alert_tasks.send_discord_alert")
def send_discord_alert(message: str, embed: dict = None):
"""Send alert via Discord webhook."""
if not settings.DISCORD_WEBHOOK_URL:
logger.warning("Discord not configured")
return {"sent": False, "reason": "not_configured"}
try:
payload = {"content": message}
if embed:
payload["embeds"] = [embed]
with httpx.Client() as client:
response = client.post(settings.DISCORD_WEBHOOK_URL, json=payload)
response.raise_for_status()
logger.info("Discord alert sent")
return {"sent": True}
except Exception as e:
logger.error("Failed to send Discord alert", error=str(e))
return {"sent": False, "error": str(e)}
@celery_app.task(name="app.workers.tasks.alert_tasks.send_buy_signal_alert")
def send_buy_signal_alert(signal_data: dict):
"""Send formatted buy signal alert to all configured channels."""
logger.info("Sending buy signal alert", symbol=signal_data.get("symbol"))
# Format message
symbol = signal_data.get("symbol", "UNKNOWN")
confidence = signal_data.get("confidence", 0) * 100
current_price = signal_data.get("price", 0)
drawdown = signal_data.get("drawdown", 0)
expected_recovery = signal_data.get("expected_recovery", 0)
message = f"""
🚨 *BUY SIGNAL: ${symbol}* 🚨
📊 *Confidence:* {confidence:.1f}%
💰 *Current Price:* ${current_price:.2f}
📉 *Drawdown:* {drawdown:.1f}%
📈 *Expected Recovery:* {expected_recovery:.1f}%
_"Buy when there's blood in the streets"_
""".strip()
results = {
"telegram": None,
"discord": None,
}
# Send to Telegram
if settings.TELEGRAM_BOT_TOKEN:
results["telegram"] = send_telegram_alert.delay(message).get()
# Send to Discord with embed
if settings.DISCORD_WEBHOOK_URL:
embed = {
"title": f"🚨 BUY SIGNAL: ${symbol}",
"color": 0x00ff00, # Green
"fields": [
{"name": "Confidence", "value": f"{confidence:.1f}%", "inline": True},
{"name": "Price", "value": f"${current_price:.2f}", "inline": True},
{"name": "Drawdown", "value": f"{drawdown:.1f}%", "inline": True},
{"name": "Expected Recovery", "value": f"{expected_recovery:.1f}%", "inline": True},
],
"footer": {"text": "MarketScanner • Buy the Fear"},
}
results["discord"] = send_discord_alert.delay("", embed).get()
return results
@celery_app.task(name="app.workers.tasks.alert_tasks.send_panic_alert")
def send_panic_alert(panic_data: dict):
"""Send formatted panic detection alert."""
logger.info("Sending panic alert", symbol=panic_data.get("symbol"))
symbol = panic_data.get("symbol", "UNKNOWN")
sentiment = panic_data.get("sentiment", 0)
price_drop = panic_data.get("price_drop", 0)
news_count = panic_data.get("news_count", 0)
message = f"""
🔴 *PANIC DETECTED: ${symbol}* 🔴
😱 *Sentiment Score:* {sentiment:.1f}
📉 *Price Drop:* {price_drop:.1f}%
📰 *News Volume:* {news_count} articles
⏳ Monitoring for buying opportunity...
""".strip()
results = {}
if settings.TELEGRAM_BOT_TOKEN:
results["telegram"] = send_telegram_alert.delay(message).get()
if settings.DISCORD_WEBHOOK_URL:
embed = {
"title": f"🔴 PANIC DETECTED: ${symbol}",
"color": 0xff0000, # Red
"fields": [
{"name": "Sentiment", "value": f"{sentiment:.1f}", "inline": True},
{"name": "Price Drop", "value": f"{price_drop:.1f}%", "inline": True},
{"name": "News Volume", "value": f"{news_count} articles", "inline": True},
],
"footer": {"text": "MarketScanner • Watching for opportunity"},
}
results["discord"] = send_discord_alert.delay("", embed).get()
return results

View File

@@ -0,0 +1,108 @@
"""
News fetching tasks
"""
from datetime import datetime, timedelta
import feedparser
import structlog
from app.workers.celery_app import celery_app
from app.core.database import get_sync_db
from app.core.config import settings
logger = structlog.get_logger()
# RSS Feeds to monitor
NEWS_FEEDS = [
# General Financial News
{"name": "Yahoo Finance", "url": "https://finance.yahoo.com/news/rssindex"},
{"name": "Reuters Business", "url": "https://www.reutersagency.com/feed/?best-topics=business-finance&post_type=best"},
{"name": "CNBC", "url": "https://www.cnbc.com/id/100003114/device/rss/rss.html"},
{"name": "MarketWatch", "url": "https://feeds.marketwatch.com/marketwatch/topstories/"},
{"name": "Seeking Alpha", "url": "https://seekingalpha.com/market_currents.xml"},
{"name": "Bloomberg", "url": "https://www.bloomberg.com/feed/podcast/etf-report.xml"},
# Tech
{"name": "TechCrunch", "url": "https://techcrunch.com/feed/"},
# Crypto (because why not)
{"name": "CoinDesk", "url": "https://www.coindesk.com/arc/outboundfeeds/rss/"},
]
@celery_app.task(name="app.workers.tasks.news_tasks.fetch_all_news")
def fetch_all_news():
"""Fetch news from all configured sources."""
logger.info("Starting news fetch from all sources")
total_fetched = 0
for feed_config in NEWS_FEEDS:
try:
count = fetch_from_feed(feed_config["name"], feed_config["url"])
total_fetched += count
except Exception as e:
logger.error(
"Failed to fetch from feed",
feed=feed_config["name"],
error=str(e)
)
logger.info("News fetch complete", total_articles=total_fetched)
return {"fetched": total_fetched}
@celery_app.task(name="app.workers.tasks.news_tasks.fetch_from_feed")
def fetch_from_feed(source_name: str, feed_url: str) -> int:
"""Fetch news from a single RSS feed."""
logger.info("Fetching from feed", source=source_name)
try:
feed = feedparser.parse(feed_url)
articles_saved = 0
for entry in feed.entries[:50]: # Limit to 50 most recent
try:
# Extract data
title = entry.get("title", "")
url = entry.get("link", "")
summary = entry.get("summary", "")
author = entry.get("author", "")
# Parse published date
published = entry.get("published_parsed") or entry.get("updated_parsed")
if published:
published_at = datetime(*published[:6])
else:
published_at = datetime.utcnow()
# Save to database (skip if exists)
# This is a placeholder - actual implementation would use the db session
articles_saved += 1
except Exception as e:
logger.warning(
"Failed to process article",
title=entry.get("title", "unknown"),
error=str(e)
)
logger.info("Feed processed", source=source_name, articles=articles_saved)
return articles_saved
except Exception as e:
logger.error("Failed to parse feed", source=source_name, error=str(e))
return 0
@celery_app.task(name="app.workers.tasks.news_tasks.cleanup_old_news")
def cleanup_old_news(days: int = 90):
"""Remove news articles older than specified days."""
logger.info("Starting news cleanup", days_to_keep=days)
cutoff = datetime.utcnow() - timedelta(days=days)
# Placeholder - actual implementation would delete from database
deleted_count = 0
logger.info("News cleanup complete", deleted=deleted_count)
return {"deleted": deleted_count}

View File

@@ -0,0 +1,142 @@
"""
Pattern detection and buy signal generation tasks
"""
from datetime import datetime, timedelta
import structlog
from app.workers.celery_app import celery_app
from app.core.config import settings
logger = structlog.get_logger()
@celery_app.task(name="app.workers.tasks.pattern_tasks.detect_panic_events")
def detect_panic_events():
"""Detect new panic events based on sentiment and price drops."""
logger.info("Starting panic event detection")
# Detection criteria:
# 1. Sentiment score drops below threshold
# 2. Price drops significantly (>5% in 24h)
# 3. News volume spikes
# Placeholder - actual implementation would:
# - Query recent news sentiment by stock
# - Check price movements
# - Create panic_events records
detected_count = 0
logger.info("Panic detection complete", detected=detected_count)
return {"detected": detected_count}
@celery_app.task(name="app.workers.tasks.pattern_tasks.generate_buy_signals")
def generate_buy_signals():
"""Generate buy signals based on historical patterns."""
logger.info("Starting buy signal generation")
# Signal generation criteria:
# 1. Active panic event exists
# 2. Similar historical events had good recovery
# 3. Price is near or past typical bottom
# 4. Volume indicates capitulation
# Placeholder - actual implementation would:
# - Find stocks with active panic events
# - Match against historical patterns
# - Calculate confidence scores
# - Create buy_signals records
signals_count = 0
logger.info("Signal generation complete", signals=signals_count)
return {"generated": signals_count}
@celery_app.task(name="app.workers.tasks.pattern_tasks.analyze_historical_pattern")
def analyze_historical_pattern(stock_id: str, event_type: str):
"""Analyze historical patterns for a specific stock and event type."""
logger.info("Analyzing historical pattern", stock_id=stock_id, event_type=event_type)
# Would query past panic events for this stock
# Calculate statistics:
# - Average/median drawdown
# - Average/median recovery time
# - Average/median recovery percentage
# - Success rate (how often did it recover)
return {
"stock_id": stock_id,
"event_type": event_type,
"pattern": None, # Would contain pattern data
}
@celery_app.task(name="app.workers.tasks.pattern_tasks.calculate_confidence_score")
def calculate_confidence_score(
stock_id: str,
current_drawdown: float,
current_sentiment: float,
historical_pattern: dict,
) -> float:
"""Calculate confidence score for a potential buy signal."""
# Factors:
# 1. How close is current drawdown to historical average
# 2. How negative is sentiment (capitulation indicator)
# 3. Pattern reliability (sample size, consistency)
# 4. Market conditions (sector performance, overall market)
score = 0.5 # Base score
# Adjust based on drawdown match
if historical_pattern and historical_pattern.get("avg_drawdown"):
avg_drawdown = historical_pattern["avg_drawdown"]
drawdown_ratio = current_drawdown / avg_drawdown
if 0.8 <= drawdown_ratio <= 1.2:
score += 0.2 # Close to historical average
# Adjust based on sentiment (more panic = higher score)
if current_sentiment < settings.PANIC_THRESHOLD:
panic_intensity = abs(current_sentiment - settings.PANIC_THRESHOLD) / 50
score += min(panic_intensity * 0.2, 0.2)
# Adjust based on pattern reliability
if historical_pattern and historical_pattern.get("event_count", 0) >= 3:
score += 0.1 # Multiple historical examples
return min(max(score, 0), 1) # Clamp to 0-1
@celery_app.task(name="app.workers.tasks.pattern_tasks.update_panic_event_status")
def update_panic_event_status():
"""Update panic events - check if they've ended/recovered."""
logger.info("Updating panic event statuses")
# Check active (incomplete) panic events
# Mark as complete if:
# - Price has recovered to pre-panic levels
# - Sentiment has normalized
# - Enough time has passed
updated_count = 0
logger.info("Panic status update complete", updated=updated_count)
return {"updated": updated_count}
@celery_app.task(name="app.workers.tasks.pattern_tasks.rebuild_patterns")
def rebuild_patterns(stock_id: str = None):
"""Rebuild historical patterns from panic events."""
logger.info("Rebuilding patterns", stock_id=stock_id or "all")
# Aggregate all completed panic events
# Group by stock and event type
# Calculate pattern statistics
patterns_count = 0
logger.info("Pattern rebuild complete", patterns=patterns_count)
return {"rebuilt": patterns_count}

View File

@@ -0,0 +1,137 @@
"""
Sentiment analysis tasks
"""
from typing import Optional
import structlog
from openai import OpenAI
from app.workers.celery_app import celery_app
from app.core.config import settings
logger = structlog.get_logger()
def get_openai_client() -> Optional[OpenAI]:
"""Get OpenAI client if configured."""
if settings.OPENAI_API_KEY:
return OpenAI(api_key=settings.OPENAI_API_KEY)
return None
@celery_app.task(name="app.workers.tasks.sentiment_tasks.process_unanalyzed_news")
def process_unanalyzed_news():
"""Process all news articles that haven't been sentiment analyzed."""
logger.info("Starting sentiment analysis batch")
# Placeholder - would query database for unprocessed articles
processed_count = 0
logger.info("Sentiment analysis complete", processed=processed_count)
return {"processed": processed_count}
@celery_app.task(name="app.workers.tasks.sentiment_tasks.analyze_sentiment")
def analyze_sentiment(article_id: str, title: str, content: str):
"""Analyze sentiment of a single article using OpenAI."""
logger.info("Analyzing sentiment", article_id=article_id)
client = get_openai_client()
if not client:
logger.warning("OpenAI not configured, using fallback")
return fallback_sentiment_analysis(title, content)
try:
# Prepare text (limit length)
text = f"Title: {title}\n\nContent: {content[:2000]}"
response = client.chat.completions.create(
model=settings.OPENAI_MODEL,
messages=[
{
"role": "system",
"content": """You are a financial sentiment analyzer. Analyze the given news article and respond with a JSON object containing:
- score: a number from -100 (extremely negative/panic) to +100 (extremely positive/euphoric)
- label: one of "negative", "neutral", or "positive"
- confidence: a number from 0 to 1 indicating confidence in the analysis
- stocks: list of stock symbols mentioned (if any)
- summary: one-sentence summary of the sentiment
Focus on:
- Financial impact
- Market reaction implications
- Panic/fear indicators
- Earnings/guidance implications
"""
},
{
"role": "user",
"content": text
}
],
response_format={"type": "json_object"},
temperature=0.3,
)
result = response.choices[0].message.content
logger.info("Sentiment analyzed", article_id=article_id, result=result)
return result
except Exception as e:
logger.error("Sentiment analysis failed", article_id=article_id, error=str(e))
return fallback_sentiment_analysis(title, content)
def fallback_sentiment_analysis(title: str, content: str) -> dict:
"""Simple keyword-based sentiment analysis as fallback."""
text = f"{title} {content}".lower()
negative_words = [
"crash", "plunge", "collapse", "scandal", "fraud", "lawsuit",
"investigation", "bankruptcy", "layoffs", "miss", "decline",
"warning", "downgrade", "sell", "bear", "crisis", "fear",
"panic", "loss", "debt", "default", "recession"
]
positive_words = [
"surge", "rally", "growth", "profit", "beat", "upgrade",
"buy", "bull", "record", "breakout", "opportunity",
"dividend", "expansion", "innovation", "deal", "acquisition"
]
neg_count = sum(1 for word in negative_words if word in text)
pos_count = sum(1 for word in positive_words if word in text)
total = neg_count + pos_count
if total == 0:
score = 0
label = "neutral"
else:
score = ((pos_count - neg_count) / total) * 100
if score < -20:
label = "negative"
elif score > 20:
label = "positive"
else:
label = "neutral"
return {
"score": round(score, 2),
"label": label,
"confidence": min(0.3 + (total * 0.1), 0.7), # Low confidence for fallback
"stocks": [],
"summary": "Analyzed using keyword matching (fallback)",
}
@celery_app.task(name="app.workers.tasks.sentiment_tasks.batch_analyze")
def batch_analyze(article_ids: list):
"""Analyze multiple articles in batch."""
logger.info("Starting batch analysis", count=len(article_ids))
results = []
for article_id in article_ids:
# Would fetch article from database and analyze
results.append({"article_id": article_id, "status": "pending"})
return {"analyzed": len(results), "results": results}

View File

@@ -0,0 +1,102 @@
"""
Stock data fetching tasks
"""
import yfinance as yf
import structlog
from app.workers.celery_app import celery_app
from app.core.config import settings
logger = structlog.get_logger()
@celery_app.task(name="app.workers.tasks.stock_tasks.update_stock_prices")
def update_stock_prices():
"""Update prices for all tracked stocks."""
logger.info("Starting stock price update")
# Placeholder - would get active stocks from database
# For now, just demonstrate the concept
updated_count = 0
logger.info("Stock prices updated", count=updated_count)
return {"updated": updated_count}
@celery_app.task(name="app.workers.tasks.stock_tasks.fetch_stock_price")
def fetch_stock_price(symbol: str):
"""Fetch current price for a single stock."""
logger.info("Fetching price", symbol=symbol)
try:
ticker = yf.Ticker(symbol)
info = ticker.info
return {
"symbol": symbol,
"price": info.get("currentPrice") or info.get("regularMarketPrice"),
"previous_close": info.get("previousClose"),
"volume": info.get("volume"),
"market_cap": info.get("marketCap"),
}
except Exception as e:
logger.error("Failed to fetch price", symbol=symbol, error=str(e))
return None
@celery_app.task(name="app.workers.tasks.stock_tasks.fetch_historical_data")
def fetch_historical_data(symbol: str, period: str = "10y"):
"""Fetch historical price data for a stock."""
logger.info("Fetching historical data", symbol=symbol, period=period)
try:
ticker = yf.Ticker(symbol)
hist = ticker.history(period=period)
# Convert to list of dicts for storage
records = []
for idx, row in hist.iterrows():
records.append({
"time": idx.isoformat(),
"open": row["Open"],
"high": row["High"],
"low": row["Low"],
"close": row["Close"],
"volume": row["Volume"],
})
logger.info(
"Historical data fetched",
symbol=symbol,
records=len(records)
)
return {"symbol": symbol, "records": len(records)}
except Exception as e:
logger.error("Failed to fetch historical data", symbol=symbol, error=str(e))
return None
@celery_app.task(name="app.workers.tasks.stock_tasks.update_stock_info")
def update_stock_info(symbol: str):
"""Update stock metadata (sector, industry, market cap, etc.)."""
logger.info("Updating stock info", symbol=symbol)
try:
ticker = yf.Ticker(symbol)
info = ticker.info
return {
"symbol": symbol,
"name": info.get("longName") or info.get("shortName"),
"sector": info.get("sector"),
"industry": info.get("industry"),
"market_cap": info.get("marketCap"),
"exchange": info.get("exchange"),
"country": info.get("country"),
}
except Exception as e:
logger.error("Failed to update stock info", symbol=symbol, error=str(e))
return None

70
backend/requirements.txt Normal file
View File

@@ -0,0 +1,70 @@
# FastAPI & Web
fastapi==0.109.2
uvicorn[standard]==0.27.1
python-multipart==0.0.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
httpx==0.26.0
aiohttp==3.9.3
websockets==12.0
# Database
sqlalchemy==2.0.25
asyncpg==0.29.0
psycopg2-binary==2.9.9
alembic==1.13.1
# Redis & Caching
redis==5.0.1
aioredis==2.0.1
# Celery & Task Queue
celery==5.3.6
flower==2.0.1
# Data Processing
pandas==2.2.0
numpy==1.26.3
scipy==1.12.0
# Stock Data
yfinance==0.2.36
alpha-vantage==2.3.1
finnhub-python==2.4.19
# News & Web Scraping
feedparser==6.0.11
newspaper3k==0.2.8
beautifulsoup4==4.12.3
lxml==5.1.0
# NLP & Sentiment
openai==1.12.0
tiktoken==0.5.2
nltk==3.8.1
textblob==0.17.1
transformers==4.37.2
torch==2.2.0
# Validation & Serialization
pydantic==2.6.1
pydantic-settings==2.1.0
# Utilities
python-dotenv==1.0.1
structlog==24.1.0
tenacity==8.2.3
python-dateutil==2.8.2
pytz==2024.1
# Testing
pytest==8.0.0
pytest-asyncio==0.23.4
pytest-cov==4.1.0
httpx==0.26.0
# Development
black==24.1.1
isort==5.13.2
flake8==7.0.0
mypy==1.8.0

222
docker-compose.yml Normal file
View File

@@ -0,0 +1,222 @@
version: '3.9'
services:
# =============================================================================
# DATABASE - TimescaleDB (PostgreSQL with time-series superpowers)
# =============================================================================
db:
image: timescale/timescaledb:latest-pg16
container_name: marketscanner-db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-marketscanner}
POSTGRES_USER: ${POSTGRES_USER:-marketscanner}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-marketscanner}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- marketscanner-network
# =============================================================================
# CACHE - Redis
# =============================================================================
redis:
image: redis:7-alpine
container_name: marketscanner-redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD:-changeme}
volumes:
- redis_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- marketscanner-network
# =============================================================================
# MESSAGE QUEUE - RabbitMQ
# =============================================================================
rabbitmq:
image: rabbitmq:3-management-alpine
container_name: marketscanner-rabbitmq
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-marketscanner}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-changeme}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
ports:
- "5672:5672"
- "15672:15672" # Management UI
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_running"]
interval: 30s
timeout: 10s
retries: 5
networks:
- marketscanner-network
# =============================================================================
# BACKEND - FastAPI
# =============================================================================
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: marketscanner-backend
restart: unless-stopped
env_file:
- .env
environment:
- POSTGRES_HOST=db
- REDIS_HOST=redis
- RABBITMQ_HOST=rabbitmq
volumes:
- ./backend:/app
- backend_logs:/app/logs
ports:
- "${BACKEND_PORT:-8000}:8000"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
- marketscanner-network
# =============================================================================
# CELERY WORKER - Background Tasks
# =============================================================================
celery-worker:
build:
context: ./backend
dockerfile: Dockerfile
container_name: marketscanner-celery-worker
restart: unless-stopped
command: celery -A app.workers.celery_app worker --loglevel=info --concurrency=4
env_file:
- .env
environment:
- POSTGRES_HOST=db
- REDIS_HOST=redis
- RABBITMQ_HOST=rabbitmq
volumes:
- ./backend:/app
- backend_logs:/app/logs
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
backend:
condition: service_healthy
networks:
- marketscanner-network
# =============================================================================
# CELERY BEAT - Scheduled Tasks
# =============================================================================
celery-beat:
build:
context: ./backend
dockerfile: Dockerfile
container_name: marketscanner-celery-beat
restart: unless-stopped
command: celery -A app.workers.celery_app beat --loglevel=info
env_file:
- .env
environment:
- POSTGRES_HOST=db
- REDIS_HOST=redis
- RABBITMQ_HOST=rabbitmq
volumes:
- ./backend:/app
- backend_logs:/app/logs
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
backend:
condition: service_healthy
networks:
- marketscanner-network
# =============================================================================
# FRONTEND - React Dashboard
# =============================================================================
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
- VITE_API_URL=${VITE_API_URL:-http://localhost:8000}
container_name: marketscanner-frontend
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-3000}:80"
depends_on:
- backend
networks:
- marketscanner-network
# =============================================================================
# OPTIONAL: Ollama for local LLM (if USE_LOCAL_LLM=true)
# =============================================================================
# ollama:
# image: ollama/ollama:latest
# container_name: marketscanner-ollama
# restart: unless-stopped
# volumes:
# - ollama_data:/root/.ollama
# ports:
# - "11434:11434"
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
# networks:
# - marketscanner-network
# =============================================================================
# NETWORKS
# =============================================================================
networks:
marketscanner-network:
driver: bridge
# =============================================================================
# VOLUMES
# =============================================================================
volumes:
postgres_data:
redis_data:
rabbitmq_data:
backend_logs:
# ollama_data:

343
docker/db/init.sql Normal file
View File

@@ -0,0 +1,343 @@
-- MarketScanner Database Initialization
-- TimescaleDB + PostgreSQL
-- Enable TimescaleDB extension
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Enable pg_trgm for text search
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- =============================================================================
-- STOCKS TABLE
-- =============================================================================
CREATE TABLE IF NOT EXISTS stocks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
symbol VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
sector VARCHAR(100),
industry VARCHAR(100),
market_cap BIGINT,
exchange VARCHAR(50),
country VARCHAR(100) DEFAULT 'USA',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_stocks_symbol ON stocks(symbol);
CREATE INDEX idx_stocks_sector ON stocks(sector);
CREATE INDEX idx_stocks_industry ON stocks(industry);
-- =============================================================================
-- STOCK PRICES (Time-series)
-- =============================================================================
CREATE TABLE IF NOT EXISTS stock_prices (
time TIMESTAMPTZ NOT NULL,
stock_id UUID NOT NULL REFERENCES stocks(id) ON DELETE CASCADE,
open DECIMAL(15, 4),
high DECIMAL(15, 4),
low DECIMAL(15, 4),
close DECIMAL(15, 4) NOT NULL,
volume BIGINT,
adjusted_close DECIMAL(15, 4),
PRIMARY KEY (time, stock_id)
);
-- Convert to hypertable for time-series optimization
SELECT create_hypertable('stock_prices', 'time', if_not_exists => TRUE);
-- Create indexes
CREATE INDEX idx_stock_prices_stock_id ON stock_prices(stock_id, time DESC);
-- =============================================================================
-- NEWS ARTICLES
-- =============================================================================
CREATE TABLE IF NOT EXISTS news_articles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
content TEXT,
summary TEXT,
url TEXT UNIQUE NOT NULL,
source VARCHAR(100) NOT NULL,
author VARCHAR(255),
published_at TIMESTAMPTZ NOT NULL,
fetched_at TIMESTAMPTZ DEFAULT NOW(),
image_url TEXT,
-- Sentiment analysis results
sentiment_score DECIMAL(5, 2), -- -100 to +100
sentiment_label VARCHAR(20), -- negative, neutral, positive
sentiment_confidence DECIMAL(5, 4),
-- Processing status
is_processed BOOLEAN DEFAULT false,
processing_error TEXT
);
CREATE INDEX idx_news_published_at ON news_articles(published_at DESC);
CREATE INDEX idx_news_source ON news_articles(source);
CREATE INDEX idx_news_sentiment ON news_articles(sentiment_score);
CREATE INDEX idx_news_title_trgm ON news_articles USING gin(title gin_trgm_ops);
-- =============================================================================
-- NEWS-STOCK ASSOCIATIONS
-- =============================================================================
CREATE TABLE IF NOT EXISTS news_stock_mentions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
news_id UUID NOT NULL REFERENCES news_articles(id) ON DELETE CASCADE,
stock_id UUID NOT NULL REFERENCES stocks(id) ON DELETE CASCADE,
mention_type VARCHAR(50), -- direct, indirect, sector
relevance_score DECIMAL(5, 4),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(news_id, stock_id)
);
CREATE INDEX idx_mentions_news ON news_stock_mentions(news_id);
CREATE INDEX idx_mentions_stock ON news_stock_mentions(stock_id);
-- =============================================================================
-- PANIC EVENTS
-- =============================================================================
CREATE TABLE IF NOT EXISTS panic_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
stock_id UUID NOT NULL REFERENCES stocks(id) ON DELETE CASCADE,
-- Event timing
start_time TIMESTAMPTZ NOT NULL,
peak_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
-- Price impact
price_at_start DECIMAL(15, 4) NOT NULL,
price_at_peak_panic DECIMAL(15, 4),
price_at_end DECIMAL(15, 4),
max_drawdown_percent DECIMAL(8, 4),
-- Sentiment
avg_sentiment_score DECIMAL(5, 2),
min_sentiment_score DECIMAL(5, 2),
news_volume INTEGER,
-- Recovery metrics
recovery_time_days INTEGER,
recovery_percent DECIMAL(8, 4),
-- Classification
event_type VARCHAR(100), -- earnings_miss, scandal, lawsuit, macro, etc.
event_category VARCHAR(50), -- company_specific, sector_wide, market_wide
-- Analysis
is_complete BOOLEAN DEFAULT false,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_panic_stock ON panic_events(stock_id);
CREATE INDEX idx_panic_time ON panic_events(start_time DESC);
CREATE INDEX idx_panic_type ON panic_events(event_type);
CREATE INDEX idx_panic_drawdown ON panic_events(max_drawdown_percent);
-- =============================================================================
-- HISTORICAL PATTERNS
-- =============================================================================
CREATE TABLE IF NOT EXISTS historical_patterns (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
stock_id UUID NOT NULL REFERENCES stocks(id) ON DELETE CASCADE,
-- Pattern definition
pattern_type VARCHAR(100) NOT NULL, -- earnings_miss_recovery, scandal_recovery, etc.
-- Statistics (aggregated from multiple panic events)
avg_drawdown_percent DECIMAL(8, 4),
avg_recovery_days INTEGER,
avg_recovery_percent DECIMAL(8, 4),
median_drawdown_percent DECIMAL(8, 4),
median_recovery_days INTEGER,
median_recovery_percent DECIMAL(8, 4),
-- Sample size
event_count INTEGER NOT NULL,
-- Confidence
pattern_confidence DECIMAL(5, 4),
-- Time range
first_event_date DATE,
last_event_date DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(stock_id, pattern_type)
);
CREATE INDEX idx_patterns_stock ON historical_patterns(stock_id);
CREATE INDEX idx_patterns_type ON historical_patterns(pattern_type);
-- =============================================================================
-- BUY SIGNALS
-- =============================================================================
CREATE TABLE IF NOT EXISTS buy_signals (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
stock_id UUID NOT NULL REFERENCES stocks(id) ON DELETE CASCADE,
panic_event_id UUID REFERENCES panic_events(id) ON DELETE SET NULL,
-- Signal details
signal_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
signal_price DECIMAL(15, 4) NOT NULL,
-- Confidence scoring
confidence_score DECIMAL(5, 4) NOT NULL, -- 0 to 1
-- Based on pattern
pattern_id UUID REFERENCES historical_patterns(id) ON DELETE SET NULL,
expected_recovery_percent DECIMAL(8, 4),
expected_recovery_days INTEGER,
-- Current metrics
current_drawdown_percent DECIMAL(8, 4),
current_sentiment_score DECIMAL(5, 2),
-- Signal status
status VARCHAR(20) DEFAULT 'active', -- active, triggered, expired, cancelled
triggered_at TIMESTAMPTZ,
-- Outcome tracking
outcome_price DECIMAL(15, 4),
outcome_percent DECIMAL(8, 4),
outcome_days INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_signals_stock ON buy_signals(stock_id);
CREATE INDEX idx_signals_time ON buy_signals(signal_time DESC);
CREATE INDEX idx_signals_confidence ON buy_signals(confidence_score DESC);
CREATE INDEX idx_signals_status ON buy_signals(status);
-- =============================================================================
-- WATCHLIST
-- =============================================================================
CREATE TABLE IF NOT EXISTS watchlist (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
stock_id UUID NOT NULL REFERENCES stocks(id) ON DELETE CASCADE,
-- Alert thresholds
panic_alert_threshold DECIMAL(5, 2) DEFAULT -50,
price_alert_low DECIMAL(15, 4),
price_alert_high DECIMAL(15, 4),
-- Preferences
priority INTEGER DEFAULT 1, -- 1 = high, 2 = medium, 3 = low
notes TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(stock_id)
);
CREATE INDEX idx_watchlist_priority ON watchlist(priority, is_active);
-- =============================================================================
-- ALERT HISTORY
-- =============================================================================
CREATE TABLE IF NOT EXISTS alert_history (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
stock_id UUID NOT NULL REFERENCES stocks(id) ON DELETE CASCADE,
signal_id UUID REFERENCES buy_signals(id) ON DELETE SET NULL,
alert_type VARCHAR(50) NOT NULL, -- panic_detected, buy_signal, price_target, etc.
alert_message TEXT NOT NULL,
-- Delivery status
sent_telegram BOOLEAN DEFAULT false,
sent_discord BOOLEAN DEFAULT false,
sent_email BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_alerts_time ON alert_history(created_at DESC);
CREATE INDEX idx_alerts_stock ON alert_history(stock_id);
-- =============================================================================
-- FUNCTIONS
-- =============================================================================
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply triggers
CREATE TRIGGER update_stocks_updated_at BEFORE UPDATE ON stocks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_panic_events_updated_at BEFORE UPDATE ON panic_events FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_patterns_updated_at BEFORE UPDATE ON historical_patterns FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_signals_updated_at BEFORE UPDATE ON buy_signals FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_watchlist_updated_at BEFORE UPDATE ON watchlist FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- =============================================================================
-- SEED DATA - Popular stocks to get started
-- =============================================================================
INSERT INTO stocks (symbol, name, sector, industry, exchange) VALUES
('AAPL', 'Apple Inc.', 'Technology', 'Consumer Electronics', 'NASDAQ'),
('MSFT', 'Microsoft Corporation', 'Technology', 'Software', 'NASDAQ'),
('GOOGL', 'Alphabet Inc.', 'Technology', 'Internet Services', 'NASDAQ'),
('AMZN', 'Amazon.com Inc.', 'Consumer Discretionary', 'E-Commerce', 'NASDAQ'),
('NVDA', 'NVIDIA Corporation', 'Technology', 'Semiconductors', 'NASDAQ'),
('META', 'Meta Platforms Inc.', 'Technology', 'Social Media', 'NASDAQ'),
('TSLA', 'Tesla Inc.', 'Consumer Discretionary', 'Electric Vehicles', 'NASDAQ'),
('JPM', 'JPMorgan Chase & Co.', 'Financials', 'Banking', 'NYSE'),
('V', 'Visa Inc.', 'Financials', 'Payments', 'NYSE'),
('JNJ', 'Johnson & Johnson', 'Healthcare', 'Pharmaceuticals', 'NYSE'),
('WMT', 'Walmart Inc.', 'Consumer Staples', 'Retail', 'NYSE'),
('XOM', 'Exxon Mobil Corporation', 'Energy', 'Oil & Gas', 'NYSE'),
('BA', 'Boeing Company', 'Industrials', 'Aerospace', 'NYSE'),
('DIS', 'Walt Disney Company', 'Communication Services', 'Entertainment', 'NYSE'),
('NFLX', 'Netflix Inc.', 'Communication Services', 'Streaming', 'NASDAQ'),
('AMD', 'Advanced Micro Devices', 'Technology', 'Semiconductors', 'NASDAQ'),
('INTC', 'Intel Corporation', 'Technology', 'Semiconductors', 'NASDAQ'),
('CRM', 'Salesforce Inc.', 'Technology', 'Software', 'NYSE'),
('ORCL', 'Oracle Corporation', 'Technology', 'Software', 'NYSE'),
('PYPL', 'PayPal Holdings Inc.', 'Financials', 'Payments', 'NASDAQ'),
-- Defense & Weapons (no morals, remember?)
('LMT', 'Lockheed Martin Corporation', 'Industrials', 'Defense', 'NYSE'),
('RTX', 'RTX Corporation', 'Industrials', 'Defense', 'NYSE'),
('NOC', 'Northrop Grumman Corporation', 'Industrials', 'Defense', 'NYSE'),
('GD', 'General Dynamics Corporation', 'Industrials', 'Defense', 'NYSE'),
('BA', 'Boeing Company', 'Industrials', 'Aerospace & Defense', 'NYSE'),
-- Mining
('NEM', 'Newmont Corporation', 'Materials', 'Gold Mining', 'NYSE'),
('FCX', 'Freeport-McMoRan Inc.', 'Materials', 'Copper Mining', 'NYSE'),
('RIO', 'Rio Tinto Group', 'Materials', 'Diversified Mining', 'NYSE'),
('BHP', 'BHP Group Limited', 'Materials', 'Diversified Mining', 'NYSE'),
-- Food & Agriculture
('ADM', 'Archer-Daniels-Midland Company', 'Consumer Staples', 'Agriculture', 'NYSE'),
('BG', 'Bunge Limited', 'Consumer Staples', 'Agriculture', 'NYSE'),
('MDLZ', 'Mondelez International', 'Consumer Staples', 'Food', 'NASDAQ'),
('KO', 'Coca-Cola Company', 'Consumer Staples', 'Beverages', 'NYSE'),
('PEP', 'PepsiCo Inc.', 'Consumer Staples', 'Beverages', 'NASDAQ'),
-- Oil & Gas
('CVX', 'Chevron Corporation', 'Energy', 'Oil & Gas', 'NYSE'),
('COP', 'ConocoPhillips', 'Energy', 'Oil & Gas', 'NYSE'),
('SLB', 'Schlumberger Limited', 'Energy', 'Oil Services', 'NYSE'),
-- Pharma & Biotech
('PFE', 'Pfizer Inc.', 'Healthcare', 'Pharmaceuticals', 'NYSE'),
('MRK', 'Merck & Co. Inc.', 'Healthcare', 'Pharmaceuticals', 'NYSE'),
('ABBV', 'AbbVie Inc.', 'Healthcare', 'Pharmaceuticals', 'NYSE'),
('BMY', 'Bristol-Myers Squibb', 'Healthcare', 'Pharmaceuticals', 'NYSE')
ON CONFLICT (symbol) DO NOTHING;
COMMIT;

34
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build arguments
ARG VITE_API_URL=http://localhost:8000
ENV VITE_API_URL=$VITE_API_URL
# Build the app
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="MarketScanner - Fear-to-Fortune Trading Intelligence" />
<title>MarketScanner | Buy the Fear</title>
</head>
<body class="bg-gray-950 text-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

35
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,35 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# Handle SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API proxy (optional, for same-origin requests)
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
}

43
frontend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "marketscanner-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/react": "^1.7.18",
"@heroicons/react": "^2.1.1",
"@tanstack/react-query": "^5.17.19",
"axios": "^1.6.7",
"clsx": "^2.1.0",
"date-fns": "^3.3.1",
"framer-motion": "^11.0.3",
"lightweight-charts": "^4.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.21.3",
"recharts": "^2.12.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.12"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

25
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Signals from './pages/Signals'
import Stocks from './pages/Stocks'
import News from './pages/News'
import Watchlist from './pages/Watchlist'
import Analytics from './pages/Analytics'
function App() {
return (
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/signals" element={<Signals />} />
<Route path="/stocks" element={<Stocks />} />
<Route path="/news" element={<News />} />
<Route path="/watchlist" element={<Watchlist />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Layout>
)
}
export default App

View File

@@ -0,0 +1,172 @@
import { ReactNode, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { motion } from 'framer-motion'
import {
HomeIcon,
BellAlertIcon,
ChartBarIcon,
NewspaperIcon,
StarIcon,
ChartPieIcon,
Bars3Icon,
XMarkIcon,
} from '@heroicons/react/24/outline'
import clsx from 'clsx'
interface LayoutProps {
children: ReactNode
}
const navigation = [
{ name: 'Dashboard', href: '/', icon: HomeIcon },
{ name: 'Buy Signals', href: '/signals', icon: BellAlertIcon },
{ name: 'Stocks', href: '/stocks', icon: ChartBarIcon },
{ name: 'News', href: '/news', icon: NewspaperIcon },
{ name: 'Watchlist', href: '/watchlist', icon: StarIcon },
{ name: 'Analytics', href: '/analytics', icon: ChartPieIcon },
]
export default function Layout({ children }: LayoutProps) {
const location = useLocation()
const [sidebarOpen, setSidebarOpen] = useState(false)
return (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-900 to-gray-950">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={clsx(
'fixed inset-y-0 left-0 z-50 w-64 bg-gray-900/95 backdrop-blur-xl border-r border-gray-800 transform transition-transform duration-300 lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
<div className="flex flex-col h-full">
{/* Logo */}
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-800">
<Link to="/" className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-green-500 to-emerald-600 rounded-xl flex items-center justify-center">
<ChartBarIcon className="w-6 h-6 text-white" />
</div>
<div>
<span className="font-bold text-lg gradient-text">MarketScanner</span>
<p className="text-xs text-gray-500">Buy the Fear</p>
</div>
</Link>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden p-1 rounded-lg hover:bg-gray-800"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{navigation.map((item) => {
const isActive = location.pathname === item.href
return (
<Link
key={item.name}
to={item.href}
onClick={() => setSidebarOpen(false)}
className={clsx(
'flex items-center px-3 py-2.5 rounded-lg transition-all duration-200',
isActive
? 'bg-green-500/20 text-green-400 border border-green-500/30'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
)}
>
<item.icon className={clsx('w-5 h-5 mr-3', isActive && 'text-green-400')} />
{item.name}
{item.name === 'Buy Signals' && (
<span className="ml-auto badge-success">3</span>
)}
</Link>
)
})}
</nav>
{/* Bottom section */}
<div className="p-4 border-t border-gray-800">
<div className="card p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500">Market Sentiment</span>
<span className="badge-danger">Fear</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<motion.div
className="h-full bg-gradient-to-r from-red-500 to-orange-500"
initial={{ width: 0 }}
animate={{ width: '35%' }}
transition={{ duration: 1, ease: 'easeOut' }}
/>
</div>
<div className="flex justify-between mt-1 text-xs text-gray-600">
<span>Extreme Fear</span>
<span>Greed</span>
</div>
</div>
</div>
</div>
</aside>
{/* Main content */}
<div className="lg:pl-64">
{/* Top bar */}
<header className="sticky top-0 z-30 h-16 bg-gray-900/80 backdrop-blur-xl border-b border-gray-800">
<div className="flex items-center justify-between h-full px-4">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden p-2 rounded-lg hover:bg-gray-800"
>
<Bars3Icon className="w-6 h-6" />
</button>
<div className="flex items-center space-x-4">
{/* Search */}
<div className="hidden md:block">
<input
type="text"
placeholder="Search stocks..."
className="input w-64"
/>
</div>
</div>
<div className="flex items-center space-x-4">
{/* Market status */}
<div className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-sm text-gray-400">Market Open</span>
</div>
{/* Time */}
<div className="hidden sm:block text-sm text-gray-500">
{new Date().toLocaleTimeString()}
</div>
</div>
</div>
</header>
{/* Page content */}
<main className="p-4 md:p-6 lg:p-8">
<motion.div
key={location.pathname}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
</main>
</div>
</div>
)
}

88
frontend/src/index.css Normal file
View File

@@ -0,0 +1,88 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom scrollbar for dark mode */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a2e;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
/* Custom styles */
@layer components {
.card {
@apply bg-gray-900/50 backdrop-blur-sm border border-gray-800 rounded-xl;
}
.card-hover {
@apply card transition-all duration-300 hover:border-gray-700 hover:shadow-lg hover:shadow-green-500/5;
}
.btn-primary {
@apply px-4 py-2 bg-green-600 hover:bg-green-500 text-white font-medium rounded-lg transition-colors;
}
.btn-secondary {
@apply px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white font-medium rounded-lg transition-colors;
}
.btn-danger {
@apply px-4 py-2 bg-red-600 hover:bg-red-500 text-white font-medium rounded-lg transition-colors;
}
.input {
@apply w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:outline-none focus:border-green-500 text-white placeholder-gray-500;
}
.badge {
@apply px-2 py-0.5 text-xs font-medium rounded-full;
}
.badge-success {
@apply badge bg-green-500/20 text-green-400 border border-green-500/30;
}
.badge-danger {
@apply badge bg-red-500/20 text-red-400 border border-red-500/30;
}
.badge-warning {
@apply badge bg-yellow-500/20 text-yellow-400 border border-yellow-500/30;
}
.badge-info {
@apply badge bg-blue-500/20 text-blue-400 border border-blue-500/30;
}
.gradient-text {
@apply bg-gradient-to-r from-green-400 via-emerald-400 to-teal-400 bg-clip-text text-transparent;
}
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
/* Number styling */
.font-mono {
font-variant-numeric: tabular-nums;
}

33
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,33 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30000, // 30 seconds
retry: 2,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster
position="top-right"
toastOptions={{
className: 'bg-gray-800 text-white',
duration: 4000,
}}
/>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,231 @@
import { motion } from 'framer-motion'
import {
ChartPieIcon,
ArrowTrendingUpIcon,
ClockIcon,
CheckCircleIcon,
} from '@heroicons/react/24/outline'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
AreaChart,
Area,
} from 'recharts'
// Mock data
const performanceData = [
{ date: 'Jan', signals: 5, successful: 4, return: 12.5 },
{ date: 'Feb', signals: 8, successful: 6, return: 18.2 },
{ date: 'Mar', signals: 3, successful: 2, return: 8.5 },
{ date: 'Apr', signals: 6, successful: 5, return: 15.3 },
{ date: 'May', signals: 4, successful: 4, return: 22.1 },
{ date: 'Jun', signals: 7, successful: 5, return: 14.8 },
]
const sentimentTrend = [
{ date: 'Mon', sentiment: -15 },
{ date: 'Tue', sentiment: -25 },
{ date: 'Wed', sentiment: -45 },
{ date: 'Thu', sentiment: -38 },
{ date: 'Fri', sentiment: -52 },
{ date: 'Sat', sentiment: -48 },
{ date: 'Sun', sentiment: -35 },
]
const topPatterns = [
{ type: 'Earnings Miss Recovery', avgRecovery: 28.5, avgDays: 45, successRate: 82 },
{ type: 'Scandal/PR Crisis', avgRecovery: 35.2, avgDays: 60, successRate: 75 },
{ type: 'Product Recall', avgRecovery: 22.8, avgDays: 35, successRate: 78 },
{ type: 'Sector Rotation', avgRecovery: 18.5, avgDays: 25, successRate: 85 },
{ type: 'Market Correction', avgRecovery: 42.3, avgDays: 90, successRate: 72 },
]
const stats = [
{ label: 'Total Signals', value: '33', icon: ChartPieIcon },
{ label: 'Success Rate', value: '79%', icon: CheckCircleIcon },
{ label: 'Avg Return', value: '+15.2%', icon: ArrowTrendingUpIcon },
{ label: 'Avg Hold Time', value: '42 days', icon: ClockIcon },
]
export default function Analytics() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold gradient-text">Analytics</h1>
<p className="text-gray-500 mt-1">Performance metrics and pattern analysis</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat, index) => (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="card p-5"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">{stat.label}</p>
<p className="text-2xl font-bold mt-1 gradient-text">{stat.value}</p>
</div>
<stat.icon className="w-8 h-8 text-green-400/50" />
</div>
</motion.div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Signal Performance Chart */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
className="card p-5"
>
<h2 className="text-lg font-semibold mb-4">Signal Performance</h2>
<ResponsiveContainer width="100%" height={250}>
<AreaChart data={performanceData}>
<defs>
<linearGradient id="returnGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="date" stroke="#6b7280" />
<YAxis stroke="#6b7280" />
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: '1px solid #374151',
borderRadius: '8px',
}}
/>
<Area
type="monotone"
dataKey="return"
stroke="#22c55e"
fill="url(#returnGradient)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</motion.div>
{/* Sentiment Trend */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
className="card p-5"
>
<h2 className="text-lg font-semibold mb-4">Market Sentiment (7 Days)</h2>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={sentimentTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="date" stroke="#6b7280" />
<YAxis stroke="#6b7280" domain={[-100, 100]} />
<Tooltip
contentStyle={{
backgroundColor: '#1f2937',
border: '1px solid #374151',
borderRadius: '8px',
}}
/>
<Line
type="monotone"
dataKey="sentiment"
stroke="#ef4444"
strokeWidth={2}
dot={{ fill: '#ef4444', strokeWidth: 2 }}
/>
{/* Zero line */}
<Line
type="monotone"
dataKey={() => 0}
stroke="#6b7280"
strokeDasharray="5 5"
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</motion.div>
</div>
{/* Top Patterns */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="card p-5"
>
<h2 className="text-lg font-semibold mb-4">Top Historical Patterns</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Pattern Type</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Avg Recovery</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Avg Days</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Success Rate</th>
</tr>
</thead>
<tbody>
{topPatterns.map((pattern, index) => (
<motion.tr
key={pattern.type}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 + index * 0.1 }}
className="border-b border-gray-800/50"
>
<td className="px-4 py-4 font-medium">{pattern.type}</td>
<td className="px-4 py-4 text-right text-green-400 font-mono">
+{pattern.avgRecovery}%
</td>
<td className="px-4 py-4 text-right text-gray-400 font-mono">
{pattern.avgDays}
</td>
<td className="px-4 py-4 text-right">
<div className="flex items-center justify-end">
<div className="w-16 h-2 bg-gray-700 rounded-full overflow-hidden mr-2">
<div
className="h-full bg-green-500"
style={{ width: `${pattern.successRate}%` }}
/>
</div>
<span className="text-green-400 font-medium">{pattern.successRate}%</span>
</div>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
</motion.div>
{/* Insight */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.9 }}
className="card p-5 border-green-500/30 bg-green-500/5"
>
<h3 className="font-semibold text-green-400 mb-2">💡 Key Insight</h3>
<p className="text-gray-300">
Based on historical data, <strong>Earnings Miss Recovery</strong> patterns have the highest
success rate (82%), while <strong>Market Correction</strong> events offer the highest
average return (+42.3%) but require longer hold times. Consider your risk tolerance
and time horizon when acting on signals.
</p>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,242 @@
import { motion } from 'framer-motion'
import {
ArrowTrendingUpIcon,
ArrowTrendingDownIcon,
BellAlertIcon,
NewspaperIcon,
ChartBarIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline'
// Mock data - will be replaced with API calls
const stats = [
{ name: 'Active Signals', value: '3', change: '+2', trend: 'up', icon: BellAlertIcon },
{ name: 'Stocks Tracked', value: '47', change: '+5', trend: 'up', icon: ChartBarIcon },
{ name: 'News Today', value: '156', change: '+23', trend: 'up', icon: NewspaperIcon },
{ name: 'Panic Events', value: '2', change: '+1', trend: 'down', icon: ExclamationTriangleIcon },
]
const topSignals = [
{ symbol: 'NVDA', confidence: 87, price: 485.23, drawdown: -18.5, expectedRecovery: 35 },
{ symbol: 'META', confidence: 76, price: 345.67, drawdown: -12.3, expectedRecovery: 25 },
{ symbol: 'TSLA', confidence: 71, price: 178.90, drawdown: -25.8, expectedRecovery: 45 },
]
const recentNews = [
{ title: 'NVIDIA faces supply chain concerns amid AI boom', sentiment: -45, time: '2h ago' },
{ title: 'Meta announces layoffs in Reality Labs division', sentiment: -62, time: '4h ago' },
{ title: 'Fed signals potential rate cuts in 2024', sentiment: 35, time: '5h ago' },
{ title: 'Tesla recalls 2M vehicles over autopilot issues', sentiment: -78, time: '6h ago' },
]
const sectorHeatmap = [
{ sector: 'Technology', sentiment: -25, change: -5.2 },
{ sector: 'Healthcare', sentiment: 15, change: 2.1 },
{ sector: 'Energy', sentiment: -45, change: -8.3 },
{ sector: 'Financials', sentiment: 5, change: 0.8 },
{ sector: 'Consumer', sentiment: -35, change: -4.5 },
{ sector: 'Defense', sentiment: 55, change: 12.3 },
]
export default function Dashboard() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold gradient-text">Dashboard</h1>
<p className="text-gray-500 mt-1">Fear-to-Fortune Trading Intelligence</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat, index) => (
<motion.div
key={stat.name}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="card-hover p-5"
>
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-gray-500">{stat.name}</p>
<p className="text-3xl font-bold mt-1">{stat.value}</p>
</div>
<div className={`p-2 rounded-lg ${stat.trend === 'up' ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
<stat.icon className={`w-5 h-5 ${stat.trend === 'up' ? 'text-green-400' : 'text-red-400'}`} />
</div>
</div>
<div className="flex items-center mt-3">
{stat.trend === 'up' ? (
<ArrowTrendingUpIcon className="w-4 h-4 text-green-400 mr-1" />
) : (
<ArrowTrendingDownIcon className="w-4 h-4 text-red-400 mr-1" />
)}
<span className={stat.trend === 'up' ? 'text-green-400' : 'text-red-400'}>
{stat.change}
</span>
<span className="text-gray-500 text-sm ml-2">from yesterday</span>
</div>
</motion.div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Top Buy Signals */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
className="lg:col-span-2 card p-5"
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center">
<BellAlertIcon className="w-5 h-5 mr-2 text-green-400" />
Top Buy Signals
</h2>
<a href="/signals" className="text-sm text-green-400 hover:text-green-300">
View all
</a>
</div>
<div className="space-y-3">
{topSignals.map((signal, index) => (
<motion.div
key={signal.symbol}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 + index * 0.1 }}
className="flex items-center justify-between p-4 bg-gray-800/50 rounded-lg hover:bg-gray-800 transition-colors"
>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-gradient-to-br from-green-500/20 to-emerald-500/20 rounded-xl flex items-center justify-center border border-green-500/30">
<span className="font-bold text-green-400">{signal.symbol.slice(0, 2)}</span>
</div>
<div>
<p className="font-semibold">${signal.symbol}</p>
<p className="text-sm text-gray-500">${signal.price.toFixed(2)}</p>
</div>
</div>
<div className="flex items-center space-x-6">
<div className="text-right">
<p className="text-sm text-gray-500">Drawdown</p>
<p className="text-red-400 font-medium">{signal.drawdown}%</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">Expected</p>
<p className="text-green-400 font-medium">+{signal.expectedRecovery}%</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">Confidence</p>
<div className="flex items-center">
<div className="w-16 h-2 bg-gray-700 rounded-full overflow-hidden mr-2">
<div
className="h-full bg-gradient-to-r from-green-500 to-emerald-400"
style={{ width: `${signal.confidence}%` }}
/>
</div>
<span className="text-green-400 font-bold">{signal.confidence}%</span>
</div>
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
{/* Sector Heatmap */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
className="card p-5"
>
<h2 className="text-lg font-semibold mb-4">Sector Sentiment</h2>
<div className="space-y-2">
{sectorHeatmap.map((sector) => (
<div
key={sector.sector}
className="flex items-center justify-between p-3 rounded-lg"
style={{
background: sector.sentiment > 0
? `rgba(34, 197, 94, ${Math.abs(sector.sentiment) / 200})`
: `rgba(239, 68, 68, ${Math.abs(sector.sentiment) / 200})`,
}}
>
<span className="font-medium">{sector.sector}</span>
<div className="flex items-center space-x-3">
<span className={sector.change > 0 ? 'text-green-400' : 'text-red-400'}>
{sector.change > 0 ? '+' : ''}{sector.change}%
</span>
<span className="text-sm text-gray-400">
({sector.sentiment})
</span>
</div>
</div>
))}
</div>
</motion.div>
</div>
{/* Recent Panic News */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="card p-5"
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center">
<NewspaperIcon className="w-5 h-5 mr-2 text-red-400" />
Recent Panic News
</h2>
<a href="/news" className="text-sm text-green-400 hover:text-green-300">
View all
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{recentNews.map((news, index) => (
<motion.div
key={index}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 + index * 0.1 }}
className="p-4 bg-gray-800/50 rounded-lg hover:bg-gray-800 transition-colors cursor-pointer"
>
<div className="flex items-start justify-between">
<p className="text-sm flex-1 mr-3">{news.title}</p>
<span
className={`badge ${
news.sentiment < -30
? 'badge-danger'
: news.sentiment > 30
? 'badge-success'
: 'badge-warning'
}`}
>
{news.sentiment}
</span>
</div>
<p className="text-xs text-gray-500 mt-2">{news.time}</p>
</motion.div>
))}
</div>
</motion.div>
{/* Quote */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.9 }}
className="text-center py-6"
>
<blockquote className="text-lg italic text-gray-500">
"Buy when there's blood in the streets, even if the blood is your own."
</blockquote>
<cite className="text-sm text-gray-600"> Baron Rothschild</cite>
</motion.div>
</div>
)
}

184
frontend/src/pages/News.tsx Normal file
View File

@@ -0,0 +1,184 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { NewspaperIcon, FunnelIcon } from '@heroicons/react/24/outline'
// Mock data
const news = [
{
id: '1',
title: 'NVIDIA faces supply chain concerns as AI demand continues to surge',
source: 'Reuters',
sentiment: -45,
stocks: ['NVDA', 'AMD'],
time: '2 hours ago',
summary: 'NVIDIA is struggling to meet AI chip demand as supply chain issues persist...',
},
{
id: '2',
title: 'Tesla recalls 2 million vehicles over autopilot safety issues',
source: 'Bloomberg',
sentiment: -78,
stocks: ['TSLA'],
time: '4 hours ago',
summary: 'The recall affects nearly all Tesla vehicles sold in the US...',
},
{
id: '3',
title: 'Meta announces 10,000 layoffs in Reality Labs division',
source: 'CNBC',
sentiment: -62,
stocks: ['META'],
time: '5 hours ago',
summary: 'Meta is cutting costs as metaverse investments continue to drain resources...',
},
{
id: '4',
title: 'Federal Reserve signals potential rate cuts in 2024',
source: 'Wall Street Journal',
sentiment: 45,
stocks: ['SPY', 'QQQ'],
time: '6 hours ago',
summary: 'Fed officials indicate inflation has cooled enough to consider easing...',
},
{
id: '5',
title: 'Lockheed Martin secures $20B defense contract',
source: 'Defense News',
sentiment: 72,
stocks: ['LMT', 'RTX'],
time: '8 hours ago',
summary: 'The Pentagon awards major contract for next-generation fighter jets...',
},
{
id: '6',
title: 'Apple iPhone sales decline in China amid competition',
source: 'Financial Times',
sentiment: -35,
stocks: ['AAPL'],
time: '10 hours ago',
summary: 'Huawei and other local brands continue to gain market share...',
},
]
const sentimentFilters = ['All', 'Panic', 'Negative', 'Neutral', 'Positive']
export default function News() {
const [selectedFilter, setSelectedFilter] = useState('All')
const filteredNews = news.filter(item => {
if (selectedFilter === 'All') return true
if (selectedFilter === 'Panic') return item.sentiment <= -50
if (selectedFilter === 'Negative') return item.sentiment < -20 && item.sentiment > -50
if (selectedFilter === 'Neutral') return item.sentiment >= -20 && item.sentiment <= 20
if (selectedFilter === 'Positive') return item.sentiment > 20
return true
})
const getSentimentColor = (sentiment: number) => {
if (sentiment <= -50) return 'text-red-500'
if (sentiment < -20) return 'text-orange-400'
if (sentiment <= 20) return 'text-gray-400'
return 'text-green-400'
}
const getSentimentBadge = (sentiment: number) => {
if (sentiment <= -50) return 'badge-danger'
if (sentiment < -20) return 'badge-warning'
if (sentiment <= 20) return 'badge-info'
return 'badge-success'
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold gradient-text">News Feed</h1>
<p className="text-gray-500 mt-1">Real-time financial news with sentiment analysis</p>
</div>
</div>
{/* Filters */}
<div className="flex items-center space-x-4">
<FunnelIcon className="w-5 h-5 text-gray-500" />
<div className="flex space-x-2">
{sentimentFilters.map((filter) => (
<button
key={filter}
onClick={() => setSelectedFilter(filter)}
className={`px-4 py-2 rounded-lg transition-colors ${
selectedFilter === filter
? filter === 'Panic'
? 'bg-red-500/20 text-red-400 border border-red-500/30'
: 'bg-green-500/20 text-green-400 border border-green-500/30'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
{filter}
{filter === 'Panic' && <span className="ml-1">🔥</span>}
</button>
))}
</div>
</div>
{/* News Cards */}
<div className="space-y-4">
{filteredNews.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="card-hover p-5 cursor-pointer"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<span className="text-sm text-gray-500">{item.source}</span>
<span className="text-gray-600"></span>
<span className="text-sm text-gray-500">{item.time}</span>
<span className={getSentimentBadge(item.sentiment)}>
{item.sentiment <= -50 ? '🔴 Panic' : item.sentiment > 20 ? '🟢 Positive' : 'Neutral'}
</span>
</div>
<h3 className="text-lg font-semibold hover:text-green-400 transition-colors">
{item.title}
</h3>
<p className="text-gray-500 mt-2 text-sm">{item.summary}</p>
<div className="flex items-center space-x-2 mt-3">
{item.stocks.map((stock) => (
<span
key={stock}
className="px-2 py-1 bg-gray-800 rounded text-sm text-green-400 font-medium"
>
${stock}
</span>
))}
</div>
</div>
<div className="ml-6 text-center">
<div className={`text-3xl font-bold ${getSentimentColor(item.sentiment)}`}>
{item.sentiment}
</div>
<div className="text-xs text-gray-500 mt-1">Sentiment</div>
</div>
</div>
</motion.div>
))}
</div>
{filteredNews.length === 0 && (
<div className="card p-12 text-center">
<NewspaperIcon className="w-16 h-16 mx-auto text-gray-600" />
<h3 className="text-xl font-semibold mt-4">No News Found</h3>
<p className="text-gray-500 mt-2">
No news articles match your current filters.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,185 @@
import { motion } from 'framer-motion'
import { BellAlertIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline'
// Mock data
const signals = [
{
id: '1',
symbol: 'NVDA',
name: 'NVIDIA Corporation',
confidence: 87,
price: 485.23,
drawdown: -18.5,
expectedRecovery: 35,
expectedDays: 45,
sentiment: -65,
status: 'active',
createdAt: '2024-01-15T10:30:00Z',
reason: 'Panic selling due to supply chain concerns. Historical pattern shows 85% recovery rate within 60 days.',
},
{
id: '2',
symbol: 'META',
name: 'Meta Platforms Inc.',
confidence: 76,
price: 345.67,
drawdown: -12.3,
expectedRecovery: 25,
expectedDays: 30,
sentiment: -48,
status: 'active',
createdAt: '2024-01-15T09:15:00Z',
reason: 'Reality Labs layoff announcement. Similar events historically recovered within 45 days.',
},
{
id: '3',
symbol: 'TSLA',
name: 'Tesla Inc.',
confidence: 71,
price: 178.90,
drawdown: -25.8,
expectedRecovery: 45,
expectedDays: 60,
sentiment: -72,
status: 'active',
createdAt: '2024-01-15T08:00:00Z',
reason: 'Autopilot recall news. Tesla has recovered from similar negative news 78% of the time.',
},
]
export default function Signals() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold gradient-text">Buy Signals</h1>
<p className="text-gray-500 mt-1">Opportunities identified by panic pattern matching</p>
</div>
<div className="flex space-x-2">
<button className="btn-secondary">Filter</button>
<button className="btn-primary">Refresh</button>
</div>
</div>
{/* Signal Cards */}
<div className="space-y-4">
{signals.map((signal, index) => (
<motion.div
key={signal.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="card p-6"
>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
{/* Left: Stock Info */}
<div className="flex items-start space-x-4">
<div className="w-14 h-14 bg-gradient-to-br from-green-500/20 to-emerald-500/20 rounded-xl flex items-center justify-center border border-green-500/30 animate-glow">
<BellAlertIcon className="w-7 h-7 text-green-400" />
</div>
<div>
<div className="flex items-center space-x-2">
<h3 className="text-xl font-bold">${signal.symbol}</h3>
<span className="badge-success">Active</span>
</div>
<p className="text-sm text-gray-500">{signal.name}</p>
<p className="text-sm text-gray-400 mt-1">
Signal generated {new Date(signal.createdAt).toLocaleString()}
</p>
</div>
</div>
{/* Middle: Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6">
<div>
<p className="text-xs text-gray-500 uppercase">Price</p>
<p className="text-xl font-bold">${signal.price.toFixed(2)}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase">Drawdown</p>
<p className="text-xl font-bold text-red-400">{signal.drawdown}%</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase">Expected</p>
<p className="text-xl font-bold text-green-400">+{signal.expectedRecovery}%</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase">Timeframe</p>
<p className="text-xl font-bold">{signal.expectedDays}d</p>
</div>
</div>
{/* Right: Confidence & Actions */}
<div className="flex items-center space-x-6">
<div className="text-center">
<div className="relative w-20 h-20">
<svg className="w-20 h-20 transform -rotate-90">
<circle
cx="40"
cy="40"
r="36"
fill="none"
stroke="#374151"
strokeWidth="8"
/>
<circle
cx="40"
cy="40"
r="36"
fill="none"
stroke="url(#gradient)"
strokeWidth="8"
strokeDasharray={`${signal.confidence * 2.26} 226`}
strokeLinecap="round"
/>
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#22c55e" />
<stop offset="100%" stopColor="#10b981" />
</linearGradient>
</defs>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl font-bold text-green-400">{signal.confidence}%</span>
</div>
</div>
<p className="text-xs text-gray-500 mt-1">Confidence</p>
</div>
<div className="flex flex-col space-y-2">
<button className="btn-primary flex items-center">
<CheckIcon className="w-4 h-4 mr-1" />
Buy
</button>
<button className="btn-secondary flex items-center">
<XMarkIcon className="w-4 h-4 mr-1" />
Dismiss
</button>
</div>
</div>
</div>
{/* Reason */}
<div className="mt-4 p-4 bg-gray-800/50 rounded-lg">
<p className="text-sm text-gray-400">
<span className="text-gray-500 font-medium">Analysis: </span>
{signal.reason}
</p>
</div>
</motion.div>
))}
</div>
{/* Empty State */}
{signals.length === 0 && (
<div className="card p-12 text-center">
<BellAlertIcon className="w-16 h-16 mx-auto text-gray-600" />
<h3 className="text-xl font-semibold mt-4">No Active Signals</h3>
<p className="text-gray-500 mt-2">
When panic creates opportunities, they'll appear here.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,136 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { MagnifyingGlassIcon, PlusIcon } from '@heroicons/react/24/outline'
// Mock data
const stocks = [
{ symbol: 'AAPL', name: 'Apple Inc.', sector: 'Technology', price: 185.23, change: 1.25, sentiment: 15 },
{ symbol: 'MSFT', name: 'Microsoft Corporation', sector: 'Technology', price: 378.45, change: -0.85, sentiment: 8 },
{ symbol: 'NVDA', name: 'NVIDIA Corporation', sector: 'Technology', price: 485.23, change: -3.45, sentiment: -45 },
{ symbol: 'GOOGL', name: 'Alphabet Inc.', sector: 'Technology', price: 142.67, change: 0.55, sentiment: 5 },
{ symbol: 'META', name: 'Meta Platforms Inc.', sector: 'Technology', price: 345.67, change: -2.15, sentiment: -35 },
{ symbol: 'TSLA', name: 'Tesla Inc.', sector: 'Consumer Discretionary', price: 178.90, change: -5.25, sentiment: -65 },
{ symbol: 'LMT', name: 'Lockheed Martin', sector: 'Defense', price: 458.32, change: 4.55, sentiment: 55 },
{ symbol: 'RTX', name: 'RTX Corporation', sector: 'Defense', price: 92.45, change: 2.15, sentiment: 42 },
{ symbol: 'XOM', name: 'Exxon Mobil', sector: 'Energy', price: 102.34, change: -1.85, sentiment: -25 },
{ symbol: 'JPM', name: 'JPMorgan Chase', sector: 'Financials', price: 172.56, change: 0.95, sentiment: 12 },
]
const sectors = ['All', 'Technology', 'Defense', 'Energy', 'Financials', 'Healthcare', 'Consumer']
export default function Stocks() {
const [search, setSearch] = useState('')
const [selectedSector, setSelectedSector] = useState('All')
const filteredStocks = stocks.filter(stock => {
const matchesSearch = stock.symbol.toLowerCase().includes(search.toLowerCase()) ||
stock.name.toLowerCase().includes(search.toLowerCase())
const matchesSector = selectedSector === 'All' || stock.sector === selectedSector
return matchesSearch && matchesSector
})
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold gradient-text">Stocks</h1>
<p className="text-gray-500 mt-1">Monitor and track stocks across all sectors</p>
</div>
<button className="btn-primary flex items-center">
<PlusIcon className="w-4 h-4 mr-2" />
Add Stock
</button>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="text"
placeholder="Search by symbol or name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
</div>
<div className="flex space-x-2 overflow-x-auto pb-2">
{sectors.map((sector) => (
<button
key={sector}
onClick={() => setSelectedSector(sector)}
className={`px-4 py-2 rounded-lg whitespace-nowrap transition-colors ${
selectedSector === sector
? 'bg-green-500/20 text-green-400 border border-green-500/30'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
{sector}
</button>
))}
</div>
</div>
{/* Stocks Table */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase">Symbol</th>
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase">Sector</th>
<th className="px-6 py-4 text-right text-xs font-medium text-gray-500 uppercase">Price</th>
<th className="px-6 py-4 text-right text-xs font-medium text-gray-500 uppercase">Change</th>
<th className="px-6 py-4 text-center text-xs font-medium text-gray-500 uppercase">Sentiment</th>
<th className="px-6 py-4 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody>
{filteredStocks.map((stock, index) => (
<motion.tr
key={stock.symbol}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.05 }}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
>
<td className="px-6 py-4">
<span className="font-bold text-green-400">${stock.symbol}</span>
</td>
<td className="px-6 py-4 text-gray-300">{stock.name}</td>
<td className="px-6 py-4">
<span className="badge-info">{stock.sector}</span>
</td>
<td className="px-6 py-4 text-right font-mono">${stock.price.toFixed(2)}</td>
<td className={`px-6 py-4 text-right font-mono ${stock.change >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{stock.change >= 0 ? '+' : ''}{stock.change.toFixed(2)}%
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-center">
<div className="w-16 h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full ${stock.sentiment >= 0 ? 'bg-green-500' : 'bg-red-500'}`}
style={{
width: `${Math.abs(stock.sentiment)}%`,
marginLeft: stock.sentiment >= 0 ? '50%' : `${50 - Math.abs(stock.sentiment)}%`,
}}
/>
</div>
<span className={`ml-2 text-sm ${stock.sentiment >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{stock.sentiment}
</span>
</div>
</td>
<td className="px-6 py-4 text-right">
<button className="text-sm text-green-400 hover:text-green-300">View</button>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,193 @@
import { motion } from 'framer-motion'
import { StarIcon, TrashIcon, BellIcon } from '@heroicons/react/24/outline'
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'
// Mock data
const watchlist = [
{
id: '1',
symbol: 'NVDA',
name: 'NVIDIA Corporation',
price: 485.23,
change: -3.45,
sentiment: -45,
alertThreshold: -50,
priority: 1,
notes: 'Watching for panic below $450',
},
{
id: '2',
symbol: 'TSLA',
name: 'Tesla Inc.',
price: 178.90,
change: -5.25,
sentiment: -65,
alertThreshold: -60,
priority: 1,
notes: 'Autopilot recall - monitoring sentiment',
},
{
id: '3',
symbol: 'META',
name: 'Meta Platforms Inc.',
price: 345.67,
change: -2.15,
sentiment: -35,
alertThreshold: -50,
priority: 2,
notes: 'Reality Labs concerns',
},
{
id: '4',
symbol: 'BA',
name: 'Boeing Company',
price: 215.34,
change: -1.85,
sentiment: -28,
alertThreshold: -40,
priority: 2,
notes: 'Safety issues being monitored',
},
{
id: '5',
symbol: 'LMT',
name: 'Lockheed Martin',
price: 458.32,
change: 4.55,
sentiment: 55,
alertThreshold: -30,
priority: 3,
notes: 'Defense sector strength',
},
]
export default function Watchlist() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold gradient-text">Watchlist</h1>
<p className="text-gray-500 mt-1">Stocks you're monitoring for opportunities</p>
</div>
<button className="btn-primary flex items-center">
<StarIcon className="w-4 h-4 mr-2" />
Add to Watchlist
</button>
</div>
{/* Priority Legend */}
<div className="flex items-center space-x-4 text-sm">
<span className="text-gray-500">Priority:</span>
<span className="flex items-center">
<span className="w-3 h-3 bg-red-500 rounded-full mr-2" />
High
</span>
<span className="flex items-center">
<span className="w-3 h-3 bg-yellow-500 rounded-full mr-2" />
Medium
</span>
<span className="flex items-center">
<span className="w-3 h-3 bg-blue-500 rounded-full mr-2" />
Low
</span>
</div>
{/* Watchlist Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{watchlist.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
className="card p-5"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-3">
<div className={`w-2 h-2 rounded-full ${
item.priority === 1 ? 'bg-red-500' :
item.priority === 2 ? 'bg-yellow-500' : 'bg-blue-500'
}`} />
<div>
<div className="flex items-center space-x-2">
<h3 className="font-bold text-lg">${item.symbol}</h3>
<StarIconSolid className="w-4 h-4 text-yellow-500" />
</div>
<p className="text-sm text-gray-500">{item.name}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<button className="p-2 hover:bg-gray-800 rounded-lg transition-colors">
<BellIcon className="w-5 h-5 text-gray-400" />
</button>
<button className="p-2 hover:bg-red-500/20 rounded-lg transition-colors">
<TrashIcon className="w-5 h-5 text-red-400" />
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mb-4">
<div>
<p className="text-xs text-gray-500 uppercase">Price</p>
<p className="text-lg font-bold">${item.price.toFixed(2)}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase">Change</p>
<p className={`text-lg font-bold ${item.change >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{item.change >= 0 ? '+' : ''}{item.change.toFixed(2)}%
</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase">Sentiment</p>
<p className={`text-lg font-bold ${item.sentiment >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{item.sentiment}
</p>
</div>
</div>
{/* Alert Threshold */}
<div className="mb-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-500">Panic Alert Threshold</span>
<span className="text-red-400">{item.alertThreshold}</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${
item.sentiment <= item.alertThreshold ? 'bg-red-500 animate-pulse' : 'bg-gray-600'
}`}
style={{ width: `${Math.abs(item.sentiment)}%` }}
/>
</div>
{item.sentiment <= item.alertThreshold && (
<p className="text-xs text-red-400 mt-1">⚠️ Below threshold - Watch for opportunity!</p>
)}
</div>
{/* Notes */}
{item.notes && (
<div className="p-3 bg-gray-800/50 rounded-lg">
<p className="text-sm text-gray-400">
<span className="text-gray-500">Notes: </span>
{item.notes}
</p>
</div>
)}
</motion.div>
))}
</div>
{watchlist.length === 0 && (
<div className="card p-12 text-center">
<StarIcon className="w-16 h-16 mx-auto text-gray-600" />
<h3 className="text-xl font-semibold mt-4">Your Watchlist is Empty</h3>
<p className="text-gray-500 mt-2">
Add stocks you want to monitor for panic-buying opportunities.
</p>
<button className="btn-primary mt-4">Add Your First Stock</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,79 @@
import axios from 'axios'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
export const api = axios.create({
baseURL: `${API_URL}/api/v1`,
headers: {
'Content-Type': 'application/json',
},
})
// Stocks
export const stocksApi = {
list: (params?: { sector?: string; search?: string; skip?: number; limit?: number }) =>
api.get('/stocks/', { params }),
get: (symbol: string) => api.get(`/stocks/${symbol}`),
create: (data: { symbol: string; name: string; sector?: string; industry?: string }) =>
api.post('/stocks/', data),
delete: (symbol: string) => api.delete(`/stocks/${symbol}`),
getSectors: () => api.get('/stocks/sectors'),
getIndustries: (sector?: string) => api.get('/stocks/industries', { params: { sector } }),
}
// News
export const newsApi = {
list: (params?: { source?: string; sentiment?: string; hours?: number; skip?: number; limit?: number }) =>
api.get('/news/', { params }),
getForStock: (symbol: string, params?: { hours?: number }) =>
api.get(`/news/stock/${symbol}`, { params }),
getPanicNews: (params?: { threshold?: number; hours?: number; limit?: number }) =>
api.get('/news/panic', { params }),
get: (id: string) => api.get(`/news/${id}`),
}
// Signals
export const signalsApi = {
list: (params?: { status?: string; min_confidence?: number; skip?: number; limit?: number }) =>
api.get('/signals/', { params }),
getTop: (limit?: number) => api.get('/signals/top', { params: { limit } }),
get: (id: string) => api.get(`/signals/${id}`),
trigger: (id: string) => api.post(`/signals/${id}/trigger`),
dismiss: (id: string) => api.post(`/signals/${id}/dismiss`),
}
// Watchlist
export const watchlistApi = {
list: (priority?: number) => api.get('/watchlist/', { params: { priority } }),
add: (data: {
symbol: string
panic_alert_threshold?: number
price_alert_low?: number
price_alert_high?: number
priority?: number
notes?: string
}) => api.post('/watchlist/', data),
update: (id: string, data: Partial<{
panic_alert_threshold: number
price_alert_low: number
price_alert_high: number
priority: number
notes: string
is_active: boolean
}>) => api.put(`/watchlist/${id}`, data),
remove: (id: string) => api.delete(`/watchlist/${id}`),
removeBySymbol: (symbol: string) => api.delete(`/watchlist/symbol/${symbol}`),
}
// Analytics
export const analyticsApi = {
getDashboard: () => api.get('/analytics/dashboard'),
getSentimentTrend: (days?: number) => api.get('/analytics/sentiment/trend', { params: { days } }),
getSectorPanic: () => api.get('/analytics/sector/panic'),
getTopPatterns: (limit?: number) => api.get('/analytics/patterns/top', { params: { limit } }),
getRecentPanicEvents: (days?: number, limit?: number) =>
api.get('/analytics/panic-events/recent', { params: { days, limit } }),
getPerformance: (days?: number) => api.get('/analytics/performance', { params: { days } }),
}
export default api

View File

@@ -0,0 +1,54 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Custom trading colors
panic: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
greed: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
neutral: {
850: '#1a1a2e',
950: '#0f0f1a',
},
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'glow': 'glow 2s ease-in-out infinite alternate',
},
keyframes: {
glow: {
'0%': { boxShadow: '0 0 5px rgb(34 197 94 / 0.5), 0 0 10px rgb(34 197 94 / 0.3)' },
'100%': { boxShadow: '0 0 10px rgb(34 197 94 / 0.8), 0 0 20px rgb(34 197 94 / 0.5)' },
},
},
},
},
plugins: [],
}

24
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})