feat: Add Phase 4 advanced metrics and components

- Add advanced metrics dashboard with trade analytics
- Add new trading components (EntryTypeAnalysis, MultiDayPositionTracker, NewsEventTracker, etc.)
- Add strategy mode selector and trend confirmation
- Add risk automation panel and slippage correlation analysis
- Add daily trading plan enhancements with modal components
- Add custom hooks (useApi, useLocalStorage, useAdvancedTradeMetrics)
- Add broker service integration and trading API
- Add test setup and vitest configuration
- Include parquet data files for live market data
- Add comprehensive documentation in docs/ folder
This commit is contained in:
Krikorios
2025-11-27 10:23:58 +02:00
parent b5e2b02cb8
commit 48e60d015f
2019 changed files with 39793 additions and 257 deletions
+531
View File
@@ -0,0 +1,531 @@
"""
Trading Journal API
Handles daily/weekly plans, manual trade logging, journal entries, and decision logging
"""
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc
from typing import List, Optional
from datetime import date, datetime, timedelta
from pydantic import BaseModel
import os
import shutil
import uuid
from app.db.database import get_db
from app.models.models import (
TradingPlan,
ManualTrade,
JournalEntry,
DecisionLog,
WeeklyPlan,
TradeAction
)
router = APIRouter(prefix="/api/journal", tags=["Trading Journal"])
# Pydantic Schemas
class TradingPlanCreate(BaseModel):
plan_date: date
plan_type: str = "daily"
market_bias: str
daily_target: Optional[float] = None
max_loss: Optional[float] = None
entry_zone_min: Optional[float] = None
entry_zone_max: Optional[float] = None
target_price: Optional[float] = None
stop_loss: Optional[float] = None
support_levels: List[float] = []
resistance_levels: List[float] = []
trading_notes: Optional[str] = None
max_trades: int = 3
ai_generated: bool = False
ai_confidence: Optional[float] = None
context_metrics: Optional[dict] = None
class TradingPlanUpdate(BaseModel):
market_bias: Optional[str] = None
daily_target: Optional[float] = None
max_loss: Optional[float] = None
entry_zone_min: Optional[float] = None
entry_zone_max: Optional[float] = None
target_price: Optional[float] = None
stop_loss: Optional[float] = None
support_levels: Optional[List[float]] = None
resistance_levels: Optional[List[float]] = None
trading_notes: Optional[str] = None
max_trades: Optional[int] = None
actual_trades: Optional[int] = None
actual_pnl: Optional[float] = None
plan_followed: Optional[bool] = None
class ManualTradeCreate(BaseModel):
plan_id: Optional[int] = None
symbol: str = "XAUUSD"
action: str # BUY or SELL
entry_price: float
exit_price: Optional[float] = None
quantity: float
broker: Optional[str] = None
pnl: Optional[float] = None
pnl_percent: Optional[float] = None
notes: Optional[str] = None
followed_plan: bool = True
entry_time: Optional[datetime] = None
exit_time: Optional[datetime] = None
class ManualTradeUpdate(BaseModel):
exit_price: Optional[float] = None
pnl: Optional[float] = None
pnl_percent: Optional[float] = None
notes: Optional[str] = None
exit_time: Optional[datetime] = None
class JournalEntryCreate(BaseModel):
entry_date: date
mood: Optional[str] = None
energy_level: Optional[int] = None
stress_level: Optional[int] = None
lessons_learned: Optional[str] = None
what_went_well: Optional[str] = None
what_to_improve: Optional[str] = None
tomorrow_focus: Optional[str] = None
mistakes_made: Optional[str] = None
market_conditions: Optional[str] = None
market_notes: Optional[str] = None
class DecisionLogCreate(BaseModel):
ai_recommendation: Optional[str] = None
ai_confidence: Optional[float] = None
ai_reasoning: Optional[str] = None
trader_action: Optional[str] = None
trade_id: Optional[int] = None
outcome: Optional[str] = None
outcome_pnl: Optional[float] = None
notes: Optional[str] = None
class WeeklyPlanCreate(BaseModel):
week_start_date: date
year: int
week_number: int
market_outlook: Optional[str] = None
key_events: List[dict] = []
major_levels: List[float] = []
weekly_target: Optional[float] = None
max_weekly_loss: Optional[float] = None
target_trade_count: Optional[int] = None
primary_strategy: Optional[str] = None
focus_areas: Optional[str] = None
risks_to_watch: Optional[str] = None
# Trading Plans Endpoints
@router.post("/plans", status_code=201)
async def create_trading_plan(
plan: TradingPlanCreate,
db: Session = Depends(get_db)
):
"""Create a new daily/weekly trading plan"""
db_plan = TradingPlan(**plan.dict())
db.add(db_plan)
db.commit()
db.refresh(db_plan)
return db_plan
@router.get("/plans/today")
async def get_today_plan(db: Session = Depends(get_db)):
"""Get today's trading plan"""
today = date.today()
plan = db.query(TradingPlan).filter(
and_(
TradingPlan.plan_date == today,
TradingPlan.plan_type == "daily"
)
).first()
if not plan:
raise HTTPException(status_code=404, detail="No plan found for today")
return plan
@router.get("/plans/date/{plan_date}")
async def get_plan_by_date(
plan_date: date,
db: Session = Depends(get_db)
):
"""Get trading plan for a specific date"""
plan = db.query(TradingPlan).filter(
TradingPlan.plan_date == plan_date
).first()
if not plan:
raise HTTPException(status_code=404, detail=f"No plan found for {plan_date}")
return plan
@router.get("/plans")
async def get_plans(
limit: int = 30,
offset: int = 0,
db: Session = Depends(get_db)
):
"""Get recent trading plans"""
plans = db.query(TradingPlan).order_by(
desc(TradingPlan.plan_date)
).limit(limit).offset(offset).all()
return {"plans": plans, "total": db.query(TradingPlan).count()}
@router.put("/plans/{plan_id}")
async def update_trading_plan(
plan_id: int,
plan_update: TradingPlanUpdate,
db: Session = Depends(get_db)
):
"""Update an existing trading plan"""
db_plan = db.query(TradingPlan).filter(TradingPlan.id == plan_id).first()
if not db_plan:
raise HTTPException(status_code=404, detail="Plan not found")
update_data = plan_update.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_plan, key, value)
db.commit()
db.refresh(db_plan)
return db_plan
@router.delete("/plans/{plan_id}")
async def delete_trading_plan(
plan_id: int,
db: Session = Depends(get_db)
):
"""Delete a trading plan"""
db_plan = db.query(TradingPlan).filter(TradingPlan.id == plan_id).first()
if not db_plan:
raise HTTPException(status_code=404, detail="Plan not found")
db.delete(db_plan)
db.commit()
return {"message": "Plan deleted successfully"}
# Manual Trades Endpoints
@router.post("/trades", status_code=201)
async def create_manual_trade(
trade: ManualTradeCreate,
db: Session = Depends(get_db)
):
"""Log a manual trade from broker platform"""
try:
action_enum = TradeAction[trade.action.upper()]
except KeyError:
raise HTTPException(status_code=400, detail=f"Invalid action: {trade.action}")
trade_dict = trade.dict()
trade_dict['action'] = action_enum
db_trade = ManualTrade(**trade_dict)
db.add(db_trade)
# Update plan if linked
if trade.plan_id:
plan = db.query(TradingPlan).filter(TradingPlan.id == trade.plan_id).first()
if plan:
plan.actual_trades += 1
if trade.pnl is not None:
plan.actual_pnl += trade.pnl
db.commit()
db.refresh(db_trade)
return db_trade
@router.get("/trades")
async def get_manual_trades(
limit: int = 50,
offset: int = 0,
plan_id: Optional[int] = None,
db: Session = Depends(get_db)
):
"""Get manual trades, optionally filtered by plan"""
query = db.query(ManualTrade)
if plan_id:
query = query.filter(ManualTrade.plan_id == plan_id)
trades = query.order_by(desc(ManualTrade.created_at)).limit(limit).offset(offset).all()
total = query.count()
return {"trades": trades, "total": total}
@router.get("/trades/{trade_id}")
async def get_manual_trade(
trade_id: int,
db: Session = Depends(get_db)
):
"""Get a specific manual trade"""
trade = db.query(ManualTrade).filter(ManualTrade.id == trade_id).first()
if not trade:
raise HTTPException(status_code=404, detail="Trade not found")
return trade
@router.put("/trades/{trade_id}")
async def update_manual_trade(
trade_id: int,
trade_update: ManualTradeUpdate,
db: Session = Depends(get_db)
):
"""Update a manual trade (e.g., closing a position)"""
db_trade = db.query(ManualTrade).filter(ManualTrade.id == trade_id).first()
if not db_trade:
raise HTTPException(status_code=404, detail="Trade not found")
update_data = trade_update.dict(exclude_unset=True)
# Calculate PnL if exit price provided
if 'exit_price' in update_data and db_trade.exit_price is None:
exit_price = update_data['exit_price']
if db_trade.action == TradeAction.BUY:
pnl = (exit_price - db_trade.entry_price) * db_trade.quantity
else: # SELL
pnl = (db_trade.entry_price - exit_price) * db_trade.quantity
update_data['pnl'] = round(pnl, 2)
update_data['pnl_percent'] = round((pnl / (db_trade.entry_price * db_trade.quantity)) * 100, 2)
# Update plan PnL
if db_trade.plan_id:
plan = db.query(TradingPlan).filter(TradingPlan.id == db_trade.plan_id).first()
if plan:
plan.actual_pnl += pnl
for key, value in update_data.items():
setattr(db_trade, key, value)
db.commit()
db.refresh(db_trade)
return db_trade
@router.post("/trades/{trade_id}/screenshot")
async def upload_trade_screenshot(
trade_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db)
):
"""Upload a screenshot for a trade"""
db_trade = db.query(ManualTrade).filter(ManualTrade.id == trade_id).first()
if not db_trade:
raise HTTPException(status_code=404, detail="Trade not found")
# Create uploads directory if it doesn't exist
upload_dir = "uploads/trade_screenshots"
os.makedirs(upload_dir, exist_ok=True)
# Generate unique filename
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{trade_id}_{uuid.uuid4()}{file_extension}"
file_path = os.path.join(upload_dir, unique_filename)
# Save file
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Update trade record
db_trade.screenshot_url = file_path
db.commit()
return {"filename": unique_filename, "path": file_path}
# Journal Entries Endpoints
@router.post("/entries", status_code=201)
async def create_journal_entry(
entry: JournalEntryCreate,
db: Session = Depends(get_db)
):
"""Create a daily journal entry"""
# Check if entry for this date already exists
existing = db.query(JournalEntry).filter(
JournalEntry.entry_date == entry.entry_date
).first()
if existing:
# Update existing entry
update_data = entry.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(existing, key, value)
db.commit()
db.refresh(existing)
return existing
db_entry = JournalEntry(**entry.dict())
db.add(db_entry)
db.commit()
db.refresh(db_entry)
return db_entry
@router.get("/entries/today")
async def get_today_journal(db: Session = Depends(get_db)):
"""Get today's journal entry"""
today = date.today()
entry = db.query(JournalEntry).filter(
JournalEntry.entry_date == today
).first()
if not entry:
raise HTTPException(status_code=404, detail="No journal entry for today")
return entry
@router.get("/entries")
async def get_journal_entries(
limit: int = 30,
offset: int = 0,
db: Session = Depends(get_db)
):
"""Get recent journal entries"""
entries = db.query(JournalEntry).order_by(
desc(JournalEntry.entry_date)
).limit(limit).offset(offset).all()
return {"entries": entries, "total": db.query(JournalEntry).count()}
# Decision Log Endpoints
@router.post("/decisions", status_code=201)
async def create_decision_log(
decision: DecisionLogCreate,
db: Session = Depends(get_db)
):
"""Log a trading decision"""
db_decision = DecisionLog(**decision.dict())
db.add(db_decision)
db.commit()
db.refresh(db_decision)
return db_decision
@router.get("/decisions")
async def get_decisions(
limit: int = 50,
offset: int = 0,
db: Session = Depends(get_db)
):
"""Get recent decisions"""
decisions = db.query(DecisionLog).order_by(
desc(DecisionLog.decision_time)
).limit(limit).offset(offset).all()
return {"decisions": decisions, "total": db.query(DecisionLog).count()}
@router.get("/decisions/accuracy")
async def get_ai_accuracy(
days: int = 30,
db: Session = Depends(get_db)
):
"""Calculate AI recommendation accuracy"""
cutoff_date = datetime.now() - timedelta(days=days)
decisions = db.query(DecisionLog).filter(
and_(
DecisionLog.decision_time >= cutoff_date,
DecisionLog.trader_action == "FOLLOWED",
DecisionLog.outcome.isnot(None)
)
).all()
if not decisions:
return {
"total_decisions": 0,
"accuracy": 0.0,
"win_rate": 0.0,
"avg_pnl": 0.0
}
wins = sum(1 for d in decisions if d.outcome == "WIN")
total_pnl = sum(d.outcome_pnl for d in decisions if d.outcome_pnl is not None)
return {
"total_decisions": len(decisions),
"wins": wins,
"losses": len(decisions) - wins,
"win_rate": round((wins / len(decisions)) * 100, 2),
"avg_pnl": round(total_pnl / len(decisions), 2) if decisions else 0,
"total_pnl": round(total_pnl, 2)
}
# Weekly Plans Endpoints
@router.post("/weekly-plans", status_code=201)
async def create_weekly_plan(
plan: WeeklyPlanCreate,
db: Session = Depends(get_db)
):
"""Create a weekly trading plan"""
db_plan = WeeklyPlan(**plan.dict())
db.add(db_plan)
db.commit()
db.refresh(db_plan)
return db_plan
@router.get("/weekly-plans/current")
async def get_current_week_plan(db: Session = Depends(get_db)):
"""Get this week's plan"""
today = date.today()
# Get Monday of current week
monday = today - timedelta(days=today.weekday())
plan = db.query(WeeklyPlan).filter(
WeeklyPlan.week_start_date == monday
).first()
if not plan:
raise HTTPException(status_code=404, detail="No plan found for current week")
return plan
@router.get("/weekly-plans")
async def get_weekly_plans(
limit: int = 12,
db: Session = Depends(get_db)
):
"""Get recent weekly plans"""
plans = db.query(WeeklyPlan).order_by(
desc(WeeklyPlan.week_start_date)
).limit(limit).all()
return {"plans": plans, "total": db.query(WeeklyPlan).count()}