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:
@@ -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()}
|
||||
Reference in New Issue
Block a user