Files
robinhood/backend/app/api/trading_persistent.py
T
Krikorios 48e60d015f 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
2025-11-27 10:23:58 +02:00

435 lines
15 KiB
Python

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