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
+434
View File
@@ -0,0 +1,434 @@
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.orm import Session, selectinload
from typing import Dict, Optional, Any, List
from datetime import datetime, timezone
from app.db.database import get_db
from app.models.models import Simulation, Trade, Position, TradeAction, TradeMetadata
from app.services.risk import validate_order
from pydantic import BaseModel
router = APIRouter(prefix="/trading", tags=["Trading"])
# Pydantic models for request/response
class TradeRequest(BaseModel):
action: str
quantity: float
price: float
symbol: str = "XAU/USD"
notes: Optional[str] = None
stop_loss: Optional[float] = None
take_profit: Optional[float] = None
source: Optional[str] = None
platform: Optional[str] = None
risk_percent: Optional[float] = None
entry_time: Optional[str] = None
class PortfolioState(BaseModel):
cash: float
initial_capital: float
position: Optional[Dict[str, Any]] = None
trades: List[Dict[str, Any]]
equity_history: List[Dict[str, Any]]
total_pnl: float
total_pnl_percent: float
def get_or_create_simulation(db: Session, user_id: str = "default") -> Simulation:
"""Get existing simulation or create a new one"""
simulation = db.query(Simulation).filter(Simulation.user_id == user_id).first()
if not simulation:
simulation = Simulation(
user_id=user_id,
symbol="XAU/USD",
initial_capital=100000.0,
current_capital=100000.0,
total_pnl=0.0,
total_pnl_percent=0.0
)
db.add(simulation)
db.commit()
db.refresh(simulation)
return simulation
def _compute_equity_at_price(simulation: Simulation, price: float, db: Session) -> float:
"""Calculate equity based on current position and price"""
position = db.query(Position).filter(
Position.simulation_id == simulation.id
).first()
qty = position.quantity if position else 0.0
return float(simulation.current_capital + qty * price)
def get_portfolio_state_from_db(simulation: Simulation, db: Session) -> PortfolioState:
"""Convert DB simulation to portfolio state"""
# Get current position
position = db.query(Position).filter(
Position.simulation_id == simulation.id
).first()
position_dict = None
if position:
position_dict = {
"symbol": position.symbol,
"quantity": position.quantity,
"avg_price": position.avg_price,
"current_price": position.current_price,
"unrealized_pnl": position.unrealized_pnl,
"unrealized_pnl_percent": position.unrealized_pnl_percent
}
# Get all trades
trades = db.query(Trade).options(selectinload(Trade.details)).filter(
Trade.simulation_id == simulation.id
).order_by(Trade.timestamp).all()
trades_list = []
for trade in trades:
details = trade.details
trades_list.append({
"id": trade.id,
"action": trade.action.value,
"quantity": trade.quantity,
"price": trade.price,
"total": trade.total,
"pnl": trade.pnl,
"timestamp": int(trade.timestamp.timestamp()) if trade.timestamp else None,
"stop_loss": details.stop_loss if details else None,
"take_profit": details.take_profit if details else None,
"notes": details.notes if details else None,
"source": details.source if details else None,
"platform": details.platform if details else None,
"risk_percent": details.risk_percent if details else None,
"entry_time": details.entry_time.isoformat() if details and details.entry_time else None,
})
# Build equity history from trades
equity_history = []
running_equity = simulation.initial_capital
for trade in trades:
if trade.action == TradeAction.SELL and trade.pnl:
running_equity += trade.pnl
equity_history.append({
"time": int(trade.timestamp.timestamp()) if trade.timestamp else 0,
"equity": running_equity
})
return PortfolioState(
cash=simulation.current_capital,
initial_capital=simulation.initial_capital,
position=position_dict,
trades=trades_list,
equity_history=equity_history,
total_pnl=simulation.total_pnl,
total_pnl_percent=simulation.total_pnl_percent
)
@router.post("/execute")
async def execute_trade(
trade_request: TradeRequest,
db: Session = Depends(get_db),
user_id: str = "default"
):
"""
Execute a trade and persist to database.
- Validates risk rules
- Updates cash/position in DB
- Records trade with timestamp
- Returns updated portfolio state
"""
try:
action = trade_request.action.upper()
quantity = trade_request.quantity
price = trade_request.price
if action not in ["BUY", "SELL"]:
raise HTTPException(status_code=400, detail="Action must be BUY or SELL")
# Get or create simulation
simulation = get_or_create_simulation(db, user_id)
# Build state dict for risk validation
position = db.query(Position).filter(
Position.simulation_id == simulation.id
).first()
state_dict = {
"cash": simulation.current_capital,
"position": {
"quantity": position.quantity,
"avg_price": position.avg_price
} if position else None
}
# Risk validation
try:
validate_order(state_dict, action, quantity, price)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
total = quantity * price
pnl = None
if action == "BUY":
if total > simulation.current_capital:
raise HTTPException(status_code=400, detail="Insufficient funds")
simulation.current_capital -= total
if not position:
# Create new position
position = Position(
simulation_id=simulation.id,
symbol=trade_request.symbol,
quantity=quantity,
avg_price=price,
current_price=price,
unrealized_pnl=0.0,
unrealized_pnl_percent=0.0
)
db.add(position)
else:
# Update existing position (average up)
new_qty = position.quantity + quantity
new_avg = (position.avg_price * position.quantity + price * quantity) / new_qty
position.quantity = new_qty
position.avg_price = new_avg
position.current_price = price
elif action == "SELL":
if not position or quantity > position.quantity:
raise HTTPException(status_code=400, detail="Insufficient position")
simulation.current_capital += total
pnl = (price - position.avg_price) * quantity
# Update simulation totals
simulation.total_pnl += pnl
if simulation.initial_capital > 0:
simulation.total_pnl_percent = (simulation.total_pnl / simulation.initial_capital) * 100
position.quantity -= quantity
if position.quantity == 0:
# Close position
db.delete(position)
position = None
else:
position.current_price = price
# Create trade record
trade_timestamp = datetime.now(timezone.utc)
trade = Trade(
simulation_id=simulation.id,
action=TradeAction[action],
quantity=quantity,
price=price,
total=total,
pnl=pnl,
timestamp=trade_timestamp
)
db.add(trade)
db.flush()
entry_time_dt = None
if trade_request.entry_time:
try:
entry_time_dt = datetime.fromisoformat(trade_request.entry_time)
if entry_time_dt.tzinfo is None:
entry_time_dt = entry_time_dt.replace(tzinfo=timezone.utc)
except ValueError:
entry_time_dt = trade_timestamp
metadata_fields = (
trade_request.source,
trade_request.platform,
trade_request.notes,
trade_request.stop_loss,
trade_request.take_profit,
trade_request.risk_percent,
entry_time_dt,
)
if any(field is not None for field in metadata_fields):
trade_metadata = TradeMetadata(
trade_id=trade.id,
source=trade_request.source,
platform=trade_request.platform,
notes=trade_request.notes,
stop_loss=trade_request.stop_loss,
take_profit=trade_request.take_profit,
risk_percent=trade_request.risk_percent,
entry_time=entry_time_dt,
)
db.add(trade_metadata)
# Commit all changes
db.commit()
db.refresh(simulation)
# Return updated portfolio state
portfolio_state = get_portfolio_state_from_db(simulation, db)
return {
"trade": {
"id": trade.id,
"action": action,
"quantity": quantity,
"price": price,
"total": total,
"pnl": pnl,
"timestamp": int(trade.timestamp.timestamp()) if trade.timestamp else None,
"stop_loss": trade_request.stop_loss,
"take_profit": trade_request.take_profit,
"notes": trade_request.notes,
"source": trade_request.source,
"platform": trade_request.platform,
"risk_percent": trade_request.risk_percent,
"entry_time": entry_time_dt.isoformat() if entry_time_dt else None,
},
"portfolio": portfolio_state.dict()
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Trade execution failed: {str(e)}")
@router.get("/portfolio")
async def get_portfolio(
db: Session = Depends(get_db),
user_id: str = "default"
):
"""Get current portfolio state from database"""
try:
simulation = get_or_create_simulation(db, user_id)
portfolio_state = get_portfolio_state_from_db(simulation, db)
return portfolio_state.dict()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get portfolio: {str(e)}")
@router.post("/reset")
async def reset_simulation(
db: Session = Depends(get_db),
user_id: str = "default"
):
"""Reset simulation to initial state"""
try:
simulation = db.query(Simulation).filter(Simulation.user_id == user_id).first()
if simulation:
# Delete all trades and positions (cascade will handle this)
db.delete(simulation)
db.commit()
# Create new simulation
new_simulation = Simulation(
user_id=user_id,
symbol="XAU/USD",
initial_capital=100000.0,
current_capital=100000.0,
total_pnl=0.0,
total_pnl_percent=0.0
)
db.add(new_simulation)
db.commit()
db.refresh(new_simulation)
portfolio_state = get_portfolio_state_from_db(new_simulation, db)
return {
"message": "Simulation reset successfully",
"portfolio": portfolio_state.dict()
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Reset failed: {str(e)}")
@router.get("/history")
async def get_trade_history(
db: Session = Depends(get_db),
user_id: str = "default",
limit: int = 100
):
"""Get trade history from database"""
try:
simulation = get_or_create_simulation(db, user_id)
trades = db.query(Trade).options(selectinload(Trade.details)).filter(
Trade.simulation_id == simulation.id
).order_by(Trade.timestamp.desc()).limit(limit).all()
return [
{
"id": trade.id,
"action": trade.action.value,
"quantity": trade.quantity,
"price": trade.price,
"total": trade.total,
"pnl": trade.pnl,
"timestamp": int(trade.timestamp.timestamp()) if trade.timestamp else None,
"stop_loss": trade.details.stop_loss if trade.details else None,
"take_profit": trade.details.take_profit if trade.details else None,
"notes": trade.details.notes if trade.details else None,
"source": trade.details.source if trade.details else None,
"platform": trade.details.platform if trade.details else None,
"risk_percent": trade.details.risk_percent if trade.details else None,
"entry_time": trade.details.entry_time.isoformat() if trade.details and trade.details.entry_time else None,
}
for trade in trades
]
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get history: {str(e)}")
@router.get("/stats")
async def get_trading_stats(
db: Session = Depends(get_db),
user_id: str = "default"
):
"""Get trading statistics"""
try:
simulation = get_or_create_simulation(db, user_id)
trades = db.query(Trade).filter(
Trade.simulation_id == simulation.id
).all()
total_trades = len(trades)
winning_trades = sum(1 for t in trades if t.pnl and t.pnl > 0)
losing_trades = sum(1 for t in trades if t.pnl and t.pnl < 0)
total_profit = sum(t.pnl for t in trades if t.pnl and t.pnl > 0)
total_loss = sum(abs(t.pnl) for t in trades if t.pnl and t.pnl < 0)
win_rate = (winning_trades / total_trades * 100) if total_trades > 0 else 0
profit_factor = (total_profit / total_loss) if total_loss > 0 else 0
return {
"total_trades": total_trades,
"winning_trades": winning_trades,
"losing_trades": losing_trades,
"win_rate": round(win_rate, 2),
"total_pnl": simulation.total_pnl,
"total_pnl_percent": simulation.total_pnl_percent,
"total_profit": total_profit,
"total_loss": total_loss,
"profit_factor": round(profit_factor, 2),
"current_capital": simulation.current_capital,
"initial_capital": simulation.initial_capital
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get stats: {str(e)}")