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,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from app.services.broker_bridge import BrokerError, broker_bridge_service
|
||||
|
||||
router = APIRouter(prefix="/brokers", tags=["Brokers"])
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
provider_id: str = Field(..., description="Broker provider identifier")
|
||||
api_key: str = Field(..., description="API key or session token")
|
||||
account_id: str = Field(..., description="Broker account identifier/login")
|
||||
demo: bool = Field(True, description="If true, stays in practice/demo mode when supported")
|
||||
|
||||
@validator("provider_id")
|
||||
def _trim(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("provider_id is required")
|
||||
return value
|
||||
|
||||
|
||||
class OrderRequest(BaseModel):
|
||||
action: str
|
||||
symbol: str
|
||||
quantity: float
|
||||
price: float
|
||||
type: str | None = None
|
||||
stopLoss: float | None = None
|
||||
takeProfit: float | None = None
|
||||
|
||||
|
||||
@router.get("/providers")
|
||||
async def list_providers():
|
||||
return broker_bridge_service.list_providers()
|
||||
|
||||
|
||||
@router.get("/session")
|
||||
async def get_session():
|
||||
return broker_bridge_service.get_session()
|
||||
|
||||
|
||||
@router.post("/connect")
|
||||
async def connect(request: ConnectRequest):
|
||||
try:
|
||||
return await broker_bridge_service.connect(
|
||||
request.provider_id,
|
||||
{
|
||||
"api_key": request.api_key,
|
||||
"account_id": request.account_id,
|
||||
"demo": request.demo,
|
||||
},
|
||||
)
|
||||
except BrokerError as exc: # pragma: no cover - depends on environment
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/disconnect")
|
||||
async def disconnect():
|
||||
await broker_bridge_service.disconnect()
|
||||
return {"status": "disconnected"}
|
||||
|
||||
|
||||
@router.post("/orders")
|
||||
async def place_order(request: OrderRequest):
|
||||
try:
|
||||
return await broker_bridge_service.place_order(request.dict())
|
||||
except BrokerError as exc: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_positions():
|
||||
try:
|
||||
return await broker_bridge_service.sync_positions()
|
||||
except BrokerError as exc: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
@@ -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()}
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Live Performance Dashboard API - Real-time plan monitoring and alerts
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Any, Dict, List, Optional, Literal
|
||||
from datetime import datetime, date, timezone
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.models.models import DailyChecklist, UserProfile
|
||||
from app.services.simulation_state import load_simulation_state
|
||||
|
||||
router = APIRouter(prefix="/api/live-dashboard", tags=["Live Dashboard"])
|
||||
|
||||
|
||||
class DailyPlanStatus(BaseModel):
|
||||
"""Current status of today's trading plan"""
|
||||
date: str
|
||||
target: float
|
||||
actual_pnl: float
|
||||
progress_percent: float
|
||||
max_loss: float
|
||||
current_drawdown: float
|
||||
max_trades: int
|
||||
actual_trades: int
|
||||
trades_remaining: int
|
||||
status: Literal["on-track", "near-limit", "limit-reached", "target-met"]
|
||||
alerts: List[str]
|
||||
|
||||
|
||||
class PerformanceWidget(BaseModel):
|
||||
"""Sticky dashboard widget data"""
|
||||
daily_plan: DailyPlanStatus
|
||||
position_summary: Dict
|
||||
risk_metrics: Dict
|
||||
alerts: List[Dict]
|
||||
recommendations: List[str]
|
||||
|
||||
|
||||
class AlertConfig(BaseModel):
|
||||
"""Alert configuration"""
|
||||
alert_type: str # trade_limit, loss_limit, target_achieved, break_recommended
|
||||
enabled: bool
|
||||
threshold: Optional[float] = None
|
||||
message: str
|
||||
|
||||
|
||||
# In-memory simulation state (shared with trading.py)
|
||||
|
||||
|
||||
def _get_today_plan_from_storage() -> Optional[Dict]:
|
||||
"""Get today's trading plan blueprint (defaults until persistence is added)."""
|
||||
# In production, this would query the database
|
||||
# For now, we'll use a default plan structure
|
||||
return {
|
||||
"date": date.today().isoformat(),
|
||||
"daily_target": 500.0,
|
||||
"max_loss": 250.0,
|
||||
"max_trades": 3,
|
||||
"bias": "NEUTRAL",
|
||||
}
|
||||
|
||||
|
||||
def _calculate_daily_pnl(trades: List[Dict[str, Any]], target_date: date | None = None) -> float:
|
||||
"""Calculate P&L for trades executed on the target date"""
|
||||
target_date = target_date or date.today()
|
||||
daily_pnl = 0.0
|
||||
for trade in trades:
|
||||
trade_ts = trade.get("timestamp", 0)
|
||||
trade_date = datetime.fromtimestamp(trade_ts, tz=timezone.utc).date()
|
||||
if trade_date == target_date:
|
||||
pnl = trade.get("pnl", 0.0)
|
||||
if pnl:
|
||||
daily_pnl += pnl
|
||||
return daily_pnl
|
||||
|
||||
|
||||
def _count_today_trades(trades: List[Dict[str, Any]], target_date: date | None = None) -> int:
|
||||
"""Count trades executed on the target date"""
|
||||
target_date = target_date or date.today()
|
||||
count = 0
|
||||
for trade in trades:
|
||||
trade_ts = trade.get("timestamp", 0)
|
||||
trade_date = datetime.fromtimestamp(trade_ts, tz=timezone.utc).date()
|
||||
if trade_date == target_date:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _generate_alerts(plan: Dict, actual_pnl: float, trades_count: int) -> List[str]:
|
||||
"""Generate smart alerts based on plan vs actual"""
|
||||
alerts = []
|
||||
|
||||
target = plan.get("daily_target", 500.0)
|
||||
max_loss = plan.get("max_loss", 250.0)
|
||||
max_trades = plan.get("max_trades", 3)
|
||||
|
||||
# Trade limit alerts
|
||||
trades_remaining = max_trades - trades_count
|
||||
if trades_remaining == 1:
|
||||
alerts.append(f"⚠️ Only 1 trade remaining before daily limit")
|
||||
elif trades_remaining <= 0:
|
||||
alerts.append(f"🛑 Daily trade limit reached ({max_trades} trades)")
|
||||
|
||||
# Loss alerts
|
||||
if actual_pnl < 0:
|
||||
loss_percent = (abs(actual_pnl) / max_loss) * 100
|
||||
if loss_percent >= 100:
|
||||
alerts.append(f"🚨 Max loss limit reached (${abs(actual_pnl):.2f})")
|
||||
elif loss_percent >= 80:
|
||||
alerts.append(f"⚠️ Near max loss limit ({loss_percent:.0f}% of ${max_loss})")
|
||||
elif loss_percent >= 50:
|
||||
alerts.append(f"⚡ Drawdown at {loss_percent:.0f}% of max loss")
|
||||
|
||||
# Target achievement alerts
|
||||
if actual_pnl > 0:
|
||||
progress_percent = (actual_pnl / target) * 100
|
||||
if progress_percent >= 100:
|
||||
alerts.append(f"🎉 Daily target achieved! (+${actual_pnl:.2f})")
|
||||
elif progress_percent >= 80:
|
||||
alerts.append(f"🎯 ${target - actual_pnl:.2f} away from daily target")
|
||||
|
||||
# Trading duration alerts (if 2+ hours and significant losses)
|
||||
if trades_count >= 2 and actual_pnl < -100:
|
||||
alerts.append(f"💡 Consider taking a break. ${abs(actual_pnl):.2f} in losses after {trades_count} trades")
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
def _determine_status(
|
||||
actual_pnl: float,
|
||||
target: float,
|
||||
max_loss: float,
|
||||
trades_count: int,
|
||||
max_trades: int
|
||||
) -> Literal["on-track", "near-limit", "limit-reached", "target-met"]:
|
||||
"""Determine overall plan status"""
|
||||
|
||||
# Target met
|
||||
if actual_pnl >= target:
|
||||
return "target-met"
|
||||
|
||||
# Limits reached
|
||||
if trades_count >= max_trades:
|
||||
return "limit-reached"
|
||||
|
||||
if actual_pnl <= -max_loss:
|
||||
return "limit-reached"
|
||||
|
||||
# Near limits
|
||||
loss_percent = (abs(actual_pnl) / max_loss) * 100 if actual_pnl < 0 else 0
|
||||
trades_percent = (trades_count / max_trades) * 100
|
||||
|
||||
if loss_percent >= 80 or trades_percent >= 80:
|
||||
return "near-limit"
|
||||
|
||||
# On track
|
||||
return "on-track"
|
||||
|
||||
|
||||
@router.get("/status", response_model=DailyPlanStatus)
|
||||
async def get_dashboard_status(db: Session = Depends(get_db)) -> DailyPlanStatus:
|
||||
"""
|
||||
Get current status of today's trading plan with real-time metrics
|
||||
"""
|
||||
try:
|
||||
plan = _get_today_plan_from_storage()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="No trading plan found for today")
|
||||
|
||||
state = load_simulation_state(db)
|
||||
trades = state.get("trades", [])
|
||||
actual_pnl = _calculate_daily_pnl(trades)
|
||||
trades_count = _count_today_trades(trades)
|
||||
|
||||
target = plan.get("daily_target", 500.0)
|
||||
max_loss = plan.get("max_loss", 250.0)
|
||||
max_trades = plan.get("max_trades", 3)
|
||||
|
||||
progress_percent = (actual_pnl / target) * 100 if target > 0 else 0
|
||||
current_drawdown = abs(actual_pnl) if actual_pnl < 0 else 0
|
||||
trades_remaining = max(0, max_trades - trades_count)
|
||||
|
||||
alerts = _generate_alerts(plan, actual_pnl, trades_count)
|
||||
status = _determine_status(actual_pnl, target, max_loss, trades_count, max_trades)
|
||||
|
||||
return DailyPlanStatus(
|
||||
date=plan["date"],
|
||||
target=target,
|
||||
actual_pnl=actual_pnl,
|
||||
progress_percent=round(progress_percent, 1),
|
||||
max_loss=max_loss,
|
||||
current_drawdown=current_drawdown,
|
||||
max_trades=max_trades,
|
||||
actual_trades=trades_count,
|
||||
trades_remaining=trades_remaining,
|
||||
status=status,
|
||||
alerts=alerts,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get dashboard status: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/widget", response_model=PerformanceWidget)
|
||||
async def get_performance_widget(db: Session = Depends(get_db)) -> PerformanceWidget:
|
||||
"""
|
||||
Get complete performance widget data for sticky dashboard
|
||||
"""
|
||||
try:
|
||||
# Get daily plan status
|
||||
daily_plan = await get_dashboard_status(db=db)
|
||||
|
||||
state = load_simulation_state(db)
|
||||
position = state.get("position")
|
||||
cash = float(state.get("cash", 100000.0))
|
||||
position_value = 0.0
|
||||
if position:
|
||||
position_value = float(position.get("quantity", 0.0)) * float(position.get("avg_price", 0.0))
|
||||
|
||||
total_equity = cash + position_value
|
||||
position_summary = {
|
||||
"has_position": position is not None,
|
||||
"quantity": float(position.get("quantity", 0.0)) if position else 0,
|
||||
"avg_price": float(position.get("avg_price", 0.0)) if position else 0,
|
||||
"cash": cash,
|
||||
"total_equity": total_equity,
|
||||
}
|
||||
|
||||
# Calculate risk metrics
|
||||
initial_capital = float(state.get("initial_capital", 100000.0))
|
||||
safe_equity = total_equity if total_equity != 0 else 1
|
||||
total_return = ((total_equity - initial_capital) / initial_capital) * 100 if initial_capital else 0
|
||||
|
||||
risk_metrics = {
|
||||
"total_equity": total_equity,
|
||||
"total_return_percent": round(total_return, 2),
|
||||
"position_size_percent": round((position_value / safe_equity * 100), 2) if position else 0,
|
||||
"cash_percent": round((cash / safe_equity * 100), 2),
|
||||
}
|
||||
|
||||
# Generate smart recommendations
|
||||
recommendations = []
|
||||
|
||||
if daily_plan.status == "target-met":
|
||||
recommendations.append("🎉 Consider closing for the day - target achieved!")
|
||||
elif daily_plan.status == "limit-reached":
|
||||
recommendations.append("🛑 Trading halt recommended - daily limits reached")
|
||||
elif daily_plan.status == "near-limit":
|
||||
if daily_plan.trades_remaining == 1:
|
||||
recommendations.append("⚠️ Last trade available - make it count")
|
||||
if daily_plan.current_drawdown > daily_plan.max_loss * 0.8:
|
||||
recommendations.append("🔻 Consider defensive position sizing")
|
||||
else:
|
||||
if daily_plan.actual_pnl > daily_plan.target * 0.7:
|
||||
recommendations.append("🎯 Near target - consider taking profits")
|
||||
|
||||
# Alert objects with metadata
|
||||
alert_objects = [
|
||||
{
|
||||
"type": "info",
|
||||
"message": alert,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
for alert in daily_plan.alerts
|
||||
]
|
||||
|
||||
return PerformanceWidget(
|
||||
daily_plan=daily_plan,
|
||||
position_summary=position_summary,
|
||||
risk_metrics=risk_metrics,
|
||||
alerts=alert_objects,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get performance widget: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/check-limits")
|
||||
async def check_trading_limits(db: Session = Depends(get_db)) -> Dict:
|
||||
"""
|
||||
Check if trading should be halted based on plan limits
|
||||
Returns: {can_trade: bool, reason: str}
|
||||
"""
|
||||
try:
|
||||
plan = _get_today_plan_from_storage()
|
||||
if not plan:
|
||||
return {"can_trade": True, "reason": "No plan configured"}
|
||||
|
||||
state = load_simulation_state(db)
|
||||
trades = state.get("trades", [])
|
||||
actual_pnl = _calculate_daily_pnl(trades)
|
||||
trades_count = _count_today_trades(trades)
|
||||
|
||||
max_loss = plan.get("max_loss", 250.0)
|
||||
max_trades = plan.get("max_trades", 3)
|
||||
target = plan.get("daily_target", 500.0)
|
||||
|
||||
if actual_pnl <= -max_loss:
|
||||
return {
|
||||
"can_trade": False,
|
||||
"reason": f"Max loss limit reached (${abs(actual_pnl):.2f})",
|
||||
"limit_type": "loss",
|
||||
}
|
||||
|
||||
if trades_count >= max_trades:
|
||||
return {
|
||||
"can_trade": False,
|
||||
"reason": f"Max trades limit reached ({trades_count}/{max_trades})",
|
||||
"limit_type": "trades",
|
||||
}
|
||||
|
||||
if actual_pnl >= target:
|
||||
return {
|
||||
"can_trade": True,
|
||||
"reason": f"Target achieved (+${actual_pnl:.2f}) - consider closing for the day",
|
||||
"warning": True,
|
||||
}
|
||||
|
||||
return {
|
||||
"can_trade": True,
|
||||
"reason": "Within limits",
|
||||
"remaining_trades": max_trades - trades_count,
|
||||
"remaining_loss_buffer": max_loss + actual_pnl,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to check trading limits: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/session-summary")
|
||||
async def get_session_summary(db: Session = Depends(get_db)) -> Dict:
|
||||
"""
|
||||
Get end-of-day session summary with AI coaching suggestions
|
||||
"""
|
||||
try:
|
||||
plan = _get_today_plan_from_storage()
|
||||
state = load_simulation_state(db)
|
||||
trades = state.get("trades", [])
|
||||
actual_pnl = _calculate_daily_pnl(trades)
|
||||
trades_count = _count_today_trades(trades)
|
||||
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="No trading plan found")
|
||||
|
||||
target = plan.get("daily_target", 500.0)
|
||||
max_loss = plan.get("max_loss", 250.0)
|
||||
|
||||
target_achieved = actual_pnl >= target
|
||||
within_limits = actual_pnl > -max_loss and trades_count <= plan.get("max_trades", 3)
|
||||
|
||||
today = date.today()
|
||||
today_trades = [
|
||||
t for t in trades
|
||||
if datetime.fromtimestamp(t.get("timestamp", 0), tz=timezone.utc).date() == today
|
||||
]
|
||||
|
||||
winning_trades = sum(1 for t in today_trades if t.get("pnl", 0) > 0)
|
||||
win_rate = (winning_trades / len(today_trades) * 100) if today_trades else 0
|
||||
|
||||
coaching = []
|
||||
if target_achieved:
|
||||
coaching.append("✅ Excellent discipline - you met your daily target!")
|
||||
else:
|
||||
deficit = target - actual_pnl
|
||||
coaching.append(f"📊 ${deficit:.2f} short of target. Review your entry setups.")
|
||||
|
||||
if win_rate >= 60:
|
||||
coaching.append(f"🎯 Strong win rate ({win_rate:.0f}%). Keep following your strategy.")
|
||||
elif win_rate < 40:
|
||||
coaching.append(f"⚠️ Low win rate ({win_rate:.0f}%). Review your trade selection criteria.")
|
||||
|
||||
if not within_limits:
|
||||
coaching.append("🔻 Limits exceeded. Focus on risk management tomorrow.")
|
||||
if trades_count > plan.get("max_trades", 3):
|
||||
coaching.append("⚠️ Over-trading detected. Stick to your max trades limit.")
|
||||
|
||||
return {
|
||||
"date": plan["date"],
|
||||
"summary": {
|
||||
"target": target,
|
||||
"actual_pnl": actual_pnl,
|
||||
"target_achieved": target_achieved,
|
||||
"within_limits": within_limits,
|
||||
"trades_count": trades_count,
|
||||
"win_rate": round(win_rate, 1),
|
||||
},
|
||||
"coaching": coaching,
|
||||
"next_session_suggestions": [
|
||||
"Review today's winning trades for patterns",
|
||||
"Adjust stop loss strategy if needed",
|
||||
"Focus on high-probability setups only",
|
||||
],
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to generate session summary: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Ollama API endpoints for local AI status and simple tasks.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from app.services.ollama_service import ollama_service
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/ollama", tags=["Local AI"])
|
||||
|
||||
|
||||
class OllamaStatus(BaseModel):
|
||||
available: bool
|
||||
model: str
|
||||
embed_model: str
|
||||
base_url: str
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
prompt: str
|
||||
system: Optional[str] = None
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 500
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
response: Optional[str]
|
||||
model: str
|
||||
success: bool
|
||||
|
||||
|
||||
class SentimentRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class SentimentResponse(BaseModel):
|
||||
sentiment: Optional[str]
|
||||
confidence: Optional[float]
|
||||
success: bool
|
||||
|
||||
|
||||
class ClassifyRequest(BaseModel):
|
||||
text: str
|
||||
categories: List[str]
|
||||
|
||||
|
||||
class ClassifyResponse(BaseModel):
|
||||
category: Optional[str]
|
||||
success: bool
|
||||
|
||||
|
||||
class SummarizeRequest(BaseModel):
|
||||
text: str
|
||||
max_sentences: int = 2
|
||||
|
||||
|
||||
class SummarizeResponse(BaseModel):
|
||||
summary: Optional[str]
|
||||
success: bool
|
||||
|
||||
|
||||
@router.get("/status", response_model=OllamaStatus)
|
||||
async def get_ollama_status():
|
||||
"""Check if Ollama is available and configured."""
|
||||
available = await ollama_service.is_available()
|
||||
return OllamaStatus(
|
||||
available=available,
|
||||
model=settings.OLLAMA_MODEL,
|
||||
embed_model=settings.OLLAMA_MODEL_EMBED,
|
||||
base_url=settings.OLLAMA_BASE_URL
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate", response_model=GenerateResponse)
|
||||
async def generate_text(request: GenerateRequest):
|
||||
"""Generate text using local Ollama model."""
|
||||
result = await ollama_service.generate(
|
||||
prompt=request.prompt,
|
||||
system=request.system,
|
||||
temperature=request.temperature,
|
||||
max_tokens=request.max_tokens
|
||||
)
|
||||
return GenerateResponse(
|
||||
response=result,
|
||||
model=settings.OLLAMA_MODEL,
|
||||
success=result is not None
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sentiment", response_model=SentimentResponse)
|
||||
async def analyze_sentiment(request: SentimentRequest):
|
||||
"""Quick sentiment analysis using local model."""
|
||||
result = await ollama_service.quick_sentiment(request.text)
|
||||
if result:
|
||||
return SentimentResponse(
|
||||
sentiment=result.get("sentiment"),
|
||||
confidence=result.get("confidence"),
|
||||
success=True
|
||||
)
|
||||
return SentimentResponse(sentiment=None, confidence=None, success=False)
|
||||
|
||||
|
||||
@router.post("/classify", response_model=ClassifyResponse)
|
||||
async def classify_text(request: ClassifyRequest):
|
||||
"""Classify text into one of the provided categories."""
|
||||
result = await ollama_service.quick_classify(request.text, request.categories)
|
||||
return ClassifyResponse(
|
||||
category=result,
|
||||
success=result is not None
|
||||
)
|
||||
|
||||
|
||||
@router.post("/summarize", response_model=SummarizeResponse)
|
||||
async def summarize_text(request: SummarizeRequest):
|
||||
"""Quick text summarization using local model."""
|
||||
result = await ollama_service.quick_summarize(
|
||||
text=request.text,
|
||||
max_sentences=request.max_sentences
|
||||
)
|
||||
return SummarizeResponse(
|
||||
summary=result,
|
||||
success=result is not None
|
||||
)
|
||||
@@ -0,0 +1,558 @@
|
||||
"""
|
||||
Position Management Assistant API
|
||||
Provides intelligent mitigation plans, exit strategies, and risk monitoring for active positions
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Literal
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import numpy as np
|
||||
|
||||
router = APIRouter(prefix="/api/position-assistant", tags=["Position Assistant"])
|
||||
|
||||
|
||||
class ActivePosition(BaseModel):
|
||||
"""Current active position details"""
|
||||
symbol: str = Field(default="XAU/USD")
|
||||
direction: Literal["LONG", "SHORT"]
|
||||
entry_price: float
|
||||
quantity: float
|
||||
stop_loss: float
|
||||
take_profit: Optional[float] = None
|
||||
entry_time: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class MitigationStrategy(BaseModel):
|
||||
"""Smart mitigation strategy for managing risk"""
|
||||
strategy_name: str
|
||||
priority: int # 1 = highest priority
|
||||
action: str
|
||||
trigger_price: float
|
||||
reasoning: str
|
||||
expected_benefit: str
|
||||
risk_level: Literal["LOW", "MEDIUM", "HIGH"]
|
||||
|
||||
|
||||
class PriceReversal(BaseModel):
|
||||
"""Predicted price reversal levels and timing"""
|
||||
level: float
|
||||
probability: float # 0-1
|
||||
timeframe: str # e.g., "2-4 hours", "End of day"
|
||||
reasoning: str
|
||||
confluences: List[str]
|
||||
|
||||
|
||||
class PositionHealth(BaseModel):
|
||||
"""Real-time position health assessment"""
|
||||
status: Literal["HEALTHY", "AT_RISK", "CRITICAL", "WINNING"]
|
||||
current_pnl: float
|
||||
current_pnl_percent: float
|
||||
distance_to_stop_loss: float
|
||||
distance_to_stop_loss_percent: float
|
||||
time_in_trade: str
|
||||
recommendation: str
|
||||
urgency: Literal["LOW", "MEDIUM", "HIGH", "URGENT"]
|
||||
|
||||
|
||||
class PositionManagementPlan(BaseModel):
|
||||
"""Complete position management plan"""
|
||||
position: ActivePosition
|
||||
current_price: float
|
||||
health: PositionHealth
|
||||
mitigation_strategies: List[MitigationStrategy]
|
||||
reversal_zones: List[PriceReversal]
|
||||
exit_plan: Dict
|
||||
alerts: List[str]
|
||||
next_actions: List[str]
|
||||
|
||||
|
||||
def _calculate_position_health(
|
||||
position: ActivePosition,
|
||||
current_price: float
|
||||
) -> PositionHealth:
|
||||
"""Calculate real-time position health"""
|
||||
|
||||
# Calculate P&L
|
||||
if position.direction == "SHORT":
|
||||
pnl = (position.entry_price - current_price) * position.quantity
|
||||
pnl_percent = ((position.entry_price - current_price) / position.entry_price) * 100
|
||||
distance_to_sl = position.stop_loss - current_price
|
||||
else: # LONG
|
||||
pnl = (current_price - position.entry_price) * position.quantity
|
||||
pnl_percent = ((current_price - position.entry_price) / position.entry_price) * 100
|
||||
distance_to_sl = current_price - position.stop_loss
|
||||
|
||||
distance_to_sl_percent = (distance_to_sl / position.entry_price) * 100
|
||||
|
||||
# Calculate time in trade
|
||||
entry_dt = datetime.fromisoformat(position.entry_time.replace('Z', '+00:00'))
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
time_diff = now_dt - entry_dt
|
||||
hours = time_diff.total_seconds() / 3600
|
||||
|
||||
if hours < 1:
|
||||
time_in_trade = f"{int(time_diff.total_seconds() / 60)} minutes"
|
||||
elif hours < 24:
|
||||
time_in_trade = f"{hours:.1f} hours"
|
||||
else:
|
||||
time_in_trade = f"{hours/24:.1f} days"
|
||||
|
||||
# Determine status and urgency
|
||||
if pnl > 0:
|
||||
if pnl_percent > 2:
|
||||
status = "WINNING"
|
||||
urgency = "LOW"
|
||||
recommendation = "Consider taking partial profits to secure gains"
|
||||
else:
|
||||
status = "HEALTHY"
|
||||
urgency = "LOW"
|
||||
recommendation = "Monitor for continuation or reversal signals"
|
||||
else:
|
||||
loss_percent_of_sl = abs(pnl_percent) / abs((position.stop_loss - position.entry_price) / position.entry_price * 100)
|
||||
|
||||
if loss_percent_of_sl > 0.8:
|
||||
status = "CRITICAL"
|
||||
urgency = "URGENT"
|
||||
recommendation = "CLOSE POSITION NOW or implement emergency mitigation"
|
||||
elif loss_percent_of_sl > 0.5:
|
||||
status = "AT_RISK"
|
||||
urgency = "HIGH"
|
||||
recommendation = "Consider scaling out or tightening stop loss"
|
||||
else:
|
||||
status = "AT_RISK"
|
||||
urgency = "MEDIUM"
|
||||
recommendation = "Watch for reversal signals, keep stop loss in place"
|
||||
|
||||
return PositionHealth(
|
||||
status=status,
|
||||
current_pnl=round(pnl, 2),
|
||||
current_pnl_percent=round(pnl_percent, 2),
|
||||
distance_to_stop_loss=round(distance_to_sl, 2),
|
||||
distance_to_stop_loss_percent=round(distance_to_sl_percent, 2),
|
||||
time_in_trade=time_in_trade,
|
||||
recommendation=recommendation,
|
||||
urgency=urgency
|
||||
)
|
||||
|
||||
|
||||
def _generate_mitigation_strategies(
|
||||
position: ActivePosition,
|
||||
current_price: float,
|
||||
health: PositionHealth
|
||||
) -> List[MitigationStrategy]:
|
||||
"""Generate smart mitigation strategies"""
|
||||
|
||||
strategies = []
|
||||
|
||||
if position.direction == "SHORT":
|
||||
# SHORT position mitigation strategies
|
||||
|
||||
# Strategy 1: Partial close at break-even
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Break-Even Exit (Partial)",
|
||||
priority=1,
|
||||
action=f"Close 50% of position at ${position.entry_price:.2f}",
|
||||
trigger_price=position.entry_price,
|
||||
reasoning="Lock in zero loss on half the position if price retraces to entry",
|
||||
expected_benefit="Reduces risk by 50% while keeping upside exposure",
|
||||
risk_level="LOW"
|
||||
))
|
||||
|
||||
# Strategy 2: Scale out in profit
|
||||
if current_price < position.entry_price:
|
||||
target_1 = position.entry_price - (position.entry_price - current_price) * 1.5
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Scale Out (First Target)",
|
||||
priority=2,
|
||||
action=f"Close 30% of position at ${target_1:.2f}",
|
||||
trigger_price=target_1,
|
||||
reasoning="Take partial profits at 1.5x current movement",
|
||||
expected_benefit="Secure profits while maintaining exposure",
|
||||
risk_level="LOW"
|
||||
))
|
||||
|
||||
# Strategy 3: Move stop to break-even
|
||||
if health.current_pnl > 0:
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Move Stop to Break-Even",
|
||||
priority=3,
|
||||
action=f"Move stop loss from ${position.stop_loss:.2f} to ${position.entry_price:.2f}",
|
||||
trigger_price=current_price,
|
||||
reasoning="Eliminate downside risk once in profit",
|
||||
expected_benefit="Cannot lose money on this trade anymore",
|
||||
risk_level="LOW"
|
||||
))
|
||||
|
||||
# Strategy 4: Emergency hedge
|
||||
if health.status == "CRITICAL":
|
||||
hedge_price = position.entry_price + (position.stop_loss - position.entry_price) * 0.5
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Emergency Hedge (LONG)",
|
||||
priority=1,
|
||||
action=f"Open LONG position at ${current_price:.2f} (same size)",
|
||||
trigger_price=current_price,
|
||||
reasoning="Neutralize the position to stop bleeding while you reassess",
|
||||
expected_benefit="Stop further losses immediately",
|
||||
risk_level="HIGH"
|
||||
))
|
||||
|
||||
# Strategy 5: Widen stop temporarily
|
||||
if health.status == "AT_RISK" and health.urgency == "HIGH":
|
||||
new_sl = position.stop_loss + (position.stop_loss - position.entry_price) * 0.3
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Temporary Stop Widening",
|
||||
priority=4,
|
||||
action=f"Widen stop loss to ${new_sl:.2f} temporarily",
|
||||
trigger_price=current_price,
|
||||
reasoning="Give position room to breathe during volatility spike",
|
||||
expected_benefit="Avoid premature stop-out if reversal is coming",
|
||||
risk_level="MEDIUM"
|
||||
))
|
||||
|
||||
else: # LONG position
|
||||
# LONG position mitigation strategies (mirror of SHORT)
|
||||
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Break-Even Exit (Partial)",
|
||||
priority=1,
|
||||
action=f"Close 50% of position at ${position.entry_price:.2f}",
|
||||
trigger_price=position.entry_price,
|
||||
reasoning="Lock in zero loss on half the position if price retraces to entry",
|
||||
expected_benefit="Reduces risk by 50% while keeping upside exposure",
|
||||
risk_level="LOW"
|
||||
))
|
||||
|
||||
if current_price > position.entry_price:
|
||||
target_1 = position.entry_price + (current_price - position.entry_price) * 1.5
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Scale Out (First Target)",
|
||||
priority=2,
|
||||
action=f"Close 30% of position at ${target_1:.2f}",
|
||||
trigger_price=target_1,
|
||||
reasoning="Take partial profits at 1.5x current movement",
|
||||
expected_benefit="Secure profits while maintaining exposure",
|
||||
risk_level="LOW"
|
||||
))
|
||||
|
||||
if health.current_pnl > 0:
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Move Stop to Break-Even",
|
||||
priority=3,
|
||||
action=f"Move stop loss from ${position.stop_loss:.2f} to ${position.entry_price:.2f}",
|
||||
trigger_price=current_price,
|
||||
reasoning="Eliminate downside risk once in profit",
|
||||
expected_benefit="Cannot lose money on this trade anymore",
|
||||
risk_level="LOW"
|
||||
))
|
||||
|
||||
# Sort by priority
|
||||
strategies.sort(key=lambda x: x.priority)
|
||||
|
||||
return strategies
|
||||
|
||||
|
||||
def _predict_reversal_zones(
|
||||
position: ActivePosition,
|
||||
current_price: float
|
||||
) -> List[PriceReversal]:
|
||||
"""Predict potential reversal zones using technical analysis"""
|
||||
|
||||
reversals = []
|
||||
|
||||
if position.direction == "SHORT":
|
||||
# For SHORT: Looking for price to drop (reversal down from current)
|
||||
|
||||
# Support level 1: 0.5 Fibonacci from entry to current
|
||||
fib_50 = position.entry_price - (position.entry_price - current_price) * 0.5
|
||||
if current_price > position.entry_price: # If against us
|
||||
fib_50 = current_price - (current_price - position.entry_price) * 0.382
|
||||
reversals.append(PriceReversal(
|
||||
level=round(fib_50, 2),
|
||||
probability=0.65,
|
||||
timeframe="2-4 hours",
|
||||
reasoning="38.2% Fibonacci retracement - common reversal zone",
|
||||
confluences=["Fibonacci level", "Potential exhaustion zone"]
|
||||
))
|
||||
|
||||
# Support level 2: Round number below entry
|
||||
round_number = (int(position.entry_price / 100) * 100) - 100
|
||||
if round_number < current_price:
|
||||
reversals.append(PriceReversal(
|
||||
level=round(round_number, 2),
|
||||
probability=0.55,
|
||||
timeframe="4-8 hours",
|
||||
reasoning="Major round number psychological support",
|
||||
confluences=["Round number", "Psychological level"]
|
||||
))
|
||||
|
||||
# Support level 3: Previous day low (simulated)
|
||||
prev_day_low = position.entry_price - (position.entry_price * 0.015) # 1.5% below entry
|
||||
reversals.append(PriceReversal(
|
||||
level=round(prev_day_low, 2),
|
||||
probability=0.70,
|
||||
timeframe="End of day",
|
||||
reasoning="Estimated previous day low - strong support",
|
||||
confluences=["Previous low", "Session support"]
|
||||
))
|
||||
|
||||
else: # LONG
|
||||
# For LONG: Looking for price to rise (reversal up from current)
|
||||
|
||||
fib_50 = position.entry_price + (current_price - position.entry_price) * 0.5
|
||||
if current_price < position.entry_price: # If against us
|
||||
fib_50 = current_price + (position.entry_price - current_price) * 0.382
|
||||
reversals.append(PriceReversal(
|
||||
level=round(fib_50, 2),
|
||||
probability=0.65,
|
||||
timeframe="2-4 hours",
|
||||
reasoning="38.2% Fibonacci retracement - common reversal zone",
|
||||
confluences=["Fibonacci level", "Potential exhaustion zone"]
|
||||
))
|
||||
|
||||
round_number = (int(position.entry_price / 100) * 100) + 100
|
||||
if round_number > current_price:
|
||||
reversals.append(PriceReversal(
|
||||
level=round(round_number, 2),
|
||||
probability=0.55,
|
||||
timeframe="4-8 hours",
|
||||
reasoning="Major round number psychological resistance",
|
||||
confluences=["Round number", "Psychological level"]
|
||||
))
|
||||
|
||||
prev_day_high = position.entry_price + (position.entry_price * 0.015)
|
||||
reversals.append(PriceReversal(
|
||||
level=round(prev_day_high, 2),
|
||||
probability=0.70,
|
||||
timeframe="End of day",
|
||||
reasoning="Estimated previous day high - strong resistance",
|
||||
confluences=["Previous high", "Session resistance"]
|
||||
))
|
||||
|
||||
# Sort by probability (highest first)
|
||||
reversals.sort(key=lambda x: x.probability, reverse=True)
|
||||
|
||||
return reversals
|
||||
|
||||
|
||||
def _create_exit_plan(
|
||||
position: ActivePosition,
|
||||
current_price: float,
|
||||
health: PositionHealth,
|
||||
reversals: List[PriceReversal]
|
||||
) -> Dict:
|
||||
"""Create comprehensive exit plan"""
|
||||
|
||||
plan = {
|
||||
"immediate_action": None,
|
||||
"optimal_exits": [],
|
||||
"emergency_exit": None,
|
||||
"time_based_exit": None
|
||||
}
|
||||
|
||||
if health.status == "CRITICAL":
|
||||
plan["immediate_action"] = {
|
||||
"action": "CLOSE IMMEDIATELY",
|
||||
"reason": "Position is critically at risk",
|
||||
"price": current_price
|
||||
}
|
||||
plan["emergency_exit"] = {
|
||||
"action": "Market order close if stop loss hit",
|
||||
"trigger": position.stop_loss,
|
||||
"loss_amount": health.current_pnl if health.current_pnl < 0 else 0
|
||||
}
|
||||
|
||||
elif health.status == "WINNING":
|
||||
# Build scaling out plan
|
||||
if position.direction == "SHORT":
|
||||
target_1 = current_price - (position.entry_price - current_price) * 0.5
|
||||
target_2 = current_price - (position.entry_price - current_price) * 1.0
|
||||
else:
|
||||
target_1 = current_price + (current_price - position.entry_price) * 0.5
|
||||
target_2 = current_price + (current_price - position.entry_price) * 1.0
|
||||
|
||||
plan["optimal_exits"] = [
|
||||
{
|
||||
"level": 1,
|
||||
"price": round(target_1, 2),
|
||||
"quantity_percent": 33,
|
||||
"reason": "First profit target - secure initial gains"
|
||||
},
|
||||
{
|
||||
"level": 2,
|
||||
"price": round(target_2, 2),
|
||||
"quantity_percent": 33,
|
||||
"reason": "Second profit target - let winners run"
|
||||
},
|
||||
{
|
||||
"level": 3,
|
||||
"price": "Trailing stop",
|
||||
"quantity_percent": 34,
|
||||
"reason": "Trail remaining with break-even stop"
|
||||
}
|
||||
]
|
||||
|
||||
else: # AT_RISK or HEALTHY
|
||||
# Exit at reversal zones
|
||||
plan["optimal_exits"] = [
|
||||
{
|
||||
"level": i + 1,
|
||||
"price": rev.level,
|
||||
"quantity_percent": 100 if i == 0 else 50,
|
||||
"reason": f"{rev.reasoning} ({int(rev.probability*100)}% probability)"
|
||||
}
|
||||
for i, rev in enumerate(reversals[:2])
|
||||
]
|
||||
|
||||
# Time-based exit (end of day or session)
|
||||
hours_in_trade = (datetime.now(timezone.utc) - datetime.fromisoformat(position.entry_time.replace('Z', '+00:00'))).total_seconds() / 3600
|
||||
|
||||
if hours_in_trade > 4 and health.status != "WINNING":
|
||||
plan["time_based_exit"] = {
|
||||
"time": "End of trading session",
|
||||
"action": "Review and consider closing if no reversal",
|
||||
"reason": "Avoid holding losing position overnight"
|
||||
}
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
@router.post("/analyze", response_model=PositionManagementPlan)
|
||||
async def analyze_position(
|
||||
position: ActivePosition,
|
||||
current_price: float = Query(..., description="Current market price")
|
||||
) -> PositionManagementPlan:
|
||||
"""
|
||||
Analyze active position and provide comprehensive management plan
|
||||
|
||||
Example:
|
||||
```
|
||||
POST /api/position-assistant/analyze?current_price=4085
|
||||
{
|
||||
"direction": "SHORT",
|
||||
"entry_price": 4070,
|
||||
"quantity": 1.0,
|
||||
"stop_loss": 4109,
|
||||
"entry_time": "2025-11-24T10:00:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
try:
|
||||
# Calculate position health
|
||||
health = _calculate_position_health(position, current_price)
|
||||
|
||||
# Generate mitigation strategies
|
||||
strategies = _generate_mitigation_strategies(position, current_price, health)
|
||||
|
||||
# Predict reversal zones
|
||||
reversals = _predict_reversal_zones(position, current_price)
|
||||
|
||||
# Create exit plan
|
||||
exit_plan = _create_exit_plan(position, current_price, health, reversals)
|
||||
|
||||
# Generate alerts
|
||||
alerts = []
|
||||
|
||||
if health.status == "CRITICAL":
|
||||
alerts.append("🚨 URGENT: Position at critical risk level")
|
||||
alerts.append(f"⚠️ Stop loss ${abs(health.distance_to_stop_loss):.2f} away")
|
||||
elif health.status == "AT_RISK" and health.urgency == "HIGH":
|
||||
alerts.append(f"⚠️ Position down {abs(health.current_pnl_percent):.1f}%")
|
||||
alerts.append("💡 Consider mitigation strategies")
|
||||
elif health.status == "WINNING":
|
||||
alerts.append(f"✅ Position up {health.current_pnl_percent:.1f}%")
|
||||
alerts.append("🎯 Consider taking partial profits")
|
||||
|
||||
# Generate next actions
|
||||
next_actions = []
|
||||
|
||||
if strategies:
|
||||
top_strategy = strategies[0]
|
||||
next_actions.append(f"📋 Primary: {top_strategy.action}")
|
||||
|
||||
if reversals:
|
||||
top_reversal = reversals[0]
|
||||
next_actions.append(f"🎯 Watch for reversal at ${top_reversal.level:.2f} ({top_reversal.timeframe})")
|
||||
|
||||
if exit_plan.get("immediate_action"):
|
||||
next_actions.insert(0, f"🚨 {exit_plan['immediate_action']['action']}")
|
||||
|
||||
return PositionManagementPlan(
|
||||
position=position,
|
||||
current_price=current_price,
|
||||
health=health,
|
||||
mitigation_strategies=strategies,
|
||||
reversal_zones=reversals,
|
||||
exit_plan=exit_plan,
|
||||
alerts=alerts,
|
||||
next_actions=next_actions
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to analyze position: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/quick-status")
|
||||
async def get_quick_status(
|
||||
direction: str = Query(..., description="LONG or SHORT"),
|
||||
entry_price: float = Query(...),
|
||||
current_price: float = Query(...),
|
||||
stop_loss: float = Query(...)
|
||||
) -> Dict:
|
||||
"""
|
||||
Quick position status check without full analysis
|
||||
|
||||
Example:
|
||||
```
|
||||
GET /api/position-assistant/quick-status?direction=SHORT&entry_price=4070¤t_price=4085&stop_loss=4109
|
||||
```
|
||||
"""
|
||||
|
||||
try:
|
||||
# Quick P&L calculation
|
||||
if direction.upper() == "SHORT":
|
||||
pnl = entry_price - current_price
|
||||
pnl_percent = ((entry_price - current_price) / entry_price) * 100
|
||||
distance_to_sl = stop_loss - current_price
|
||||
else:
|
||||
pnl = current_price - entry_price
|
||||
pnl_percent = ((current_price - entry_price) / entry_price) * 100
|
||||
distance_to_sl = current_price - stop_loss
|
||||
|
||||
distance_to_sl_percent = (distance_to_sl / entry_price) * 100
|
||||
|
||||
# Quick status
|
||||
if pnl > 0:
|
||||
status = "✅ In Profit"
|
||||
color = "green"
|
||||
else:
|
||||
loss_ratio = abs(distance_to_sl_percent / ((stop_loss - entry_price) / entry_price * 100))
|
||||
if loss_ratio > 0.8:
|
||||
status = "🚨 CRITICAL - Close to stop loss"
|
||||
color = "red"
|
||||
elif loss_ratio > 0.5:
|
||||
status = "⚠️ AT RISK"
|
||||
color = "orange"
|
||||
else:
|
||||
status = "📊 Monitoring"
|
||||
color = "yellow"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"color": color,
|
||||
"pnl": round(pnl, 2),
|
||||
"pnl_percent": round(pnl_percent, 2),
|
||||
"distance_to_stop_loss": round(abs(distance_to_sl), 2),
|
||||
"distance_to_stop_loss_percent": round(abs(distance_to_sl_percent), 2)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get quick status: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.schemas.schemas import PositionMetrics
|
||||
from app.services.ai_context_builder import ai_context_builder
|
||||
from app.services.price_anchor import price_anchor_service
|
||||
|
||||
router = APIRouter(prefix="/positions", tags=["Positions"])
|
||||
|
||||
|
||||
@router.get("/metrics", response_model=PositionMetrics)
|
||||
async def get_position_metrics(
|
||||
symbol: str = Query("XAUUSD", description="Symbol, e.g., XAUUSD or BTCUSDT"),
|
||||
timeframe: str = Query("1m", description="Timeframe such as 1m,5m,1h"),
|
||||
limit: int = Query(400, ge=50, le=2000, description="Number of bars to analyze"),
|
||||
) -> PositionMetrics:
|
||||
try:
|
||||
sym = symbol.upper().replace("/", "")
|
||||
ctx = ai_context_builder.build_request(sym, timeframe, limit)
|
||||
metrics = ai_context_builder.build_metrics(sym, timeframe, ctx.price_data)
|
||||
anchor_price = await price_anchor_service.get_anchor_price(sym)
|
||||
return price_anchor_service.apply_anchor(metrics, anchor_price)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to compute position metrics: {exc}")
|
||||
@@ -0,0 +1,495 @@
|
||||
"""
|
||||
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)}",
|
||||
)
|
||||
@@ -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)}")
|
||||
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Trading Schools API
|
||||
Endpoints for accessing trading methodologies, strategies, and plan templates
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.trading_schools import trading_schools, TradingSchool
|
||||
from app.services.plan_templates import plan_templates, PlanType, MarketCondition
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/trading-schools", tags=["Trading Schools"])
|
||||
|
||||
|
||||
# Pydantic Models
|
||||
class TradingSchoolInfo(BaseModel):
|
||||
"""Trading school information"""
|
||||
school: str
|
||||
name: str
|
||||
description: str
|
||||
key_concepts: List[str]
|
||||
timeframes: List[str]
|
||||
indicators: List[str]
|
||||
best_for: List[str]
|
||||
|
||||
|
||||
class GeneratePlanRequest(BaseModel):
|
||||
"""Request to generate a trading plan"""
|
||||
methodology: str # ict_smc, wyckoff, multi_confluence, etc.
|
||||
current_price: float
|
||||
market_condition: Optional[str] = "trending_up"
|
||||
session: Optional[str] = "london_ny"
|
||||
risk_tolerance: Optional[str] = "moderate"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TRADING SCHOOLS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/list")
|
||||
async def get_all_trading_schools():
|
||||
"""Get list of all available trading schools and methodologies"""
|
||||
schools = trading_schools.get_all_schools()
|
||||
|
||||
return {
|
||||
"total_schools": len(schools),
|
||||
"schools": list(schools.keys()),
|
||||
"schools_detail": schools,
|
||||
"description": "Comprehensive collection of trading methodologies"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/school/{school_name}")
|
||||
async def get_school_details(school_name: str):
|
||||
"""Get detailed information about a specific trading school"""
|
||||
schools = trading_schools.get_all_schools()
|
||||
|
||||
if school_name not in schools:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"School '{school_name}' not found. Available schools: {list(schools.keys())}"
|
||||
)
|
||||
|
||||
return schools[school_name]
|
||||
|
||||
|
||||
@router.get("/combined-strategies")
|
||||
async def get_combined_strategies():
|
||||
"""Get hybrid strategies combining multiple trading schools"""
|
||||
strategies = trading_schools.get_combined_strategies()
|
||||
|
||||
return {
|
||||
"total_strategies": len(strategies),
|
||||
"strategies": strategies,
|
||||
"description": "Hybrid approaches combining multiple methodologies for higher probability setups"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/indicator-presets")
|
||||
async def get_indicator_presets(school: Optional[str] = Query(None)):
|
||||
"""Get recommended indicator configurations for trading schools"""
|
||||
if school:
|
||||
preset = trading_schools.get_indicator_presets_for_school(TradingSchool(school))
|
||||
return {
|
||||
"school": school,
|
||||
"preset": preset
|
||||
}
|
||||
|
||||
# Get all presets
|
||||
all_presets = {}
|
||||
for s in TradingSchool:
|
||||
all_presets[s.value] = trading_schools.get_indicator_presets_for_school(s)
|
||||
|
||||
return {
|
||||
"total_schools": len(all_presets),
|
||||
"presets": all_presets
|
||||
}
|
||||
|
||||
|
||||
@router.get("/risk-models")
|
||||
async def get_risk_management_models():
|
||||
"""Get advanced risk management models and position sizing strategies"""
|
||||
models = trading_schools.get_risk_models()
|
||||
|
||||
return {
|
||||
"total_models": len(models),
|
||||
"models": models,
|
||||
"recommendation": "Use Fixed Fractional (1-2% per trade) for beginners, Kelly Criterion for advanced traders with proven edge"
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TRADING PLAN TEMPLATES ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/plan-types")
|
||||
async def get_plan_types():
|
||||
"""Get all available trading plan types"""
|
||||
types = plan_templates.get_all_plan_types()
|
||||
|
||||
return {
|
||||
"total_types": len(types),
|
||||
"plan_types": types,
|
||||
"description": "Pre-built trading plan templates for different methodologies"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate-plan")
|
||||
async def generate_trading_plan(request: GeneratePlanRequest):
|
||||
"""
|
||||
Generate a comprehensive trading plan based on selected methodology
|
||||
|
||||
Methodologies:
|
||||
- ict_smc: ICT / Smart Money Concepts
|
||||
- wyckoff: Wyckoff Method
|
||||
- multi_confluence: Multi-Method Confluence (ICT + Fib + S/D + PA)
|
||||
- session_trading: London/NY Session-Based Trading
|
||||
"""
|
||||
try:
|
||||
# Validate market condition
|
||||
try:
|
||||
market_cond = MarketCondition(request.market_condition)
|
||||
except ValueError:
|
||||
market_cond = MarketCondition.TRENDING_UP
|
||||
|
||||
# Generate plan based on methodology
|
||||
if request.methodology == "ict_smc":
|
||||
plan = plan_templates.generate_ict_smc_plan(
|
||||
current_price=request.current_price,
|
||||
market_condition=market_cond,
|
||||
session=request.session or "london_ny"
|
||||
)
|
||||
elif request.methodology == "wyckoff":
|
||||
plan = plan_templates.generate_wyckoff_plan(
|
||||
current_price=request.current_price,
|
||||
market_condition=market_cond
|
||||
)
|
||||
elif request.methodology == "multi_confluence":
|
||||
plan = plan_templates.generate_multi_method_confluence_plan(
|
||||
current_price=request.current_price,
|
||||
market_condition=market_cond
|
||||
)
|
||||
elif request.methodology == "session_trading":
|
||||
plan = plan_templates.generate_session_based_plan(
|
||||
current_price=request.current_price,
|
||||
target_session=request.session or "london_ny_overlap"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown methodology: {request.methodology}. Use: ict_smc, wyckoff, multi_confluence, or session_trading"
|
||||
)
|
||||
|
||||
return {
|
||||
"methodology": request.methodology,
|
||||
"current_price": request.current_price,
|
||||
"market_condition": request.market_condition,
|
||||
"plan": plan,
|
||||
"generated_at": "now"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/quick-reference/{school}")
|
||||
async def get_quick_reference(school: str):
|
||||
"""Get a quick reference guide for a specific trading school"""
|
||||
schools = trading_schools.get_all_schools()
|
||||
|
||||
if school not in schools:
|
||||
raise HTTPException(status_code=404, detail=f"School '{school}' not found")
|
||||
|
||||
school_data = schools[school]
|
||||
|
||||
# Create quick reference
|
||||
quick_ref = {
|
||||
"name": school_data["name"],
|
||||
"school_type": school_data["school"],
|
||||
"elevator_pitch": school_data["description"],
|
||||
"key_concepts": school_data["key_concepts"][:5], # Top 5
|
||||
"timeframes": school_data["timeframes"],
|
||||
"best_for": school_data["best_for"],
|
||||
"one_sentence_summary": _get_one_liner(school)
|
||||
}
|
||||
|
||||
if "entry_criteria" in school_data:
|
||||
quick_ref["how_to_trade"] = school_data["entry_criteria"]
|
||||
|
||||
if "risk_management" in school_data:
|
||||
quick_ref["risk_management"] = school_data["risk_management"]
|
||||
|
||||
return quick_ref
|
||||
|
||||
|
||||
@router.get("/comparison")
|
||||
async def compare_trading_schools(
|
||||
schools_list: str = Query(..., description="Comma-separated list of schools to compare, e.g., ict_smc,wyckoff,price_action")
|
||||
):
|
||||
"""Compare multiple trading schools side by side"""
|
||||
school_names = [s.strip() for s in schools_list.split(",")]
|
||||
schools_data = trading_schools.get_all_schools()
|
||||
|
||||
comparison = {}
|
||||
for school_name in school_names:
|
||||
if school_name not in schools_data:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"School '{school_name}' not found"
|
||||
)
|
||||
|
||||
data = schools_data[school_name]
|
||||
comparison[school_name] = {
|
||||
"name": data["name"],
|
||||
"description": data["description"],
|
||||
"timeframes": data["timeframes"],
|
||||
"indicators": data["indicators"],
|
||||
"best_for": data["best_for"],
|
||||
"complexity": _rate_complexity(school_name)
|
||||
}
|
||||
|
||||
return {
|
||||
"schools_compared": len(comparison),
|
||||
"comparison": comparison,
|
||||
"recommendation": _get_comparison_recommendation(school_names)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/learning-path")
|
||||
async def get_learning_path():
|
||||
"""Get recommended learning path for mastering different trading schools"""
|
||||
return {
|
||||
"beginner_path": {
|
||||
"level": "Beginner (0-6 months)",
|
||||
"schools": [
|
||||
{
|
||||
"order": 1,
|
||||
"school": "price_action",
|
||||
"name": "Price Action",
|
||||
"reason": "Foundation - Learn to read candles and basic S/R",
|
||||
"time_to_learn": "2-3 months"
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"school": "fibonacci_trading",
|
||||
"name": "Fibonacci Trading",
|
||||
"reason": "Simple tool, high applicability",
|
||||
"time_to_learn": "1 month"
|
||||
},
|
||||
{
|
||||
"order": 3,
|
||||
"school": "supply_demand",
|
||||
"name": "Supply & Demand Zones",
|
||||
"reason": "Logical, builds on S/R knowledge",
|
||||
"time_to_learn": "2 months"
|
||||
}
|
||||
],
|
||||
"practice": "Demo trade minimum 3 months before real money"
|
||||
},
|
||||
"intermediate_path": {
|
||||
"level": "Intermediate (6-18 months)",
|
||||
"schools": [
|
||||
{
|
||||
"order": 1,
|
||||
"school": "ict_smc",
|
||||
"name": "ICT / Smart Money Concepts",
|
||||
"reason": "Modern, powerful for gold/forex",
|
||||
"time_to_learn": "4-6 months"
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"school": "market_profile",
|
||||
"name": "Market Profile",
|
||||
"reason": "Understand volume and value",
|
||||
"time_to_learn": "3 months"
|
||||
},
|
||||
{
|
||||
"order": 3,
|
||||
"school": "multi_timeframe",
|
||||
"name": "Multi-Timeframe Analysis",
|
||||
"reason": "Combine skills, improve timing",
|
||||
"time_to_learn": "2 months"
|
||||
}
|
||||
],
|
||||
"practice": "Start combining methods, track statistics"
|
||||
},
|
||||
"advanced_path": {
|
||||
"level": "Advanced (18+ months)",
|
||||
"schools": [
|
||||
{
|
||||
"order": 1,
|
||||
"school": "wyckoff",
|
||||
"name": "Wyckoff Method",
|
||||
"reason": "Deep market understanding, institutional perspective",
|
||||
"time_to_learn": "6-12 months"
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"school": "elliott_wave",
|
||||
"name": "Elliott Wave Theory",
|
||||
"reason": "Complex but powerful for major moves",
|
||||
"time_to_learn": "6-12 months"
|
||||
},
|
||||
{
|
||||
"order": 3,
|
||||
"school": "order_flow",
|
||||
"name": "Order Flow Trading",
|
||||
"reason": "Real-time institutional activity",
|
||||
"time_to_learn": "3-6 months (requires specialized tools)"
|
||||
}
|
||||
],
|
||||
"practice": "Develop personal methodology combining multiple schools"
|
||||
},
|
||||
"professional_edge": {
|
||||
"level": "Professional",
|
||||
"approach": "Multi-Method Confluence",
|
||||
"description": "Combine 3-4 methodologies for maximum probability setups",
|
||||
"schools": ["ict_smc", "fibonacci_trading", "supply_demand", "price_action"],
|
||||
"goal": "Trade only highest-quality setups with 70%+ win rate",
|
||||
"frequency": "1-3 trades per week (quality over quantity)"
|
||||
},
|
||||
"general_advice": [
|
||||
"Master ONE school completely before moving to next",
|
||||
"Journal every trade and study every setup",
|
||||
"Backtest each methodology on historical data",
|
||||
"Paper trade new methods for 2-3 months minimum",
|
||||
"Don't skip fundamentals (Price Action first!)",
|
||||
"Find 1-2 mentors for each major methodology",
|
||||
"Join communities: ICT students, Wyckoff traders, etc.",
|
||||
"Most profitable traders use 2-3 methods maximum (confluence)"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
def _get_one_liner(school: str) -> str:
|
||||
"""Get one-sentence summary of a trading school"""
|
||||
summaries = {
|
||||
"ict_smc": "Trade like institutions: Follow liquidity, FVGs, and order blocks during killzones.",
|
||||
"wyckoff": "Identify accumulation and distribution phases using volume to trade with smart money.",
|
||||
"elliott_wave": "Count wave structures and use Fibonacci to predict major market moves.",
|
||||
"market_profile": "Find value areas and trade price rejection from high/low volume nodes.",
|
||||
"order_flow": "Read real-time buying/selling pressure to anticipate institutional moves.",
|
||||
"price_action": "Trade pure price patterns at support/resistance without indicators.",
|
||||
"supply_demand": "Identify fresh zones of imbalance and trade rejections from these levels.",
|
||||
"fibonacci_trading": "Use golden ratio levels (0.618, 1.618) for entries and targets.",
|
||||
"gold_fundamental": "Trade gold based on USD strength, yields, inflation, and geopolitical factors.",
|
||||
"multi_timeframe": "Align multiple timeframes for high-probability entries with HTF targets.",
|
||||
"london_ny_session": "Trade gold during high-liquidity sessions (3-5 AM, 8-11 AM EST) for best moves."
|
||||
}
|
||||
return summaries.get(school, "A proven trading methodology.")
|
||||
|
||||
|
||||
def _rate_complexity(school: str) -> str:
|
||||
"""Rate the complexity of learning a trading school"""
|
||||
ratings = {
|
||||
"price_action": "Beginner",
|
||||
"fibonacci_trading": "Beginner",
|
||||
"supply_demand": "Beginner-Intermediate",
|
||||
"multi_timeframe": "Intermediate",
|
||||
"ict_smc": "Intermediate",
|
||||
"market_profile": "Intermediate-Advanced",
|
||||
"gold_fundamental": "Intermediate",
|
||||
"london_ny_session": "Intermediate",
|
||||
"wyckoff": "Advanced",
|
||||
"elliott_wave": "Advanced",
|
||||
"order_flow": "Advanced"
|
||||
}
|
||||
return ratings.get(school, "Intermediate")
|
||||
|
||||
|
||||
def _get_comparison_recommendation(schools: List[str]) -> str:
|
||||
"""Get recommendation based on schools being compared"""
|
||||
if len(schools) == 1:
|
||||
return f"Focus on mastering {schools[0]} before adding other methods."
|
||||
|
||||
if "ict_smc" in schools and "fibonacci_trading" in schools and "supply_demand" in schools:
|
||||
return "Excellent combination! These three methods work very well together for confluence trading."
|
||||
|
||||
if "wyckoff" in schools and any(s in schools for s in ["market_profile", "order_flow"]):
|
||||
return "Volume-based methods pair well. Focus on volume analysis across all methods."
|
||||
|
||||
if len(schools) > 4:
|
||||
return "⚠️ Too many methods. Focus on mastering 2-3 maximum to avoid analysis paralysis."
|
||||
|
||||
return "Good selection. Look for confluence zones where multiple methods confirm the same setup."
|
||||
Reference in New Issue
Block a user