- 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
496 lines
16 KiB
Python
496 lines
16 KiB
Python
"""
|
|
Smart Trade Hub API - Unified trade entry system
|
|
Consolidates Simulator, Manual Logger, and Broker Bridge into one intelligent interface
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
|
from sqlalchemy.orm import Session
|
|
from typing import Any, Dict, List, Optional, Literal
|
|
from datetime import datetime, timezone
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.db.database import get_db
|
|
from app.services.simulation_state import load_simulation_state
|
|
from app.api.trading_persistent import (
|
|
TradeRequest as PersistentTradeRequest,
|
|
execute_trade as persistent_execute_trade,
|
|
)
|
|
from app.services.risk import validate_order
|
|
from app.services.ai_context_builder import ai_context_builder
|
|
from app.services.price_anchor import price_anchor_service
|
|
|
|
router = APIRouter(prefix="/api/smart-trade-hub", tags=["Smart Trade Hub"])
|
|
|
|
|
|
class TradeSource(str):
|
|
"""Enumeration of trade sources"""
|
|
SIMULATOR = "simulator"
|
|
MANUAL = "manual"
|
|
BROKER = "broker"
|
|
VOICE = "voice"
|
|
OCR = "ocr"
|
|
|
|
|
|
class SmartTradeRequest(BaseModel):
|
|
"""Unified trade entry request with auto-detection"""
|
|
action: Literal["BUY", "SELL", "CLOSE"]
|
|
symbol: str = Field(default="XAU/USD", description="Trading symbol")
|
|
quantity: Optional[float] = Field(None, description="Trade quantity (auto-filled if None)")
|
|
price: Optional[float] = Field(None, description="Entry price (uses current market if None)")
|
|
|
|
# Optional guards (auto-calculated if None)
|
|
stop_loss: Optional[float] = None
|
|
take_profit: Optional[float] = None
|
|
risk_percent: Optional[float] = None
|
|
|
|
# Source detection and metadata
|
|
source: Optional[str] = Field(None, description="Trade source: simulator/manual/broker/voice/ocr")
|
|
platform: Optional[str] = Field(None, description="Trading platform (e.g., MT5, TradingView)")
|
|
notes: Optional[str] = Field(None, description="Trade notes or voice transcription")
|
|
entry_time: Optional[str] = Field(None, description="Custom entry time (ISO format)")
|
|
|
|
# OCR/Voice metadata
|
|
image_data: Optional[str] = Field(None, description="Base64 encoded screenshot for OCR")
|
|
voice_data: Optional[str] = Field(None, description="Voice memo data")
|
|
|
|
# Pre-fill hints
|
|
use_last_trade_defaults: bool = Field(True, description="Auto-fill from last trade")
|
|
apply_smart_guards: bool = Field(True, description="Apply AI-suggested guards")
|
|
|
|
|
|
class SmartTradeResponse(BaseModel):
|
|
"""Response with executed trade and suggestions"""
|
|
trade_id: int
|
|
action: str
|
|
symbol: str
|
|
quantity: float
|
|
price: float
|
|
stop_loss: Optional[float]
|
|
take_profit: Optional[float]
|
|
risk_percent: Optional[float]
|
|
|
|
# Execution details
|
|
source: str
|
|
executed_at: str
|
|
total_cost: float
|
|
|
|
# Smart suggestions applied
|
|
guards_applied: bool
|
|
guards_suggested: Optional[Dict] = None
|
|
prefill_used: bool
|
|
|
|
# Position state after trade
|
|
remaining_cash: float
|
|
total_equity: float
|
|
position_size: Optional[float]
|
|
unrealized_pnl: Optional[float]
|
|
|
|
|
|
class SmartPreFillResponse(BaseModel):
|
|
"""Pre-fill suggestions for trade entry"""
|
|
symbol: str
|
|
suggested_quantity: float
|
|
current_price: float
|
|
suggested_guards: Dict
|
|
last_trade_context: Optional[Dict]
|
|
market_context: Dict
|
|
confidence: float
|
|
|
|
|
|
class SmartGuardSuggestion(BaseModel):
|
|
"""AI-suggested risk guards"""
|
|
stop_loss_price: float
|
|
stop_loss_percent: float
|
|
take_profit_price: float
|
|
take_profit_percent: float
|
|
risk_percent: float
|
|
position_size: float
|
|
risk_reward_ratio: float
|
|
reasoning: str
|
|
confidence: float
|
|
|
|
|
|
def _get_current_market_price(symbol: str) -> float:
|
|
"""Get current market price from price anchor service"""
|
|
try:
|
|
anchor_price = price_anchor_service.get_anchor_price_sync(symbol.upper().replace("/", ""))
|
|
if anchor_price and anchor_price > 0:
|
|
return anchor_price
|
|
except:
|
|
pass
|
|
|
|
# Fallback to a reasonable default for XAU/USD
|
|
return 2034.0
|
|
|
|
|
|
def _compute_equity(state: Dict[str, Any], price_hint: Optional[float] = None) -> float:
|
|
"""Compute total equity using cash and current position."""
|
|
cash = float(state.get("cash", 0.0) or 0.0)
|
|
position = state.get("position") or {}
|
|
|
|
if position:
|
|
current_price = price_hint or position.get("current_price") or position.get("avg_price") or 0.0
|
|
quantity = position.get("quantity", 0.0) or 0.0
|
|
cash += float(quantity) * float(current_price)
|
|
|
|
return cash
|
|
|
|
|
|
def _get_last_trade_defaults(state: Dict[str, Any]) -> Optional[Dict]:
|
|
"""Get defaults from the last trade"""
|
|
trades = state.get("trades", [])
|
|
if not trades:
|
|
return None
|
|
|
|
last_trade = trades[-1]
|
|
return {
|
|
"quantity": last_trade.get("quantity"),
|
|
"symbol": last_trade.get("symbol", "XAU/USD"),
|
|
"platform": last_trade.get("platform"),
|
|
"stop_loss": last_trade.get("stop_loss"),
|
|
"take_profit": last_trade.get("take_profit"),
|
|
"risk_percent": last_trade.get("risk_percent"),
|
|
}
|
|
|
|
|
|
def _calculate_smart_guards(
|
|
symbol: str,
|
|
action: str,
|
|
price: float,
|
|
quantity: float,
|
|
equity: float
|
|
) -> SmartGuardSuggestion:
|
|
"""
|
|
Calculate optimal stop loss and take profit using ATR and risk management principles
|
|
"""
|
|
try:
|
|
# Get market metrics including ATR
|
|
ctx = ai_context_builder.build_request(
|
|
symbol.upper().replace("/", ""),
|
|
"1h", # Use hourly for guard calculation
|
|
100
|
|
)
|
|
metrics = ai_context_builder.build_metrics(
|
|
symbol.upper().replace("/", ""),
|
|
"1h",
|
|
ctx.price_data
|
|
)
|
|
|
|
# Extract ATR value
|
|
atr = metrics.atr_14 if hasattr(metrics, 'atr_14') else (price * 0.015) # Default to 1.5%
|
|
|
|
# Calculate stop loss (1.5x ATR from entry)
|
|
sl_distance = atr * 1.5
|
|
sl_percent = (sl_distance / price) * 100
|
|
|
|
# Calculate take profit (2x stop loss for 1:2 risk/reward minimum)
|
|
tp_distance = sl_distance * 2.0
|
|
tp_percent = (tp_distance / price) * 100
|
|
|
|
if action == "BUY":
|
|
sl_price = price - sl_distance
|
|
tp_price = price + tp_distance
|
|
else: # SELL
|
|
sl_price = price + sl_distance
|
|
tp_price = price - tp_distance
|
|
|
|
# Calculate position risk as % of equity
|
|
risk_amount = quantity * sl_distance
|
|
risk_percent = (risk_amount / equity) * 100
|
|
|
|
# Ensure risk doesn't exceed 2% of equity (conservative default)
|
|
if risk_percent > 2.0:
|
|
# Adjust quantity to maintain 2% risk
|
|
adjusted_quantity = (equity * 0.02) / sl_distance
|
|
risk_percent = 2.0
|
|
else:
|
|
adjusted_quantity = quantity
|
|
|
|
return SmartGuardSuggestion(
|
|
stop_loss_price=round(sl_price, 2),
|
|
stop_loss_percent=round(sl_percent, 2),
|
|
take_profit_price=round(tp_price, 2),
|
|
take_profit_percent=round(tp_percent, 2),
|
|
risk_percent=round(risk_percent, 2),
|
|
position_size=round(adjusted_quantity, 2),
|
|
risk_reward_ratio=2.0,
|
|
reasoning=f"ATR-based guards: {atr:.2f} | 1.5x ATR stop | 1:2 R:R ratio | Max 2% risk",
|
|
confidence=0.85
|
|
)
|
|
|
|
except Exception as e:
|
|
# Fallback to simple percentage-based guards
|
|
sl_percent = 2.0
|
|
tp_percent = 4.0
|
|
|
|
if action == "BUY":
|
|
sl_price = price * (1 - sl_percent / 100)
|
|
tp_price = price * (1 + tp_percent / 100)
|
|
else:
|
|
sl_price = price * (1 + sl_percent / 100)
|
|
tp_price = price * (1 - tp_percent / 100)
|
|
|
|
risk_amount = quantity * price * (sl_percent / 100)
|
|
risk_percent = (risk_amount / equity) * 100
|
|
|
|
return SmartGuardSuggestion(
|
|
stop_loss_price=round(sl_price, 2),
|
|
stop_loss_percent=round(sl_percent, 2),
|
|
take_profit_price=round(tp_price, 2),
|
|
take_profit_percent=round(tp_percent, 2),
|
|
risk_percent=round(risk_percent, 2),
|
|
position_size=quantity,
|
|
risk_reward_ratio=2.0,
|
|
reasoning="Fallback guards: 2% stop loss | 4% take profit | 1:2 ratio",
|
|
confidence=0.60
|
|
)
|
|
|
|
|
|
@router.post("/prefill", response_model=SmartPreFillResponse)
|
|
async def get_smart_prefill(
|
|
symbol: str = Query("XAU/USD"),
|
|
action: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
user_id: str = "default",
|
|
) -> SmartPreFillResponse:
|
|
"""Get smart pre-fill suggestions based on last trade and current market context."""
|
|
try:
|
|
current_price = _get_current_market_price(symbol)
|
|
state = load_simulation_state(db, user_id)
|
|
last_trade = _get_last_trade_defaults(state)
|
|
|
|
suggested_quantity = 1.0
|
|
if last_trade and last_trade.get("quantity"):
|
|
suggested_quantity = last_trade["quantity"]
|
|
|
|
equity = _compute_equity(state, price_hint=current_price)
|
|
trade_action = (action or "BUY").upper()
|
|
guards = _calculate_smart_guards(
|
|
symbol,
|
|
trade_action,
|
|
current_price,
|
|
suggested_quantity,
|
|
equity,
|
|
)
|
|
|
|
market_context = {
|
|
"current_price": current_price,
|
|
"equity": equity,
|
|
"cash": state.get("cash", 0.0),
|
|
"position": state.get("position"),
|
|
}
|
|
|
|
return SmartPreFillResponse(
|
|
symbol=symbol,
|
|
suggested_quantity=suggested_quantity,
|
|
current_price=current_price,
|
|
suggested_guards={
|
|
"stop_loss": guards.stop_loss_price,
|
|
"take_profit": guards.take_profit_price,
|
|
"risk_percent": guards.risk_percent,
|
|
"reasoning": guards.reasoning,
|
|
"confidence": guards.confidence,
|
|
},
|
|
last_trade_context=last_trade,
|
|
market_context=market_context,
|
|
confidence=0.80,
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to generate pre-fill suggestions: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.post("/execute", response_model=SmartTradeResponse)
|
|
async def execute_smart_trade(
|
|
request: SmartTradeRequest,
|
|
db: Session = Depends(get_db),
|
|
user_id: str = "default",
|
|
) -> SmartTradeResponse:
|
|
"""Execute a trade through the unified smart trade hub using the persistent state."""
|
|
try:
|
|
source = request.source or TradeSource.MANUAL
|
|
if request.image_data:
|
|
source = TradeSource.OCR
|
|
elif request.voice_data:
|
|
source = TradeSource.VOICE
|
|
|
|
price = request.price or _get_current_market_price(request.symbol)
|
|
state = load_simulation_state(db, user_id)
|
|
|
|
last_trade = _get_last_trade_defaults(state) if request.use_last_trade_defaults else None
|
|
quantity = request.quantity
|
|
if quantity is None:
|
|
if last_trade and last_trade.get("quantity"):
|
|
quantity = last_trade["quantity"]
|
|
else:
|
|
quantity = 1.0
|
|
|
|
equity = _compute_equity(state, price_hint=price)
|
|
|
|
guards_applied = False
|
|
guards_suggested: Optional[Dict[str, Any]] = None
|
|
guards: Optional[SmartGuardSuggestion] = None
|
|
|
|
if request.apply_smart_guards:
|
|
guards = _calculate_smart_guards(
|
|
request.symbol,
|
|
request.action,
|
|
price,
|
|
quantity,
|
|
equity,
|
|
)
|
|
|
|
if request.stop_loss is None:
|
|
request.stop_loss = guards.stop_loss_price
|
|
guards_applied = True
|
|
|
|
if request.take_profit is None:
|
|
request.take_profit = guards.take_profit_price
|
|
guards_applied = True
|
|
|
|
if request.risk_percent is None:
|
|
request.risk_percent = guards.risk_percent
|
|
guards_applied = True
|
|
|
|
if guards.position_size != quantity:
|
|
quantity = guards.position_size
|
|
guards_applied = True
|
|
|
|
guards_suggested = guards.model_dump()
|
|
|
|
if request.action == "CLOSE":
|
|
position = state.get("position")
|
|
if not position:
|
|
raise HTTPException(status_code=400, detail="No position to close")
|
|
|
|
request.action = "SELL"
|
|
quantity = position.get("quantity", 0.0) or 0.0
|
|
|
|
try:
|
|
validate_order(state, request.action, quantity, price)
|
|
except ValueError as ve:
|
|
raise HTTPException(status_code=400, detail=str(ve))
|
|
|
|
persistent_request = PersistentTradeRequest(
|
|
action=request.action,
|
|
quantity=quantity,
|
|
price=price,
|
|
symbol=request.symbol,
|
|
notes=request.notes,
|
|
stop_loss=request.stop_loss,
|
|
take_profit=request.take_profit,
|
|
source=source,
|
|
platform=request.platform,
|
|
risk_percent=request.risk_percent,
|
|
entry_time=request.entry_time,
|
|
)
|
|
|
|
result = await persistent_execute_trade(persistent_request, db=db, user_id=user_id)
|
|
trade_info = result["trade"]
|
|
portfolio = result["portfolio"]
|
|
|
|
total_cost = trade_info.get("total", quantity * price)
|
|
executed_ts = trade_info.get("timestamp")
|
|
executed_at = (
|
|
datetime.fromtimestamp(executed_ts, tz=timezone.utc).isoformat()
|
|
if executed_ts
|
|
else datetime.now(timezone.utc).isoformat()
|
|
)
|
|
|
|
position_after = portfolio.get("position") or {}
|
|
position_size = position_after.get("quantity")
|
|
unrealized_pnl = position_after.get("unrealized_pnl")
|
|
total_equity = _compute_equity(portfolio, price_hint=price)
|
|
|
|
return SmartTradeResponse(
|
|
trade_id=trade_info["id"],
|
|
action=trade_info["action"],
|
|
symbol=request.symbol,
|
|
quantity=trade_info["quantity"],
|
|
price=trade_info["price"],
|
|
stop_loss=trade_info.get("stop_loss"),
|
|
take_profit=trade_info.get("take_profit"),
|
|
risk_percent=trade_info.get("risk_percent"),
|
|
source=source,
|
|
executed_at=executed_at,
|
|
total_cost=total_cost,
|
|
guards_applied=guards_applied,
|
|
guards_suggested=guards_suggested,
|
|
prefill_used=request.use_last_trade_defaults,
|
|
remaining_cash=portfolio.get("cash", 0.0),
|
|
total_equity=total_equity,
|
|
position_size=position_size,
|
|
unrealized_pnl=unrealized_pnl,
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to execute smart trade: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.get("/suggestions", response_model=SmartGuardSuggestion)
|
|
async def get_guard_suggestions(
|
|
symbol: str = Query("XAU/USD"),
|
|
action: str = Query("BUY"),
|
|
quantity: float = Query(1.0),
|
|
price: Optional[float] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
user_id: str = "default",
|
|
) -> SmartGuardSuggestion:
|
|
"""Get AI-suggested stop loss and take profit guards using persistent state."""
|
|
try:
|
|
if price is None:
|
|
price = _get_current_market_price(symbol)
|
|
|
|
state = load_simulation_state(db, user_id)
|
|
equity = _compute_equity(state, price_hint=price)
|
|
|
|
return _calculate_smart_guards(symbol, action, price, quantity, equity)
|
|
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to calculate guard suggestions: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.get("/history")
|
|
async def get_trade_history(
|
|
limit: int = Query(50, ge=1, le=500),
|
|
source: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
user_id: str = "default",
|
|
) -> Dict:
|
|
"""Get trade history with optional source filtering from persisted trades."""
|
|
try:
|
|
state = load_simulation_state(db, user_id)
|
|
trades = state.get("trades", [])
|
|
|
|
if source:
|
|
trades = [t for t in trades if t.get("source") == source]
|
|
|
|
trades = trades[-limit:]
|
|
|
|
sources = {
|
|
(t.get("source") or "unknown")
|
|
for t in state.get("trades", [])
|
|
}
|
|
|
|
return {
|
|
"trades": trades,
|
|
"total": len(trades),
|
|
"sources": sorted(sources),
|
|
}
|
|
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Failed to retrieve trade history: {str(e)}",
|
|
)
|