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."
|
||||
@@ -0,0 +1,406 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from statistics import mean
|
||||
from typing import List, Dict, Optional, Iterable
|
||||
|
||||
from app.schemas.schemas import AIAnalysisRequest, PriceData, PositionMetrics
|
||||
from app.streaming.live_store import live_store
|
||||
from app.services.candlestick_patterns import candlestick_detector
|
||||
|
||||
|
||||
BB_LENGTH = 20
|
||||
BB_MULT = 2.0
|
||||
RSI_FAST_LENGTH = 3
|
||||
ZLSMA_LENGTH = 50
|
||||
CHAND_LENGTH = 22
|
||||
CHAND_MULT = 2.0
|
||||
MAX_PATTERN_SIGNALS = 10
|
||||
|
||||
|
||||
class AIContextBuilder:
|
||||
"""Builds enriched AIAnalysisRequest payloads from live store data."""
|
||||
|
||||
def __init__(self, default_symbol: str = "XAUUSD", default_timeframe: str = "1m") -> None:
|
||||
self.default_symbol = default_symbol
|
||||
self.default_timeframe = default_timeframe
|
||||
|
||||
def build_request(self, symbol: Optional[str] = None, timeframe: Optional[str] = None, limit: int = 400) -> AIAnalysisRequest:
|
||||
sym = (symbol or self.default_symbol).upper().replace("/", "")
|
||||
tf = timeframe or self.default_timeframe
|
||||
bars = self._load_bars(sym, tf, limit)
|
||||
if not bars:
|
||||
raise ValueError(f"No live data available for {sym} {tf}")
|
||||
|
||||
trimmed = bars[-limit:]
|
||||
price_data = [
|
||||
PriceData(
|
||||
time=int(row["time"]),
|
||||
open=float(row["open"]),
|
||||
high=float(row["high"]),
|
||||
low=float(row["low"]),
|
||||
close=float(row["close"]),
|
||||
volume=float(row.get("volume", 0.0)),
|
||||
)
|
||||
for row in trimmed
|
||||
]
|
||||
|
||||
indicators = self._build_indicators(trimmed)
|
||||
current_price = price_data[-1].close if price_data else float(trimmed[-1]["close"])
|
||||
|
||||
return AIAnalysisRequest(
|
||||
price_data=price_data,
|
||||
indicators=indicators,
|
||||
current_price=current_price,
|
||||
symbol=self._format_symbol(sym),
|
||||
timeframe=tf,
|
||||
)
|
||||
|
||||
def build_metrics(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
price_data: Iterable[PriceData] | Iterable[Dict[str, float]]
|
||||
) -> PositionMetrics:
|
||||
rows = list(price_data)
|
||||
if not rows:
|
||||
raise ValueError("No price data available for metrics")
|
||||
|
||||
def _get(row, key: str) -> float:
|
||||
if hasattr(row, key):
|
||||
return float(getattr(row, key))
|
||||
return float(row[key])
|
||||
|
||||
closes = [float(_get(row, "close")) for row in rows]
|
||||
highs = [float(_get(row, "high")) for row in rows]
|
||||
lows = [float(_get(row, "low")) for row in rows]
|
||||
|
||||
last = rows[-1]
|
||||
prev = rows[-2] if len(rows) > 1 else None
|
||||
|
||||
change = None
|
||||
change_pct = None
|
||||
if prev is not None:
|
||||
prev_close = _get(prev, "close")
|
||||
last_close = _get(last, "close")
|
||||
change = last_close - prev_close
|
||||
if prev_close:
|
||||
change_pct = (change / prev_close) * 100
|
||||
|
||||
recent_window = rows[-120:] if len(rows) > 120 else rows
|
||||
support_levels = sorted({round(_get(row, "low"), 2) for row in recent_window})[:4]
|
||||
resistance_levels = sorted({round(_get(row, "high"), 2) for row in recent_window}, reverse=True)[:4]
|
||||
|
||||
atr14 = self._atr(highs, lows, closes, 14)
|
||||
rsi14 = self._rsi(closes, 14)
|
||||
ema21 = self._ema(closes, 21)
|
||||
sma55 = self._sma(closes, 55)
|
||||
sma100 = self._sma(closes, 100)
|
||||
sma200 = self._sma(closes, 200)
|
||||
volatility = self._volatility(closes, 30)
|
||||
momentum = self._momentum(closes, 12)
|
||||
|
||||
current_price = float(_get(last, "close"))
|
||||
previous_close = float(_get(prev, "close")) if prev is not None else None
|
||||
|
||||
rsi_fast = self._rsi(closes, RSI_FAST_LENGTH)
|
||||
rsi_fast_prev = self._rsi(closes[:-1], RSI_FAST_LENGTH) if len(closes) > RSI_FAST_LENGTH + 1 else None
|
||||
bb_basis, bb_upper, bb_lower = self._bollinger_bands(closes, BB_LENGTH, BB_MULT)
|
||||
bb_prev = self._bollinger_bands(closes[:-1], BB_LENGTH, BB_MULT) if len(closes) > BB_LENGTH else (None, None, None)
|
||||
bb_signal = None
|
||||
if (
|
||||
bb_basis is not None
|
||||
and bb_upper is not None
|
||||
and bb_lower is not None
|
||||
and rsi_fast is not None
|
||||
and rsi_fast_prev is not None
|
||||
and bb_prev[0] is not None
|
||||
):
|
||||
close_prev = closes[-2]
|
||||
bb_upper_prev = bb_prev[1]
|
||||
bb_lower_prev = bb_prev[2]
|
||||
if (
|
||||
rsi_fast_prev < 30
|
||||
and close_prev < bb_lower_prev
|
||||
and rsi_fast > 30
|
||||
and closes[-1] > bb_lower
|
||||
and rsi_fast < 50
|
||||
and closes[-1] < bb_basis
|
||||
):
|
||||
bb_signal = "LONG"
|
||||
elif (
|
||||
rsi_fast_prev > 70
|
||||
and close_prev > bb_upper_prev
|
||||
and rsi_fast < 70
|
||||
and closes[-1] < bb_upper
|
||||
and rsi_fast > 50
|
||||
and closes[-1] > bb_basis
|
||||
):
|
||||
bb_signal = "SHORT"
|
||||
|
||||
zlsma = self._zlsma(closes, ZLSMA_LENGTH)
|
||||
chandelier_long_stop, chandelier_short_stop = self._chandelier_exit(
|
||||
highs, lows, closes, CHAND_LENGTH, CHAND_MULT
|
||||
)
|
||||
chandelier_signal = None
|
||||
if chandelier_long_stop is not None or chandelier_short_stop is not None:
|
||||
if chandelier_long_stop is not None and chandelier_short_stop is not None:
|
||||
if current_price > chandelier_short_stop and (zlsma is None or current_price >= zlsma):
|
||||
chandelier_signal = "LONG"
|
||||
elif current_price < chandelier_long_stop and (zlsma is None or current_price <= zlsma):
|
||||
chandelier_signal = "SHORT"
|
||||
else:
|
||||
chandelier_signal = "NEUTRAL"
|
||||
elif chandelier_long_stop is not None:
|
||||
chandelier_signal = "LONG" if current_price > chandelier_long_stop else "SHORT"
|
||||
else:
|
||||
chandelier_signal = "SHORT" if current_price < chandelier_short_stop else "LONG"
|
||||
|
||||
symbol_fmt = self._format_symbol(symbol)
|
||||
timestamp = int(_get(last, "time"))
|
||||
pattern_signals = candlestick_detector.analyze(rows)
|
||||
recent_pattern_signals = pattern_signals[-MAX_PATTERN_SIGNALS:]
|
||||
|
||||
return PositionMetrics(
|
||||
symbol=symbol_fmt,
|
||||
timeframe=timeframe,
|
||||
timestamp=timestamp,
|
||||
current_price=current_price,
|
||||
previous_close=previous_close,
|
||||
change=round(change, 4) if change is not None else None,
|
||||
change_percent=round(change_pct, 4) if change_pct is not None else None,
|
||||
high=round(max(_get(row, "high") for row in recent_window), 4) if recent_window else None,
|
||||
low=round(min(_get(row, "low") for row in recent_window), 4) if recent_window else None,
|
||||
atr14=round(atr14, 4) if atr14 is not None else None,
|
||||
rsi14=round(rsi14, 2) if rsi14 is not None else None,
|
||||
rsi3=round(rsi_fast, 2) if rsi_fast is not None else None,
|
||||
ema21=round(ema21, 4) if ema21 is not None else None,
|
||||
sma55=round(sma55, 4) if sma55 is not None else None,
|
||||
sma100=round(sma100, 4) if sma100 is not None else None,
|
||||
sma200=round(sma200, 4) if sma200 is not None else None,
|
||||
bb_basis=round(bb_basis, 4) if bb_basis is not None else None,
|
||||
bb_upper=round(bb_upper, 4) if bb_upper is not None else None,
|
||||
bb_lower=round(bb_lower, 4) if bb_lower is not None else None,
|
||||
bb_signal=bb_signal,
|
||||
zlsma=round(zlsma, 4) if zlsma is not None else None,
|
||||
chandelier_long_stop=round(chandelier_long_stop, 4) if chandelier_long_stop is not None else None,
|
||||
chandelier_short_stop=round(chandelier_short_stop, 4) if chandelier_short_stop is not None else None,
|
||||
chandelier_signal=chandelier_signal,
|
||||
volatility30=round(volatility * 100, 2) if volatility is not None else None,
|
||||
momentum12=round(momentum, 4) if momentum is not None else None,
|
||||
support_levels=support_levels,
|
||||
resistance_levels=resistance_levels,
|
||||
pattern_signals=recent_pattern_signals,
|
||||
bars_analyzed=len(rows),
|
||||
)
|
||||
|
||||
def _load_bars(self, symbol: str, timeframe: str, limit: int) -> List[Dict[str, float]]:
|
||||
bars = live_store.get_history(symbol, timeframe)
|
||||
if not bars or len(bars) < limit:
|
||||
try:
|
||||
live_store.load_historical_data(symbol, timeframe, days_back=30)
|
||||
bars = live_store.get_history(symbol, timeframe)
|
||||
except Exception:
|
||||
pass
|
||||
return bars[-limit:] if bars else []
|
||||
|
||||
def _build_indicators(self, bars: List[Dict[str, float]]) -> List[Dict[str, float]]:
|
||||
closes = [float(b["close"]) for b in bars]
|
||||
highs = [float(b["high"]) for b in bars]
|
||||
lows = [float(b["low"]) for b in bars]
|
||||
indicators: List[Dict[str, float]] = []
|
||||
|
||||
for window in (8, 21, 55, 100, 200):
|
||||
val = self._sma(closes, window)
|
||||
if val is not None:
|
||||
indicators.append({"name": f"SMA_{window}", "value": round(val, 4)})
|
||||
|
||||
ema21 = self._ema(closes, 21)
|
||||
if ema21 is not None:
|
||||
indicators.append({"name": "EMA_21", "value": round(ema21, 4)})
|
||||
|
||||
rsi14 = self._rsi(closes, 14)
|
||||
if rsi14 is not None:
|
||||
indicators.append({"name": "RSI_14", "value": round(rsi14, 2)})
|
||||
|
||||
atr14 = self._atr(highs, lows, closes, 14)
|
||||
if atr14 is not None:
|
||||
indicators.append({"name": "ATR_14", "value": round(atr14, 4)})
|
||||
|
||||
volatility = self._volatility(closes, 30)
|
||||
if volatility is not None:
|
||||
indicators.append({"name": "VOLATILITY_30", "value": round(volatility * 100, 2), "unit": "%"})
|
||||
|
||||
momentum = self._momentum(closes, 12)
|
||||
if momentum is not None:
|
||||
indicators.append({"name": "MOMENTUM_12", "value": round(momentum, 4)})
|
||||
|
||||
rsi_fast = self._rsi(closes, RSI_FAST_LENGTH)
|
||||
if rsi_fast is not None:
|
||||
indicators.append({"name": f"RSI_{RSI_FAST_LENGTH}", "value": round(rsi_fast, 2)})
|
||||
|
||||
bb_basis, bb_upper, bb_lower = self._bollinger_bands(closes, BB_LENGTH, BB_MULT)
|
||||
if bb_basis is not None:
|
||||
indicators.append({"name": f"BB_{BB_LENGTH}_BASIS", "value": round(bb_basis, 4)})
|
||||
indicators.append({"name": f"BB_{BB_LENGTH}_UPPER", "value": round(bb_upper, 4)})
|
||||
indicators.append({"name": f"BB_{BB_LENGTH}_LOWER", "value": round(bb_lower, 4)})
|
||||
|
||||
zlsma = self._zlsma(closes, ZLSMA_LENGTH)
|
||||
if zlsma is not None:
|
||||
indicators.append({"name": f"ZLSMA_{ZLSMA_LENGTH}", "value": round(zlsma, 4)})
|
||||
|
||||
chandelier_long_stop, chandelier_short_stop = self._chandelier_exit(
|
||||
highs, lows, closes, CHAND_LENGTH, CHAND_MULT
|
||||
)
|
||||
if chandelier_long_stop is not None and chandelier_short_stop is not None:
|
||||
indicators.append({"name": f"CHAND_{CHAND_LENGTH}_LONG", "value": round(chandelier_long_stop, 4)})
|
||||
indicators.append({"name": f"CHAND_{CHAND_LENGTH}_SHORT", "value": round(chandelier_short_stop, 4)})
|
||||
|
||||
return indicators
|
||||
|
||||
@staticmethod
|
||||
def _sma(values: List[float], window: int) -> Optional[float]:
|
||||
if len(values) < window:
|
||||
return None
|
||||
return mean(values[-window:])
|
||||
|
||||
@staticmethod
|
||||
def _ema(values: List[float], window: int) -> Optional[float]:
|
||||
if len(values) < window:
|
||||
return None
|
||||
k = 2 / (window + 1)
|
||||
ema = mean(values[:window])
|
||||
for price in values[window:]:
|
||||
ema = price * k + ema * (1 - k)
|
||||
return ema
|
||||
|
||||
@staticmethod
|
||||
def _rsi(values: List[float], window: int = 14) -> Optional[float]:
|
||||
if len(values) <= window:
|
||||
return None
|
||||
gains = []
|
||||
losses = []
|
||||
for i in range(1, window + 1):
|
||||
change = values[-i] - values[-i - 1]
|
||||
if change >= 0:
|
||||
gains.append(change)
|
||||
else:
|
||||
losses.append(abs(change))
|
||||
avg_gain = mean(gains) if gains else 0
|
||||
avg_loss = mean(losses) if losses else 0
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
rs = avg_gain / avg_loss if avg_loss else 0
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
@staticmethod
|
||||
def _atr(highs: List[float], lows: List[float], closes: List[float], window: int = 14) -> Optional[float]:
|
||||
if len(closes) <= window:
|
||||
return None
|
||||
true_ranges = []
|
||||
for i in range(-window + 1, 0):
|
||||
high = highs[i]
|
||||
low = lows[i]
|
||||
prev_close = closes[i - 1]
|
||||
tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
|
||||
true_ranges.append(tr)
|
||||
return mean(true_ranges) if true_ranges else None
|
||||
|
||||
@staticmethod
|
||||
def _volatility(values: List[float], window: int) -> Optional[float]:
|
||||
if len(values) < window:
|
||||
return None
|
||||
subset = values[-window:]
|
||||
avg = mean(subset)
|
||||
variance = mean([(p - avg) ** 2 for p in subset])
|
||||
return (variance ** 0.5) / avg if avg else None
|
||||
|
||||
@staticmethod
|
||||
def _momentum(values: List[float], lookback: int = 12) -> Optional[float]:
|
||||
if len(values) <= lookback:
|
||||
return None
|
||||
return values[-1] - values[-lookback - 1]
|
||||
|
||||
@staticmethod
|
||||
def _bollinger_bands(values: List[float], length: int, multiplier: float) -> tuple[Optional[float], Optional[float], Optional[float]]:
|
||||
if len(values) < length:
|
||||
return (None, None, None)
|
||||
window = values[-length:]
|
||||
basis = mean(window)
|
||||
variance = mean([(price - basis) ** 2 for price in window])
|
||||
deviation = variance ** 0.5
|
||||
upper = basis + multiplier * deviation
|
||||
lower = basis - multiplier * deviation
|
||||
return (basis, upper, lower)
|
||||
|
||||
@staticmethod
|
||||
def _linreg(values: List[float], length: int) -> Optional[float]:
|
||||
if len(values) < length:
|
||||
return None
|
||||
window = values[-length:]
|
||||
x = list(range(length))
|
||||
sum_x = sum(x)
|
||||
sum_y = sum(window)
|
||||
sum_x2 = sum(i * i for i in x)
|
||||
sum_xy = sum(i * y for i, y in zip(x, window))
|
||||
denominator = length * sum_x2 - sum_x ** 2
|
||||
if denominator == 0:
|
||||
return window[-1]
|
||||
slope = (length * sum_xy - sum_x * sum_y) / denominator
|
||||
intercept = (sum_y - slope * sum_x) / length
|
||||
return intercept + slope * (length - 1)
|
||||
|
||||
@classmethod
|
||||
def _zlsma(cls, values: List[float], length: int) -> Optional[float]:
|
||||
if len(values) < length:
|
||||
return None
|
||||
|
||||
lsma_series: List[float] = []
|
||||
for idx in range(length, len(values) + 1):
|
||||
segment = values[idx - length : idx]
|
||||
lsma_val = cls._linreg(segment, length)
|
||||
if lsma_val is not None:
|
||||
lsma_series.append(lsma_val)
|
||||
|
||||
if not lsma_series:
|
||||
return None
|
||||
|
||||
lsma_last = lsma_series[-1]
|
||||
if len(lsma_series) < length:
|
||||
return lsma_last
|
||||
|
||||
lsma2_series: List[float] = []
|
||||
for idx in range(length, len(lsma_series) + 1):
|
||||
segment = lsma_series[idx - length : idx]
|
||||
lsma2_val = cls._linreg(segment, length)
|
||||
if lsma2_val is not None:
|
||||
lsma2_series.append(lsma2_val)
|
||||
|
||||
if not lsma2_series:
|
||||
return lsma_last
|
||||
|
||||
lsma2_last = lsma2_series[-1]
|
||||
return lsma_last + (lsma_last - lsma2_last)
|
||||
|
||||
@classmethod
|
||||
def _chandelier_exit(
|
||||
cls, highs: List[float], lows: List[float], closes: List[float], length: int, multiplier: float
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
if len(closes) <= length:
|
||||
return (None, None)
|
||||
|
||||
recent_high = max(highs[-length:])
|
||||
recent_low = min(lows[-length:])
|
||||
atr = cls._atr(highs, lows, closes, length)
|
||||
if atr is None:
|
||||
return (None, None)
|
||||
|
||||
long_stop = recent_high - multiplier * atr
|
||||
short_stop = recent_low + multiplier * atr
|
||||
return (long_stop, short_stop)
|
||||
|
||||
@staticmethod
|
||||
def _format_symbol(symbol: str) -> str:
|
||||
if len(symbol) == 6 and symbol.isalpha():
|
||||
return f"{symbol[:3]}/{symbol[3:]}"
|
||||
return symbol
|
||||
ai_context_builder = AIContextBuilder()
|
||||
@@ -0,0 +1,473 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
try: # Optional dependency for MetaTrader5
|
||||
import MetaTrader5 # type: ignore
|
||||
except ImportError: # pragma: no cover - optional
|
||||
MetaTrader5 = None # type: ignore
|
||||
|
||||
|
||||
class BrokerError(RuntimeError):
|
||||
"""Raised when bridge operations fail."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerProvider:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
docs_url: str
|
||||
latency_ms: int
|
||||
features: Dict[str, bool]
|
||||
supports_demo: bool = True
|
||||
|
||||
|
||||
BROKER_PROVIDERS: List[BrokerProvider] = [
|
||||
BrokerProvider(
|
||||
id="mt5",
|
||||
name="MetaTrader 5",
|
||||
description="Direct bridge to a locally running MetaTrader 5 terminal.",
|
||||
docs_url="https://www.metatrader5.com/en/terminal/help",
|
||||
latency_ms=180,
|
||||
features={
|
||||
"trailingStops": True,
|
||||
"partialCloses": True,
|
||||
"hedging": True,
|
||||
"streaming": True,
|
||||
},
|
||||
),
|
||||
BrokerProvider(
|
||||
id="oanda",
|
||||
name="OANDA v20",
|
||||
description="REST trading for FX/CFD (practice or live)",
|
||||
docs_url="https://developer.oanda.com/rest-live-v20/",
|
||||
latency_ms=230,
|
||||
features={
|
||||
"trailingStops": True,
|
||||
"partialCloses": True,
|
||||
"hedging": False,
|
||||
"streaming": False,
|
||||
},
|
||||
),
|
||||
BrokerProvider(
|
||||
id="alpaca",
|
||||
name="Alpaca Trading",
|
||||
description="Equities/crypto order routing (paper or live)",
|
||||
docs_url="https://alpaca.markets/docs/api-references/trading-api/",
|
||||
latency_ms=120,
|
||||
features={
|
||||
"trailingStops": False,
|
||||
"partialCloses": True,
|
||||
"hedging": False,
|
||||
"streaming": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
PROVIDER_LOOKUP = {provider.id: provider for provider in BROKER_PROVIDERS}
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _demo_state(balance: Optional[float] = None) -> Dict[str, Any]:
|
||||
return {
|
||||
"mode": "demo",
|
||||
"token": f"demo-{uuid.uuid4()}",
|
||||
"balance": balance if balance is not None else settings.BROKER_SIM_BALANCE,
|
||||
"positions": [],
|
||||
}
|
||||
|
||||
|
||||
class BaseConnector(abc.ABC):
|
||||
provider_id: str
|
||||
|
||||
@abc.abstractmethod
|
||||
async def connect(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def disconnect(self, state: Dict[str, Any]) -> None:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def place_order(self, state: Dict[str, Any], order: Dict[str, Any]) -> Dict[str, Any]:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def sync_positions(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
...
|
||||
|
||||
|
||||
class MetaTraderConnector(BaseConnector):
|
||||
provider_id = "mt5"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def _client(self):
|
||||
if MetaTrader5 is None:
|
||||
raise BrokerError("MetaTrader5 python package is not installed")
|
||||
return MetaTrader5
|
||||
|
||||
async def connect(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if credentials.get("demo", True):
|
||||
return _demo_state()
|
||||
|
||||
mt5 = self._client()
|
||||
login = int(credentials["account_id"])
|
||||
password = credentials["api_key"]
|
||||
server = credentials.get("server") or settings.MT5_SERVER
|
||||
|
||||
async with self._lock:
|
||||
def _login():
|
||||
if not mt5.initialize():
|
||||
raise BrokerError(f"MetaTrader5 initialize failed: {mt5.last_error()}")
|
||||
if not mt5.login(login=login, password=password, server=server):
|
||||
raise BrokerError(f"MetaTrader5 login failed: {mt5.last_error()}")
|
||||
info = mt5.account_info()
|
||||
balance = float(info.balance) if info else None
|
||||
return {
|
||||
"mode": "live",
|
||||
"balance": balance,
|
||||
"token": f"mt5-{uuid.uuid4()}",
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_login)
|
||||
|
||||
async def disconnect(self, state: Dict[str, Any]) -> None:
|
||||
if state.get("mode") == "demo":
|
||||
return
|
||||
mt5 = self._client()
|
||||
|
||||
async with self._lock:
|
||||
def _shutdown():
|
||||
mt5.shutdown()
|
||||
|
||||
await asyncio.to_thread(_shutdown)
|
||||
|
||||
async def place_order(self, state: Dict[str, Any], order: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if state.get("mode") == "demo":
|
||||
return {
|
||||
"remote_id": f"demo-{order['action']}-{uuid.uuid4().hex[:6]}",
|
||||
"filled": True,
|
||||
}
|
||||
|
||||
mt5 = self._client()
|
||||
|
||||
def _send():
|
||||
request = {
|
||||
"action": mt5.TRADE_ACTION_DEAL,
|
||||
"symbol": order["symbol"],
|
||||
"type": mt5.ORDER_TYPE_BUY if order["action"] == "BUY" else mt5.ORDER_TYPE_SELL,
|
||||
"volume": float(order["quantity"]),
|
||||
"price": float(order["price"]),
|
||||
"type_filling": mt5.ORDER_FILLING_RETURN,
|
||||
"sl": order.get("stopLoss"),
|
||||
"tp": order.get("takeProfit"),
|
||||
}
|
||||
result = mt5.order_send(request)
|
||||
if result is None or result.retcode != mt5.TRADE_RETCODE_DONE:
|
||||
raise BrokerError(f"MetaTrader5 order failed: {mt5.last_error()}")
|
||||
return {
|
||||
"remote_id": str(result.order),
|
||||
"filled": True,
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_send)
|
||||
|
||||
async def sync_positions(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if state.get("mode") == "demo":
|
||||
return {"positions": state.setdefault("positions", []), "balance": state.get("balance")}
|
||||
|
||||
mt5 = self._client()
|
||||
|
||||
def _fetch():
|
||||
info = mt5.account_info()
|
||||
balance = float(info.balance) if info else None
|
||||
rows = mt5.positions_get()
|
||||
positions: List[Dict[str, Any]] = []
|
||||
if rows:
|
||||
for row in rows:
|
||||
positions.append(
|
||||
{
|
||||
"symbol": row.symbol,
|
||||
"quantity": float(row.volume),
|
||||
"avgPrice": float(row.price_open),
|
||||
"lastPrice": float(row.price_current),
|
||||
"pnl": float(row.profit),
|
||||
"ticket": int(row.ticket),
|
||||
}
|
||||
)
|
||||
return {"positions": positions, "balance": balance}
|
||||
|
||||
return await asyncio.to_thread(_fetch)
|
||||
|
||||
|
||||
class OandaConnector(BaseConnector):
|
||||
provider_id = "oanda"
|
||||
|
||||
async def connect(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if credentials.get("demo", True) or not credentials.get("api_key"):
|
||||
return _demo_state()
|
||||
|
||||
account_id = credentials["account_id"]
|
||||
headers = {
|
||||
"Authorization": f"Bearer {credentials['api_key']}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
base_url = settings.OANDA_BASE_URL.rstrip("/")
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=settings.BROKER_HTTP_TIMEOUT) as client:
|
||||
resp = await client.get(f"/v3/accounts/{account_id}", headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("account", {})
|
||||
balance = float(data.get("balance", 0))
|
||||
return {
|
||||
"mode": "live",
|
||||
"headers": headers,
|
||||
"account_id": account_id,
|
||||
"base_url": base_url,
|
||||
"balance": balance,
|
||||
"token": f"oanda-{uuid.uuid4()}",
|
||||
}
|
||||
|
||||
async def disconnect(self, state: Dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
async def place_order(self, state: Dict[str, Any], order: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if state.get("mode") == "demo":
|
||||
return {
|
||||
"remote_id": f"demo-{order['action']}-{uuid.uuid4().hex[:6]}",
|
||||
"filled": True,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"order": {
|
||||
"instrument": order["symbol"],
|
||||
"units": str(order["quantity"] if order["action"] == "BUY" else -order["quantity"]),
|
||||
"type": order.get("type", "MARKET"),
|
||||
"timeInForce": "FOK",
|
||||
"positionFill": "DEFAULT",
|
||||
}
|
||||
}
|
||||
if order.get("stopLoss"):
|
||||
payload["order"]["stopLossOnFill"] = {"price": str(order["stopLoss"])}
|
||||
if order.get("takeProfit"):
|
||||
payload["order"]["takeProfitOnFill"] = {"price": str(order["takeProfit"])}
|
||||
|
||||
async with httpx.AsyncClient(base_url=state["base_url"], timeout=settings.BROKER_HTTP_TIMEOUT) as client:
|
||||
resp = await client.post(
|
||||
f"/v3/accounts/{state['account_id']}/orders",
|
||||
headers=state["headers"],
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return {
|
||||
"remote_id": data.get("orderFillTransaction", {}).get("orderID") or uuid.uuid4().hex,
|
||||
"filled": True,
|
||||
}
|
||||
|
||||
async def sync_positions(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if state.get("mode") == "demo":
|
||||
return {"positions": state.setdefault("positions", []), "balance": state.get("balance")}
|
||||
|
||||
async with httpx.AsyncClient(base_url=state["base_url"], timeout=settings.BROKER_HTTP_TIMEOUT) as client:
|
||||
resp = await client.get(
|
||||
f"/v3/accounts/{state['account_id']}/openPositions",
|
||||
headers=state["headers"],
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
|
||||
positions: List[Dict[str, Any]] = []
|
||||
for item in payload.get("positions", []):
|
||||
net = float(item.get("net", {}).get("units", 0))
|
||||
if net == 0:
|
||||
continue
|
||||
avg_price = float(item.get("net", {}).get("averagePrice", 0))
|
||||
positions.append(
|
||||
{
|
||||
"symbol": item.get("instrument"),
|
||||
"quantity": abs(net),
|
||||
"avgPrice": avg_price,
|
||||
"lastPrice": None,
|
||||
"pnl": None,
|
||||
}
|
||||
)
|
||||
|
||||
return {"positions": positions, "balance": state.get("balance")}
|
||||
|
||||
|
||||
class AlpacaConnector(BaseConnector):
|
||||
provider_id = "alpaca"
|
||||
|
||||
async def connect(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if credentials.get("demo", True) or not credentials.get("api_key"):
|
||||
return _demo_state()
|
||||
|
||||
key_parts = credentials["api_key"].split(":", 1)
|
||||
if len(key_parts) != 2:
|
||||
raise BrokerError("Provide API_KEY:API_SECRET for Alpaca API key field")
|
||||
|
||||
headers = {
|
||||
"APCA-API-KEY-ID": key_parts[0],
|
||||
"APCA-API-SECRET-KEY": key_parts[1],
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
base_url = settings.ALPACA_BASE_URL.rstrip("/")
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=settings.BROKER_HTTP_TIMEOUT) as client:
|
||||
resp = await client.get("/account", headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return {
|
||||
"mode": "live",
|
||||
"headers": headers,
|
||||
"base_url": base_url,
|
||||
"account_id": data.get("id") or credentials.get("account_id"),
|
||||
"balance": float(data.get("cash", 0)),
|
||||
"token": f"alpaca-{uuid.uuid4()}",
|
||||
}
|
||||
|
||||
async def disconnect(self, state: Dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
async def place_order(self, state: Dict[str, Any], order: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if state.get("mode") == "demo":
|
||||
return {
|
||||
"remote_id": f"demo-{order['action']}-{uuid.uuid4().hex[:6]}",
|
||||
"filled": True,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"symbol": order["symbol"],
|
||||
"qty": order["quantity"],
|
||||
"side": "buy" if order["action"] == "BUY" else "sell",
|
||||
"type": order.get("type", "market").lower(),
|
||||
"time_in_force": "day",
|
||||
}
|
||||
if order.get("stopLoss") or order.get("takeProfit"):
|
||||
payload["order_class"] = "oto"
|
||||
payload["take_profit"] = {"limit_price": order.get("takeProfit")}
|
||||
payload["stop_loss"] = {"stop_price": order.get("stopLoss")}
|
||||
|
||||
async with httpx.AsyncClient(base_url=state["base_url"], timeout=settings.BROKER_HTTP_TIMEOUT) as client:
|
||||
resp = await client.post("/orders", headers=state["headers"], json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return {
|
||||
"remote_id": data.get("id", uuid.uuid4().hex),
|
||||
"filled": data.get("status") == "filled",
|
||||
}
|
||||
|
||||
async def sync_positions(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if state.get("mode") == "demo":
|
||||
return {"positions": state.setdefault("positions", []), "balance": state.get("balance")}
|
||||
|
||||
async with httpx.AsyncClient(base_url=state["base_url"], timeout=settings.BROKER_HTTP_TIMEOUT) as client:
|
||||
resp = await client.get("/positions", headers=state["headers"])
|
||||
resp.raise_for_status()
|
||||
rows = resp.json()
|
||||
positions = [
|
||||
{
|
||||
"symbol": row.get("symbol"),
|
||||
"quantity": float(row.get("qty", 0)),
|
||||
"avgPrice": float(row.get("avg_entry_price", 0)),
|
||||
"lastPrice": float(row.get("current_price", 0)),
|
||||
"pnl": float(row.get("unrealized_pl", 0)),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return {"positions": positions, "balance": state.get("balance")}
|
||||
|
||||
|
||||
CONNECTORS: Dict[str, BaseConnector] = {
|
||||
"mt5": MetaTraderConnector(),
|
||||
"oanda": OandaConnector(),
|
||||
"alpaca": AlpacaConnector(),
|
||||
}
|
||||
|
||||
|
||||
class BrokerBridgeService:
|
||||
def __init__(self) -> None:
|
||||
self._session: Optional[Dict[str, Any]] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def list_providers(self) -> List[Dict[str, Any]]:
|
||||
return [asdict(provider) for provider in BROKER_PROVIDERS]
|
||||
|
||||
def get_session(self) -> Optional[Dict[str, Any]]:
|
||||
if not self._session:
|
||||
return None
|
||||
provider = PROVIDER_LOOKUP.get(self._session["provider_id"])
|
||||
payload = {**self._session}
|
||||
payload["provider"] = asdict(provider) if provider else None
|
||||
return payload
|
||||
|
||||
async def connect(self, provider_id: str, credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
connector = CONNECTORS.get(provider_id)
|
||||
if not connector:
|
||||
raise BrokerError("Unsupported broker provider")
|
||||
|
||||
state = await connector.connect(credentials)
|
||||
async with self._lock:
|
||||
self._session = {
|
||||
"provider_id": provider_id,
|
||||
"credentials": credentials,
|
||||
"state": state,
|
||||
"account_id": credentials.get("account_id"),
|
||||
"demo": credentials.get("demo", True),
|
||||
"last_heartbeat": _iso_now(),
|
||||
"balance": state.get("balance"),
|
||||
"positions": state.get("positions", []),
|
||||
}
|
||||
return self.get_session() # type: ignore[return-value]
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if not self._session:
|
||||
return
|
||||
connector = CONNECTORS.get(self._session["provider_id"])
|
||||
if connector:
|
||||
await connector.disconnect(self._session.get("state", {}))
|
||||
async with self._lock:
|
||||
self._session = None
|
||||
|
||||
async def place_order(self, order: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not self._session:
|
||||
raise BrokerError("No active broker session")
|
||||
connector = CONNECTORS.get(self._session["provider_id"])
|
||||
if not connector:
|
||||
raise BrokerError("Unsupported broker provider")
|
||||
result = await connector.place_order(self._session.get("state", {}), order)
|
||||
self._session["last_heartbeat"] = _iso_now()
|
||||
return result
|
||||
|
||||
async def sync_positions(self) -> Dict[str, Any]:
|
||||
if not self._session:
|
||||
raise BrokerError("No active broker session")
|
||||
connector = CONNECTORS.get(self._session["provider_id"])
|
||||
if not connector:
|
||||
raise BrokerError("Unsupported broker provider")
|
||||
snapshot = await connector.sync_positions(self._session.get("state", {}))
|
||||
self._session["last_heartbeat"] = _iso_now()
|
||||
self._session["positions"] = snapshot.get("positions", [])
|
||||
self._session["balance"] = snapshot.get("balance", self._session.get("balance"))
|
||||
return {
|
||||
"positions": self._session["positions"],
|
||||
"balance": self._session.get("balance"),
|
||||
"lastHeartbeat": self._session.get("last_heartbeat"),
|
||||
}
|
||||
|
||||
|
||||
broker_bridge_service = BrokerBridgeService()
|
||||
@@ -0,0 +1,387 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, List, Sequence, Tuple, Union
|
||||
|
||||
from app.schemas.schemas import PatternSignal, PriceData
|
||||
|
||||
BarLike = Union[PriceData, dict]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Candle:
|
||||
time: int
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
close: float
|
||||
|
||||
@property
|
||||
def hl2(self) -> float:
|
||||
return (self.high + self.low) / 2
|
||||
|
||||
|
||||
class CandlestickPatternDetector:
|
||||
"""Translated subset of the TradingView *All Candlestick Patterns* study.
|
||||
|
||||
The detector focuses on high-signal patterns that are most useful for
|
||||
risk automation and narrative building. The implementation is intentionally
|
||||
modular so additional patterns from the Pine script can be ported quickly.
|
||||
"""
|
||||
|
||||
BODY_AVG_EMA = 14
|
||||
SHADOW_PERCENT = 5.0
|
||||
SHADOW_EQUALS_PERCENT = 100.0
|
||||
DOJI_BODY_PERCENT = 5.0
|
||||
LONG_LOWER_PERCENT = 75.0
|
||||
LONG_UPPER_PERCENT = 75.0
|
||||
HAMMER_FACTOR = 2.0
|
||||
TREND_SMA = 50
|
||||
TREND_SMA_LONG = 200
|
||||
|
||||
def analyze(self, rows: Iterable[BarLike]) -> List[PatternSignal]:
|
||||
candles = self._normalize(rows)
|
||||
if len(candles) < 3:
|
||||
return []
|
||||
|
||||
opens = [c.open for c in candles]
|
||||
highs = [c.high for c in candles]
|
||||
lows = [c.low for c in candles]
|
||||
closes = [c.close for c in candles]
|
||||
times = [c.time for c in candles]
|
||||
|
||||
body_hi = [max(o, c) for o, c in zip(opens, closes)]
|
||||
body_lo = [min(o, c) for o, c in zip(opens, closes)]
|
||||
bodies = [hi - lo for hi, lo in zip(body_hi, body_lo)]
|
||||
ranges = [h - l for h, l in zip(highs, lows)]
|
||||
upper_shadows = [h - hi for h, hi in zip(highs, body_hi)]
|
||||
lower_shadows = [lo - l for lo, l in zip(body_lo, lows)]
|
||||
body_avg = self._ema_series(bodies, self.BODY_AVG_EMA)
|
||||
sma50 = self._sma_series(closes, self.TREND_SMA)
|
||||
sma200 = self._sma_series(closes, self.TREND_SMA_LONG)
|
||||
|
||||
up_trend = [False] * len(candles)
|
||||
down_trend = [False] * len(candles)
|
||||
for idx in range(len(candles)):
|
||||
if sma50[idx] is None:
|
||||
if idx > 0:
|
||||
up_trend[idx] = closes[idx] > closes[idx - 1]
|
||||
down_trend[idx] = closes[idx] < closes[idx - 1]
|
||||
continue
|
||||
close = closes[idx]
|
||||
s50 = sma50[idx]
|
||||
s200 = sma200[idx]
|
||||
up = close > s50
|
||||
down = close < s50
|
||||
if s200 is not None:
|
||||
up = up and s50 > s200
|
||||
down = down and s50 < s200
|
||||
up_trend[idx] = up
|
||||
down_trend[idx] = down
|
||||
|
||||
pattern_signals: List[PatternSignal] = []
|
||||
|
||||
for i in range(len(candles)):
|
||||
detected = self._detect_at(
|
||||
i,
|
||||
candles,
|
||||
body_hi,
|
||||
body_lo,
|
||||
bodies,
|
||||
body_avg,
|
||||
ranges,
|
||||
upper_shadows,
|
||||
lower_shadows,
|
||||
up_trend,
|
||||
down_trend,
|
||||
)
|
||||
for pattern, classification in detected:
|
||||
pattern_signals.append(
|
||||
PatternSignal(
|
||||
pattern=pattern,
|
||||
classification=classification,
|
||||
price=closes[i],
|
||||
time=times[i],
|
||||
)
|
||||
)
|
||||
|
||||
return pattern_signals
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detection helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _detect_at(
|
||||
self,
|
||||
i: int,
|
||||
candles: Sequence[Candle],
|
||||
body_hi: Sequence[float],
|
||||
body_lo: Sequence[float],
|
||||
bodies: Sequence[float],
|
||||
body_avg: Sequence[float | None],
|
||||
ranges: Sequence[float],
|
||||
upper_shadows: Sequence[float],
|
||||
lower_shadows: Sequence[float],
|
||||
up_trend: Sequence[bool],
|
||||
down_trend: Sequence[bool],
|
||||
) -> List[Tuple[str, str]]:
|
||||
signals: List[Tuple[str, str]] = []
|
||||
|
||||
if i == 0:
|
||||
return signals
|
||||
|
||||
body = bodies[i]
|
||||
body_average = body_avg[i] or 0.0
|
||||
range_ = ranges[i]
|
||||
upper = upper_shadows[i]
|
||||
lower = lower_shadows[i]
|
||||
is_white = candles[i].close > candles[i].open
|
||||
is_black = candles[i].open > candles[i].close
|
||||
prev_white = candles[i - 1].close > candles[i - 1].open
|
||||
prev_black = candles[i - 1].open > candles[i - 1].close
|
||||
small_body = body_average > 0 and body < body_average
|
||||
long_body = body_average > 0 and body > body_average
|
||||
has_upper_shadow = upper > self.SHADOW_PERCENT / 100 * body if body > 0 else False
|
||||
has_lower_shadow = lower > self.SHADOW_PERCENT / 100 * body if body > 0 else False
|
||||
doji = self._is_doji(body, range_)
|
||||
|
||||
# Single-candle patterns -------------------------------------------------
|
||||
if doji:
|
||||
signals.append(("Doji", "NEUTRAL"))
|
||||
if upper <= body:
|
||||
signals.append(("Dragonfly Doji", "BULLISH"))
|
||||
if lower <= body:
|
||||
signals.append(("Gravestone Doji", "BEARISH"))
|
||||
|
||||
if body > 0:
|
||||
if not has_upper_shadow and lower >= self.HAMMER_FACTOR * body and candles[i].hl2 < body_lo[i] and down_trend[i]:
|
||||
signals.append(("Hammer", "BULLISH"))
|
||||
if not has_upper_shadow and lower >= self.HAMMER_FACTOR * body and candles[i].hl2 < body_lo[i] and up_trend[i]:
|
||||
signals.append(("Hanging Man", "BEARISH"))
|
||||
if not has_lower_shadow and upper >= self.HAMMER_FACTOR * body and candles[i].hl2 > body_hi[i] and down_trend[i]:
|
||||
signals.append(("Inverted Hammer", "BULLISH"))
|
||||
if not has_lower_shadow and upper >= self.HAMMER_FACTOR * body and candles[i].hl2 > body_hi[i] and up_trend[i]:
|
||||
signals.append(("Shooting Star", "BEARISH"))
|
||||
|
||||
if body > 0 and upper <= body * self.SHADOW_PERCENT / 100 and lower <= body * self.SHADOW_PERCENT / 100:
|
||||
if is_white:
|
||||
signals.append(("Marubozu White", "BULLISH"))
|
||||
if is_black:
|
||||
signals.append(("Marubozu Black", "BEARISH"))
|
||||
|
||||
if lower > range_ * self.LONG_LOWER_PERCENT / 100:
|
||||
signals.append(("Long Lower Shadow", "BULLISH"))
|
||||
if upper > range_ * self.LONG_UPPER_PERCENT / 100:
|
||||
signals.append(("Long Upper Shadow", "BEARISH"))
|
||||
|
||||
# Multi-candle patterns --------------------------------------------------
|
||||
signals.extend(
|
||||
self._two_candle_patterns(
|
||||
i,
|
||||
candles,
|
||||
body_hi,
|
||||
body_lo,
|
||||
bodies,
|
||||
body_avg,
|
||||
ranges,
|
||||
up_trend,
|
||||
down_trend,
|
||||
)
|
||||
)
|
||||
signals.extend(
|
||||
self._three_candle_patterns(
|
||||
i,
|
||||
candles,
|
||||
body_hi,
|
||||
body_lo,
|
||||
bodies,
|
||||
body_avg,
|
||||
up_trend,
|
||||
down_trend,
|
||||
)
|
||||
)
|
||||
signals.extend(self._soldiers_and_crows(i, candles, bodies, body_avg))
|
||||
|
||||
return signals
|
||||
|
||||
def _two_candle_patterns(
|
||||
self,
|
||||
i: int,
|
||||
candles: Sequence[Candle],
|
||||
body_hi: Sequence[float],
|
||||
body_lo: Sequence[float],
|
||||
bodies: Sequence[float],
|
||||
body_avg: Sequence[float | None],
|
||||
ranges: Sequence[float],
|
||||
up_trend: Sequence[bool],
|
||||
down_trend: Sequence[bool],
|
||||
) -> List[Tuple[str, str]]:
|
||||
if i < 1:
|
||||
return []
|
||||
signals: List[Tuple[str, str]] = []
|
||||
body = bodies[i]
|
||||
body_prev = bodies[i - 1]
|
||||
avg = body_avg[i] or 0.0
|
||||
avg_prev = body_avg[i - 1] or 0.0
|
||||
white = candles[i].close > candles[i].open
|
||||
black = candles[i].open > candles[i].close
|
||||
prev_white = candles[i - 1].close > candles[i - 1].open
|
||||
prev_black = candles[i - 1].open > candles[i - 1].close
|
||||
|
||||
tol = (avg + avg_prev) / 2 * 0.05 if (avg + avg_prev) > 0 else 0.0
|
||||
|
||||
# Tweezer patterns
|
||||
if abs(candles[i].high - candles[i - 1].high) <= tol and prev_white and black and up_trend[i - 1]:
|
||||
signals.append(("Tweezer Top", "BEARISH"))
|
||||
if abs(candles[i].low - candles[i - 1].low) <= tol and prev_black and white and down_trend[i - 1]:
|
||||
signals.append(("Tweezer Bottom", "BULLISH"))
|
||||
|
||||
# Engulfing
|
||||
if down_trend[i - 1] and prev_black and (avg_prev == 0 or body_prev <= avg_prev) and white:
|
||||
if candles[i].close >= candles[i - 1].open and candles[i].open <= candles[i - 1].close:
|
||||
signals.append(("Bullish Engulfing", "BULLISH"))
|
||||
if up_trend[i - 1] and prev_white and (avg_prev == 0 or body_prev <= avg_prev) and black:
|
||||
if candles[i].close <= candles[i - 1].open and candles[i].open >= candles[i - 1].close:
|
||||
signals.append(("Bearish Engulfing", "BEARISH"))
|
||||
|
||||
# Piercing / Dark Cloud Cover
|
||||
mid_prev = (candles[i - 1].open + candles[i - 1].close) / 2
|
||||
if down_trend[i - 1] and prev_black and white:
|
||||
if candles[i].open <= candles[i - 1].low and candles[i].close > mid_prev and candles[i].close < candles[i - 1].open:
|
||||
signals.append(("Piercing", "BULLISH"))
|
||||
if up_trend[i - 1] and prev_white and black:
|
||||
if candles[i].open >= candles[i - 1].high and candles[i].close < mid_prev and candles[i].close > candles[i - 1].open:
|
||||
signals.append(("Dark Cloud Cover", "BEARISH"))
|
||||
|
||||
# Doji Star variants
|
||||
if self._is_doji(body, ranges[i]) and up_trend[i - 1] and prev_white:
|
||||
if candles[i].open > candles[i - 1].high:
|
||||
signals.append(("Doji Star", "BEARISH"))
|
||||
if self._is_doji(body, ranges[i]) and down_trend[i - 1] and prev_black:
|
||||
if candles[i].open < candles[i - 1].low:
|
||||
signals.append(("Doji Star", "BULLISH"))
|
||||
|
||||
return signals
|
||||
|
||||
def _three_candle_patterns(
|
||||
self,
|
||||
i: int,
|
||||
candles: Sequence[Candle],
|
||||
body_hi: Sequence[float],
|
||||
body_lo: Sequence[float],
|
||||
bodies: Sequence[float],
|
||||
body_avg: Sequence[float | None],
|
||||
up_trend: Sequence[bool],
|
||||
down_trend: Sequence[bool],
|
||||
) -> List[Tuple[str, str]]:
|
||||
if i < 2:
|
||||
return []
|
||||
signals: List[Tuple[str, str]] = []
|
||||
|
||||
c0, c1, c2 = candles[i - 2], candles[i - 1], candles[i]
|
||||
body0, body1, body2 = bodies[i - 2], bodies[i - 1], bodies[i]
|
||||
avg0 = body_avg[i - 2] or 0.0
|
||||
avg1 = body_avg[i - 1] or 0.0
|
||||
avg2 = body_avg[i] or 0.0
|
||||
white2 = c2.close > c2.open
|
||||
black2 = c2.open > c2.close
|
||||
small1 = avg1 > 0 and body1 < avg1
|
||||
doji1 = self._is_doji(body1, c1.high - c1.low)
|
||||
|
||||
mid0 = (c0.open + c0.close) / 2
|
||||
|
||||
if down_trend[i - 2] and (c0.open > c0.close) and small1 and white2:
|
||||
if c1.open < c0.low and c2.close >= mid0 and c2.close < c0.high:
|
||||
signals.append(("Morning Star", "BULLISH"))
|
||||
if up_trend[i - 2] and (c0.close > c0.open) and small1 and black2:
|
||||
if c1.open > c0.high and c2.close <= mid0 and c2.close > c0.low:
|
||||
signals.append(("Evening Star", "BEARISH"))
|
||||
|
||||
if down_trend[i - 2] and (c0.open > c0.close) and doji1 and white2:
|
||||
if c1.open < c0.low and c2.close >= mid0 and c2.close < c0.high:
|
||||
signals.append(("Morning Doji Star", "BULLISH"))
|
||||
if up_trend[i - 2] and (c0.close > c0.open) and doji1 and black2:
|
||||
if c1.open > c0.high and c2.close <= mid0 and c2.close > c0.low:
|
||||
signals.append(("Evening Doji Star", "BEARISH"))
|
||||
|
||||
return signals
|
||||
|
||||
def _soldiers_and_crows(
|
||||
self,
|
||||
i: int,
|
||||
candles: Sequence[Candle],
|
||||
bodies: Sequence[float],
|
||||
body_avg: Sequence[float | None],
|
||||
) -> List[Tuple[str, str]]:
|
||||
if i < 2:
|
||||
return []
|
||||
signals: List[Tuple[str, str]] = []
|
||||
c0, c1, c2 = candles[i - 2], candles[i - 1], candles[i]
|
||||
body0, body1, body2 = bodies[i - 2], bodies[i - 1], bodies[i]
|
||||
avg0 = body_avg[i - 2] or 0.0
|
||||
avg1 = body_avg[i - 1] or 0.0
|
||||
avg2 = body_avg[i] or 0.0
|
||||
|
||||
if all(b > a for b, a in zip((body0, body1, body2), (avg0, avg1, avg2))):
|
||||
if c0.close < c0.open and c1.close > c1.open and c2.close > c2.open:
|
||||
if c1.open > c0.close and c2.open > c1.close and c2.close > c1.close > c0.close:
|
||||
signals.append(("Three White Soldiers", "BULLISH"))
|
||||
if c0.close > c0.open and c1.close < c1.open and c2.close < c2.open:
|
||||
if c1.open < c0.close and c2.open < c1.close and c2.close < c1.close < c0.close:
|
||||
signals.append(("Three Black Crows", "BEARISH"))
|
||||
|
||||
return signals
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utility functions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _normalize(self, rows: Iterable[BarLike]) -> List[Candle]:
|
||||
candles: List[Candle] = []
|
||||
for row in rows:
|
||||
if isinstance(row, PriceData):
|
||||
candles.append(Candle(time=row.time, open=row.open, high=row.high, low=row.low, close=row.close))
|
||||
else:
|
||||
candles.append(
|
||||
Candle(
|
||||
time=int(row.get("time", len(candles))),
|
||||
open=float(row["open"]),
|
||||
high=float(row["high"]),
|
||||
low=float(row["low"]),
|
||||
close=float(row["close"]),
|
||||
)
|
||||
)
|
||||
return candles
|
||||
|
||||
def _ema_series(self, values: Sequence[float], length: int) -> List[float | None]:
|
||||
ema_series: List[float | None] = [None] * len(values)
|
||||
if len(values) < length:
|
||||
return ema_series
|
||||
k = 2 / (length + 1)
|
||||
ema = sum(values[:length]) / length
|
||||
ema_series[length - 1] = ema
|
||||
for idx in range(length, len(values)):
|
||||
ema = values[idx] * k + ema * (1 - k)
|
||||
ema_series[idx] = ema
|
||||
return ema_series
|
||||
|
||||
def _sma_series(self, values: Sequence[float], length: int) -> List[float | None]:
|
||||
sma_series: List[float | None] = [None] * len(values)
|
||||
if length <= 0:
|
||||
return sma_series
|
||||
window_sum = 0.0
|
||||
for idx, value in enumerate(values):
|
||||
window_sum += value
|
||||
if idx >= length:
|
||||
window_sum -= values[idx - length]
|
||||
if idx >= length - 1:
|
||||
sma_series[idx] = window_sum / length
|
||||
return sma_series
|
||||
|
||||
def _is_doji(self, body: float, candle_range: float) -> bool:
|
||||
if candle_range <= 0:
|
||||
return False
|
||||
return body <= candle_range * self.DOJI_BODY_PERCENT / 100
|
||||
|
||||
|
||||
candlestick_detector = CandlestickPatternDetector()
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
BullionVault Gold Price Service
|
||||
Fetches real-time gold prices from BullionVault's CSV data API
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BullionVaultService:
|
||||
"""
|
||||
Service to fetch gold prices from BullionVault
|
||||
BullionVault provides accurate, real-time precious metals prices
|
||||
Uses their CSV data API: https://chart-data.bullionvault.com
|
||||
"""
|
||||
|
||||
# Correct BullionVault CSV API base URL
|
||||
BASE_URL = "https://chart-data.bullionvault.com"
|
||||
|
||||
# Metal codes
|
||||
METALS = {
|
||||
'gold': 'AUX',
|
||||
'silver': 'AGX',
|
||||
'platinum': 'PTX',
|
||||
'palladium': 'PDX'
|
||||
}
|
||||
|
||||
# Interval codes (seconds between data points)
|
||||
INTERVALS = {
|
||||
'10m': 5, # 10 minutes
|
||||
'1h': 15, # 1 hour
|
||||
'6h': 120, # 6 hours
|
||||
'1d': 600, # 1 day (default)
|
||||
'1w': 3600, # 1 week
|
||||
'1m': 14400, # 1 month
|
||||
'3m': 43200, # 3 months (1 quarter)
|
||||
'1y': 172800, # 1 year
|
||||
'5y': 864000, # 5 years
|
||||
'20y': 2592000 # 20 years
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
*,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
max_retries: int = 3,
|
||||
retry_backoff_seconds: float = 0.5,
|
||||
) -> None:
|
||||
self.base_url = base_url or self.BASE_URL
|
||||
self.max_retries = max(1, max_retries)
|
||||
self.retry_backoff_seconds = max(0.0, retry_backoff_seconds)
|
||||
|
||||
if client is None:
|
||||
self.client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)
|
||||
self._owns_client = True
|
||||
else:
|
||||
self.client = client
|
||||
self._owns_client = False
|
||||
|
||||
async def __aenter__(self) -> "BullionVaultService":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.close()
|
||||
|
||||
async def get_current_gold_price(self, currency: str = "USD") -> Dict[str, Any]:
|
||||
"""
|
||||
Get current gold spot price from BullionVault
|
||||
|
||||
Args:
|
||||
currency: Currency code (USD, GBP, EUR, JPY, AUD, CAD, CHF)
|
||||
|
||||
Returns:
|
||||
Dict with price, high, low, change, timestamp, etc.
|
||||
"""
|
||||
try:
|
||||
# Fetch CSV data from BullionVault
|
||||
# Format: /prices/CSV/{metal}/{currency}/{interval}/Full
|
||||
metal_code = self.METALS['gold']
|
||||
interval = self.INTERVALS['1d']
|
||||
|
||||
path = f"/prices/CSV/{metal_code}/{currency.upper()}/{interval}/Full"
|
||||
|
||||
csv_text = await self._fetch_csv(path)
|
||||
|
||||
# Parse CSV data
|
||||
price_data = self._parse_csv(csv_text)
|
||||
|
||||
if not price_data:
|
||||
raise ValueError("No price data available from BullionVault")
|
||||
|
||||
# Get latest price (first row after header)
|
||||
latest = price_data[0]
|
||||
|
||||
# Calculate daily statistics
|
||||
oz_prices = [row['oz_close'] for row in price_data if row['oz_close'] is not None]
|
||||
|
||||
if not oz_prices:
|
||||
raise ValueError("No valid price points")
|
||||
|
||||
current_price = latest['oz_close']
|
||||
daily_high = max([row['oz_high'] for row in price_data if row['oz_high'] is not None])
|
||||
daily_low = min([row['oz_low'] for row in price_data if row['oz_low'] is not None])
|
||||
|
||||
# Calculate change from last data point
|
||||
first_price = price_data[-1]['oz_close'] if len(price_data) > 1 else current_price
|
||||
change = current_price - first_price
|
||||
change_percent = (change / first_price * 100) if first_price else 0.0
|
||||
|
||||
timestamp = latest['timestamp']
|
||||
|
||||
result = {
|
||||
"price": round(current_price, 2),
|
||||
"price_kg": round(latest['kg_close'], 2),
|
||||
"open": round(first_price, 2),
|
||||
"high": round(daily_high, 2),
|
||||
"low": round(daily_low, 2),
|
||||
"previous_close": round(first_price, 2),
|
||||
"change": round(change, 2),
|
||||
"change_percent": round(change_percent, 4),
|
||||
"currency": currency.upper(),
|
||||
"unit": "per troy oz",
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"source": "BullionVault",
|
||||
"trading_day": timestamp.strftime("%Y-%m-%d"),
|
||||
"data_points": len(price_data)
|
||||
}
|
||||
|
||||
logger.info(f"✅ BullionVault gold price: {currency} ${current_price:.2f}/oz")
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"❌ BullionVault HTTP error: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BullionVault price fetch failed: {e}")
|
||||
raise
|
||||
|
||||
async def _fetch_csv(self, path: str) -> str:
|
||||
"""Fetch CSV data from BullionVault with simple retry logic."""
|
||||
|
||||
last_exception: Optional[Exception] = None
|
||||
base = self.base_url.rstrip("/")
|
||||
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
try:
|
||||
url = path if path.startswith("http") else f"{base}{path}"
|
||||
response = await self.client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
csv_text = response.text.strip()
|
||||
if not csv_text:
|
||||
raise ValueError("BullionVault returned empty response body")
|
||||
|
||||
logger.debug(
|
||||
"Fetched BullionVault CSV successfully",
|
||||
extra={"path": url, "attempt": attempt},
|
||||
)
|
||||
return csv_text
|
||||
|
||||
except (httpx.RequestError, httpx.HTTPStatusError, ValueError) as exc:
|
||||
last_exception = exc
|
||||
logger.warning(
|
||||
"BullionVault CSV fetch attempt failed",
|
||||
extra={
|
||||
"path": url if "url" in locals() else path,
|
||||
"attempt": attempt,
|
||||
"max_attempts": self.max_retries,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
if attempt < self.max_retries:
|
||||
await asyncio.sleep(self.retry_backoff_seconds * attempt)
|
||||
|
||||
assert last_exception is not None
|
||||
raise last_exception
|
||||
|
||||
def _parse_csv(self, csv_text: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse BullionVault CSV response
|
||||
|
||||
CSV format:
|
||||
"Date",High (kg),Low (kg),Close (kg),,High (troy oz),Low (troy oz),Close (troy oz),
|
||||
"05:10:00 23-Nov-2025",130702.99,130702.99,130702.99,,4065.32,4065.32,4065.32,
|
||||
|
||||
Args:
|
||||
csv_text: Raw CSV text from BullionVault
|
||||
|
||||
Returns:
|
||||
List of price dictionaries
|
||||
"""
|
||||
result = []
|
||||
|
||||
# Parse CSV
|
||||
reader = csv.reader(io.StringIO(csv_text))
|
||||
|
||||
# Skip header
|
||||
next(reader, None)
|
||||
|
||||
for row in reader:
|
||||
if len(row) < 8:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Parse date/time: "HH:MM:SS DD-Mon-YYYY"
|
||||
date_str = row[0].strip('"')
|
||||
timestamp = datetime.strptime(date_str, "%H:%M:%S %d-%b-%Y").replace(tzinfo=timezone.utc)
|
||||
|
||||
# Extract prices (kg and oz)
|
||||
kg_high = self._to_float(row[1])
|
||||
kg_low = self._to_float(row[2])
|
||||
kg_close = self._to_float(row[3])
|
||||
|
||||
oz_high = self._to_float(row[5])
|
||||
oz_low = self._to_float(row[6])
|
||||
oz_close = self._to_float(row[7])
|
||||
|
||||
result.append({
|
||||
'timestamp': timestamp,
|
||||
'kg_high': kg_high,
|
||||
'kg_low': kg_low,
|
||||
'kg_close': kg_close,
|
||||
'oz_high': oz_high,
|
||||
'oz_low': oz_low,
|
||||
'oz_close': oz_close
|
||||
})
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.warning(f"Skipping malformed CSV row: {row} - {e}")
|
||||
continue
|
||||
|
||||
result.sort(key=lambda entry: entry['timestamp'], reverse=True)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _to_float(value: Optional[str]) -> Optional[float]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
async def get_gold_history(
|
||||
self,
|
||||
currency: str = "USD",
|
||||
timeframe: str = "1d",
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get historical gold price data from BullionVault
|
||||
|
||||
Args:
|
||||
currency: Currency code
|
||||
timeframe: Time range (10m, 1h, 6h, 1d, 1w, 1m, 3m, 1y, 5y, 20y)
|
||||
limit: Maximum number of data points to return
|
||||
|
||||
Returns:
|
||||
List of OHLC data points
|
||||
"""
|
||||
try:
|
||||
metal_code = self.METALS['gold']
|
||||
interval = self.INTERVALS.get(timeframe, self.INTERVALS['1d'])
|
||||
|
||||
path = f"/prices/CSV/{metal_code}/{currency.upper()}/{interval}/Full"
|
||||
|
||||
csv_text = await self._fetch_csv(path)
|
||||
|
||||
# Parse CSV data
|
||||
price_data = self._parse_csv(csv_text)
|
||||
|
||||
# Apply limit if specified
|
||||
if limit and len(price_data) > limit:
|
||||
price_data = price_data[:limit]
|
||||
|
||||
# Convert to OHLCV format
|
||||
result = []
|
||||
for point in price_data:
|
||||
result.append({
|
||||
"timestamp": point['timestamp'].isoformat(),
|
||||
"time": int(point['timestamp'].timestamp()),
|
||||
"open": point['oz_close'], # BullionVault doesn't provide open, use close
|
||||
"high": point['oz_high'],
|
||||
"low": point['oz_low'],
|
||||
"close": point['oz_close'],
|
||||
"volume": 0, # BullionVault doesn't provide volume
|
||||
})
|
||||
|
||||
logger.info(f"✅ BullionVault history: {len(result)} points for {timeframe}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BullionVault history fetch failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_multi_currency_prices(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get current gold prices in multiple currencies
|
||||
|
||||
Returns:
|
||||
Dict mapping currency codes to price data
|
||||
"""
|
||||
currencies = ["USD", "GBP", "EUR", "JPY", "AUD", "CAD", "CHF"]
|
||||
tasks = [self.get_current_gold_price(curr) for curr in currencies]
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
prices = {}
|
||||
for curr, result in zip(currencies, results):
|
||||
if isinstance(result, dict):
|
||||
prices[curr] = result
|
||||
else:
|
||||
logger.warning(f"Failed to fetch {curr} price: {result}")
|
||||
|
||||
return prices
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
if self._owns_client:
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Global instance
|
||||
bullionvault_service = BullionVaultService()
|
||||
|
||||
|
||||
# Convenience functions
|
||||
async def get_bullionvault_gold_price(currency: str = "USD") -> Dict[str, Any]:
|
||||
"""Get current gold price from BullionVault"""
|
||||
return await bullionvault_service.get_current_gold_price(currency)
|
||||
|
||||
|
||||
async def get_bullionvault_history(
|
||||
currency: str = "USD",
|
||||
timeframe: str = "1d",
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get historical gold prices from BullionVault"""
|
||||
return await bullionvault_service.get_gold_history(currency, timeframe, limit)
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
BullionVault Gold Price Service
|
||||
Fetches real-time gold prices from BullionVault's chart data API
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BullionVaultService:
|
||||
"""
|
||||
Service to fetch gold prices from BullionVault
|
||||
BullionVault provides accurate, real-time precious metals prices
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = httpx.AsyncClient(timeout=15.0)
|
||||
self.base_url = "https://www.bullionvault.com"
|
||||
# BullionVault chart data endpoint
|
||||
self.chart_data_url = f"{self.base_url}/chart/chart-data.json"
|
||||
|
||||
async def get_current_gold_price(self, currency: str = "USD") -> Dict[str, Any]:
|
||||
"""
|
||||
Get current gold spot price from BullionVault
|
||||
|
||||
Args:
|
||||
currency: Currency code (USD, GBP, EUR, JPY, AUD, CAD, CHF)
|
||||
|
||||
Returns:
|
||||
Dict with price, high, low, change, timestamp, etc.
|
||||
"""
|
||||
try:
|
||||
# Fetch latest gold price data
|
||||
params = {
|
||||
"bullion": "gold",
|
||||
"currency": currency.upper(),
|
||||
"timeframe": "1d", # 1 day for recent data
|
||||
"chartType": "line"
|
||||
}
|
||||
|
||||
response = await self.client.get(self.chart_data_url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data or "prices" not in data:
|
||||
raise ValueError("Invalid response from BullionVault")
|
||||
|
||||
prices = data["prices"]
|
||||
if not prices:
|
||||
raise ValueError("No price data available")
|
||||
|
||||
# Get latest price point
|
||||
latest = prices[-1]
|
||||
|
||||
# Calculate daily statistics
|
||||
daily_prices = [p[1] for p in prices if p[1] is not None]
|
||||
|
||||
if not daily_prices:
|
||||
raise ValueError("No valid price points")
|
||||
|
||||
current_price = latest[1] # Price per ounce
|
||||
daily_high = max(daily_prices)
|
||||
daily_low = min(daily_prices)
|
||||
|
||||
# Calculate change from first price of day
|
||||
first_price = prices[0][1]
|
||||
change = current_price - first_price
|
||||
change_percent = (change / first_price * 100) if first_price else 0.0
|
||||
|
||||
# Convert timestamp (BullionVault uses milliseconds)
|
||||
timestamp_ms = latest[0]
|
||||
timestamp = datetime.fromtimestamp(timestamp_ms / 1000.0)
|
||||
|
||||
result = {
|
||||
"price": round(current_price, 2),
|
||||
"open": round(first_price, 2),
|
||||
"high": round(daily_high, 2),
|
||||
"low": round(daily_low, 2),
|
||||
"previous_close": round(first_price, 2),
|
||||
"change": round(change, 2),
|
||||
"change_percent": round(change_percent, 4),
|
||||
"currency": currency.upper(),
|
||||
"unit": "per troy oz",
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"timestamp_ms": timestamp_ms,
|
||||
"source": "BullionVault",
|
||||
"trading_day": timestamp.strftime("%Y-%m-%d"),
|
||||
"data_points": len(prices)
|
||||
}
|
||||
|
||||
logger.info(f"✅ BullionVault gold price: {currency} ${current_price:.2f}/oz")
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"❌ BullionVault HTTP error: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BullionVault price fetch failed: {e}")
|
||||
raise
|
||||
|
||||
async def get_gold_history(
|
||||
self,
|
||||
currency: str = "USD",
|
||||
timeframe: str = "1d",
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get historical gold price data from BullionVault
|
||||
|
||||
Args:
|
||||
currency: Currency code
|
||||
timeframe: Time range (10m, 1h, 6h, 1d, 1w, 1m, 1q, 1y, 5y, 20y)
|
||||
limit: Maximum number of data points to return
|
||||
|
||||
Returns:
|
||||
List of OHLC data points
|
||||
"""
|
||||
try:
|
||||
params = {
|
||||
"bullion": "gold",
|
||||
"currency": currency.upper(),
|
||||
"timeframe": timeframe,
|
||||
"chartType": "hlc" # High-Low-Close for OHLC data
|
||||
}
|
||||
|
||||
response = await self.client.get(self.chart_data_url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data or "prices" not in data:
|
||||
return []
|
||||
|
||||
prices = data["prices"]
|
||||
|
||||
# Apply limit if specified
|
||||
if limit and len(prices) > limit:
|
||||
prices = prices[-limit:]
|
||||
|
||||
# Convert to OHLCV format
|
||||
result = []
|
||||
for point in prices:
|
||||
if len(point) >= 4: # [timestamp, open, high, low, close]
|
||||
timestamp_ms = point[0]
|
||||
result.append({
|
||||
"timestamp": datetime.fromtimestamp(timestamp_ms / 1000.0).isoformat(),
|
||||
"time": int(timestamp_ms / 1000),
|
||||
"open": float(point[1]) if point[1] is not None else 0.0,
|
||||
"high": float(point[2]) if point[2] is not None else 0.0,
|
||||
"low": float(point[3]) if point[3] is not None else 0.0,
|
||||
"close": float(point[4]) if len(point) > 4 and point[4] is not None else float(point[1]),
|
||||
"volume": 0, # BullionVault doesn't provide volume
|
||||
})
|
||||
|
||||
logger.info(f"✅ BullionVault history: {len(result)} points for {timeframe}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BullionVault history fetch failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_multi_currency_prices(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get current gold prices in multiple currencies
|
||||
|
||||
Returns:
|
||||
Dict mapping currency codes to price data
|
||||
"""
|
||||
currencies = ["USD", "GBP", "EUR", "JPY", "AUD", "CAD", "CHF"]
|
||||
tasks = [self.get_current_gold_price(curr) for curr in currencies]
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
prices = {}
|
||||
for curr, result in zip(currencies, results):
|
||||
if isinstance(result, dict):
|
||||
prices[curr] = result
|
||||
else:
|
||||
logger.warning(f"Failed to fetch {curr} price: {result}")
|
||||
|
||||
return prices
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Global instance
|
||||
bullionvault_service = BullionVaultService()
|
||||
|
||||
|
||||
# Convenience functions
|
||||
async def get_bullionvault_gold_price(currency: str = "USD") -> Dict[str, Any]:
|
||||
"""Get current gold price from BullionVault"""
|
||||
return await bullionvault_service.get_current_gold_price(currency)
|
||||
|
||||
|
||||
async def get_bullionvault_history(
|
||||
currency: str = "USD",
|
||||
timeframe: str = "1d",
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get historical gold prices from BullionVault"""
|
||||
return await bullionvault_service.get_gold_history(currency, timeframe, limit)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Robust Gold Price Fetcher with Multiple Data Sources and Fallback
|
||||
Ensures accurate real-time gold pricing with redundancy
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GoldPriceFetcher:
|
||||
"""
|
||||
Multi-source gold price fetcher with automatic fallback
|
||||
|
||||
Data Sources (in priority order):
|
||||
1. Alpha Vantage - GLD ETF (reliable, free tier)
|
||||
2. Twelve Data API (if available)
|
||||
3. Yahoo Finance (backup)
|
||||
4. Static fallback to reasonable estimate
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = httpx.AsyncClient(timeout=10.0)
|
||||
# GLD ETF tracks ~1/10th of gold spot price
|
||||
self.gld_multiplier = 10.0
|
||||
# Gold futures (GC) are 100oz contracts, but quote is per oz
|
||||
self.gc_multiplier = 1.0
|
||||
|
||||
async def get_current_gold_price(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current gold price with automatic fallback through multiple sources
|
||||
|
||||
Returns:
|
||||
Dict with: price, source, timestamp, high_24h, low_24h, change_percent
|
||||
"""
|
||||
# Try Alpha Vantage GLD first (most reliable)
|
||||
try:
|
||||
result = await self._fetch_from_alpha_vantage_gld()
|
||||
if result:
|
||||
logger.info(f"✅ Gold price from Alpha Vantage GLD: ${result['price']:.2f}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Alpha Vantage GLD failed: {e}")
|
||||
|
||||
# Try Twelve Data if available
|
||||
try:
|
||||
result = await self._fetch_from_twelve_data()
|
||||
if result:
|
||||
logger.info(f"✅ Gold price from Twelve Data: ${result['price']:.2f}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Twelve Data failed: {e}")
|
||||
|
||||
# Try alternative free sources
|
||||
try:
|
||||
result = await self._fetch_from_metals_api()
|
||||
if result:
|
||||
logger.info(f"✅ Gold price from Metals-API: ${result['price']:.2f}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Metals-API failed: {e}")
|
||||
|
||||
# Last resort: return estimated price with warning
|
||||
logger.error("⚠️ All gold price sources failed, using estimated price")
|
||||
return self._get_fallback_price()
|
||||
|
||||
async def _fetch_from_alpha_vantage_gld(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch from Alpha Vantage using GLD ETF as proxy
|
||||
GLD tracks gold at ~1/10th spot price
|
||||
"""
|
||||
api_key = settings.ALPHA_VANTAGE_API_KEY or "M1S58UEM42CQD31T"
|
||||
url = f"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=GLD&apikey={api_key}"
|
||||
|
||||
response = await self.client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if "Global Quote" not in data or not data["Global Quote"]:
|
||||
return None
|
||||
|
||||
quote = data["Global Quote"]
|
||||
gld_price = float(quote.get("05. price", 0))
|
||||
|
||||
if gld_price == 0:
|
||||
return None
|
||||
|
||||
# Convert GLD price to gold spot price (multiply by 10)
|
||||
gold_price = gld_price * self.gld_multiplier
|
||||
|
||||
return {
|
||||
"price": gold_price,
|
||||
"open": float(quote.get("02. open", 0)) * self.gld_multiplier,
|
||||
"high": float(quote.get("03. high", 0)) * self.gld_multiplier,
|
||||
"low": float(quote.get("04. low", 0)) * self.gld_multiplier,
|
||||
"volume": int(quote.get("06. volume", 0)),
|
||||
"previous_close": float(quote.get("08. previous close", 0)) * self.gld_multiplier,
|
||||
"change": float(quote.get("09. change", 0)) * self.gld_multiplier,
|
||||
"change_percent": quote.get("10. change percent", "0%"),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"source": "Alpha Vantage (GLD ETF)",
|
||||
"trading_day": quote.get("07. latest trading day", ""),
|
||||
}
|
||||
|
||||
async def _fetch_from_twelve_data(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch from Twelve Data API (if API key available)
|
||||
They have direct XAU/USD forex pair
|
||||
"""
|
||||
# Twelve Data would require API key setup
|
||||
# Placeholder for now
|
||||
return None
|
||||
|
||||
async def _fetch_from_metals_api(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch from Metals-API.com free tier
|
||||
Provides direct gold spot prices
|
||||
"""
|
||||
try:
|
||||
# Free tier endpoint (limited requests)
|
||||
url = "https://metals-api.com/api/latest"
|
||||
params = {
|
||||
"access_key": "your_key_here", # Would need API key
|
||||
"base": "USD",
|
||||
"symbols": "XAU"
|
||||
}
|
||||
|
||||
# Skip if no key configured
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_fallback_price(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return reasonable estimated gold price when all sources fail
|
||||
Based on typical 2025 gold trading range
|
||||
"""
|
||||
# Conservative estimate for late 2025 gold prices
|
||||
estimated_price = 3800.0 # Mid-range estimate
|
||||
|
||||
return {
|
||||
"price": estimated_price,
|
||||
"open": estimated_price,
|
||||
"high": estimated_price * 1.01,
|
||||
"low": estimated_price * 0.99,
|
||||
"volume": 0,
|
||||
"previous_close": estimated_price,
|
||||
"change": 0.0,
|
||||
"change_percent": "0%",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"source": "FALLBACK_ESTIMATE",
|
||||
"trading_day": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
"warning": "⚠️ Using estimated price - all data sources unavailable"
|
||||
}
|
||||
|
||||
async def get_intraday_data(self, interval: str = "5min", limit: int = 100) -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Get intraday gold price data
|
||||
|
||||
Args:
|
||||
interval: Time interval (1min, 5min, 15min, 30min, 60min)
|
||||
limit: Number of data points to return
|
||||
|
||||
Returns:
|
||||
List of OHLCV data points
|
||||
"""
|
||||
try:
|
||||
return await self._fetch_intraday_alpha_vantage(interval, limit)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch intraday data: {e}")
|
||||
return []
|
||||
|
||||
async def _fetch_intraday_alpha_vantage(self, interval: str, limit: int) -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch intraday data from Alpha Vantage
|
||||
Using GLD as proxy since XAU/USD intraday is premium
|
||||
"""
|
||||
api_key = settings.ALPHA_VANTAGE_API_KEY or "M1S58UEM42CQD31T"
|
||||
url = f"https://www.alphavantage.co/query"
|
||||
params = {
|
||||
"function": "TIME_SERIES_INTRADAY",
|
||||
"symbol": "GLD",
|
||||
"interval": interval,
|
||||
"apikey": api_key,
|
||||
"outputsize": "compact" # Last 100 data points
|
||||
}
|
||||
|
||||
response = await self.client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
time_series_key = f"Time Series ({interval})"
|
||||
if time_series_key not in data:
|
||||
return []
|
||||
|
||||
time_series = data[time_series_key]
|
||||
|
||||
# Convert to OHLCV format and apply gold multiplier
|
||||
result = []
|
||||
for timestamp, values in list(time_series.items())[:limit]:
|
||||
result.append({
|
||||
"timestamp": timestamp,
|
||||
"time": int(datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()),
|
||||
"open": float(values["1. open"]) * self.gld_multiplier,
|
||||
"high": float(values["2. high"]) * self.gld_multiplier,
|
||||
"low": float(values["3. low"]) * self.gld_multiplier,
|
||||
"close": float(values["4. close"]) * self.gld_multiplier,
|
||||
"volume": int(values["5. volume"]),
|
||||
})
|
||||
|
||||
return sorted(result, key=lambda x: x["time"])
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Global instance
|
||||
gold_price_fetcher = GoldPriceFetcher()
|
||||
|
||||
|
||||
# Convenience functions for backward compatibility
|
||||
async def get_current_gold_price() -> Dict[str, Any]:
|
||||
"""Get current gold spot price"""
|
||||
return await gold_price_fetcher.get_current_gold_price()
|
||||
|
||||
|
||||
async def get_gold_intraday(interval: str = "5min", limit: int = 100) -> list[Dict[str, Any]]:
|
||||
"""Get intraday gold price data"""
|
||||
return await gold_price_fetcher.get_intraday_data(interval, limit)
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
GOLDPRICE_URL_TEMPLATE = "https://data-asg.goldprice.org/dbXRates/{currency}"
|
||||
DEFAULT_CURRENCY = "USD"
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def fetch_goldprice_quote(currency: str = DEFAULT_CURRENCY) -> Optional[dict]:
|
||||
url = GOLDPRICE_URL_TEMPLATE.format(currency=currency.upper())
|
||||
async with httpx.AsyncClient(timeout=10.0, headers=HEADERS) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
items = data.get("items") or []
|
||||
if not items:
|
||||
return None
|
||||
|
||||
quote = items[0]
|
||||
xau_price = quote.get("xauPrice")
|
||||
if xau_price is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"price": float(xau_price),
|
||||
"change": float(quote.get("chgXau") or 0.0),
|
||||
"change_percent": float(quote.get("pcXau") or 0.0),
|
||||
"previous_close": float(quote.get("xauClose") or 0.0),
|
||||
"timestamp_ms": int(data.get("ts") or 0),
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.schemas.schemas import PriceData
|
||||
|
||||
YAHOO_QUOTE_URL = "https://query1.finance.yahoo.com/v7/finance/quote"
|
||||
YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
||||
YAHOO_SYMBOL = "XAUUSD=X"
|
||||
YAHOO_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def fetch_yahoo_quote(symbol: str = YAHOO_SYMBOL) -> Optional[dict]:
|
||||
params = {"symbols": symbol}
|
||||
async with httpx.AsyncClient(timeout=20.0, headers=YAHOO_HEADERS) as client:
|
||||
response = await client.get(YAHOO_QUOTE_URL, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
result = (data.get("quoteResponse", {}) or {}).get("result", [])
|
||||
if not result:
|
||||
return None
|
||||
quote = result[0]
|
||||
def _safe_float(value: Optional[float], default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"price": _safe_float(quote.get("regularMarketPrice"), default=0.0),
|
||||
"high": _safe_float(quote.get("regularMarketDayHigh")),
|
||||
"low": _safe_float(quote.get("regularMarketDayLow")),
|
||||
"volume": _safe_float(quote.get("regularMarketVolume"), default=0.0),
|
||||
"previous_close": _safe_float(quote.get("regularMarketPreviousClose"), default=0.0),
|
||||
"timestamp": int(quote.get("regularMarketTime") or 0),
|
||||
}
|
||||
|
||||
|
||||
def _interval_range_for_chart(interval: str) -> tuple[str, str]:
|
||||
normalized = interval.lower()
|
||||
mapping = {
|
||||
"1m": ("1m", "1d"),
|
||||
"1min": ("1m", "1d"),
|
||||
"5m": ("5m", "5d"),
|
||||
"5min": ("5m", "5d"),
|
||||
"15m": ("15m", "1mo"),
|
||||
"15min": ("15m", "1mo"),
|
||||
"30m": ("30m", "1mo"),
|
||||
"30min": ("30m", "1mo"),
|
||||
"60m": ("60m", "1y"),
|
||||
"60min": ("60m", "1y"),
|
||||
"daily": ("1d", "5y"),
|
||||
}
|
||||
return mapping.get(normalized, ("1m", "1d"))
|
||||
|
||||
|
||||
async def fetch_yahoo_ohlcv(symbol: str = YAHOO_SYMBOL, interval: str = "1m") -> List[PriceData]:
|
||||
interval_key, range_key = _interval_range_for_chart(interval)
|
||||
url = YAHOO_CHART_URL.format(symbol=symbol)
|
||||
params = {"interval": interval_key, "range": range_key, "includePrePost": "false"}
|
||||
async with httpx.AsyncClient(timeout=20.0, headers=YAHOO_HEADERS) as client:
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
chart = (data.get("chart") or {}).get("result") or []
|
||||
if not chart:
|
||||
return []
|
||||
result = chart[0]
|
||||
timestamps = result.get("timestamp") or []
|
||||
indicators = (result.get("indicators") or {}).get("quote") or []
|
||||
if not indicators:
|
||||
return []
|
||||
quote = indicators[0]
|
||||
opens = quote.get("open") or []
|
||||
highs = quote.get("high") or []
|
||||
lows = quote.get("low") or []
|
||||
closes = quote.get("close") or []
|
||||
volumes = quote.get("volume") or []
|
||||
|
||||
price_data: List[PriceData] = []
|
||||
for idx, ts in enumerate(timestamps):
|
||||
open_price = opens[idx] if idx < len(opens) else None
|
||||
high_price = highs[idx] if idx < len(highs) else None
|
||||
low_price = lows[idx] if idx < len(lows) else None
|
||||
close_price = closes[idx] if idx < len(closes) else None
|
||||
if None in (open_price, high_price, low_price, close_price):
|
||||
continue
|
||||
volume_val = volumes[idx] if idx < len(volumes) else 0.0
|
||||
price_data.append(
|
||||
PriceData(
|
||||
time=int(ts),
|
||||
open=float(open_price),
|
||||
high=float(high_price),
|
||||
low=float(low_price),
|
||||
close=float(close_price),
|
||||
volume=float(volume_val or 0.0),
|
||||
)
|
||||
)
|
||||
return price_data
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
from app.schemas.schemas import PriceData
|
||||
|
||||
YA_SYMBOL = "XAUUSD=X"
|
||||
|
||||
|
||||
def _format_dataframe(df: pd.DataFrame) -> List[PriceData]:
|
||||
rows: List[PriceData] = []
|
||||
if df.empty:
|
||||
return rows
|
||||
df = df.dropna(subset=["Open", "High", "Low", "Close"])
|
||||
for idx, row in df.iterrows():
|
||||
timestamp = int(pd.Timestamp(idx).timestamp())
|
||||
rows.append(
|
||||
PriceData(
|
||||
time=timestamp,
|
||||
open=float(row["Open"]),
|
||||
high=float(row["High"]),
|
||||
low=float(row["Low"]),
|
||||
close=float(row["Close"]),
|
||||
volume=float(row.get("Volume", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def fetch_yfinance_history(
|
||||
symbol: str = YA_SYMBOL,
|
||||
interval: str = "1m",
|
||||
period: str = "1d",
|
||||
start: Optional[str] = None,
|
||||
end: Optional[str] = None,
|
||||
) -> List[PriceData]:
|
||||
def _download() -> pd.DataFrame:
|
||||
return yf.download(
|
||||
symbol,
|
||||
interval=interval,
|
||||
period=None if start else period,
|
||||
start=start,
|
||||
end=end,
|
||||
progress=False,
|
||||
auto_adjust=False,
|
||||
threads=False,
|
||||
)
|
||||
|
||||
df = await asyncio.to_thread(_download)
|
||||
return _format_dataframe(df)
|
||||
|
||||
|
||||
async def fetch_yfinance_quote(symbol: str = YA_SYMBOL) -> Optional[dict]:
|
||||
rows = await fetch_yfinance_history(symbol=symbol, interval="1m", period="1d")
|
||||
if not rows:
|
||||
return None
|
||||
latest = rows[-1]
|
||||
previous = rows[-2] if len(rows) > 1 else latest
|
||||
return {
|
||||
"price": latest.close,
|
||||
"previous_close": previous.close,
|
||||
"high_24h": max(r.high for r in rows[-1440:]),
|
||||
"low_24h": min(r.low for r in rows[-1440:]),
|
||||
"volume": latest.volume or 0.0,
|
||||
"updated_at": latest.time,
|
||||
"rows": rows,
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Web search integration for fetching real-time gold market news.
|
||||
|
||||
This module provides functionality to search for recent gold market news
|
||||
using various search APIs. Currently supports:
|
||||
- DuckDuckGo search (free, no API key required)
|
||||
- Extensible for Tavily, SerpAPI, or other providers
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class NewsSearchService:
|
||||
"""Service for fetching recent gold market news from the web."""
|
||||
|
||||
def __init__(self):
|
||||
self.timeout = 10.0
|
||||
|
||||
async def search_gold_news(self, query: str = "gold price XAU/USD", max_results: int = 5) -> List[Dict]:
|
||||
"""
|
||||
Search for recent gold market news.
|
||||
|
||||
Args:
|
||||
query: Search query (default: "gold price XAU/USD")
|
||||
max_results: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of news articles with title, snippet, url, and date
|
||||
"""
|
||||
try:
|
||||
# Use DuckDuckGo Instant Answer API (free, no key required)
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
"https://api.duckduckgo.com/",
|
||||
params={
|
||||
"q": query,
|
||||
"format": "json",
|
||||
"no_html": 1,
|
||||
"skip_disambig": 1,
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
results = []
|
||||
|
||||
# Extract related topics (news items)
|
||||
related_topics = data.get("RelatedTopics", [])
|
||||
for topic in related_topics[:max_results]:
|
||||
if isinstance(topic, dict) and "Text" in topic:
|
||||
results.append({
|
||||
"title": topic.get("Text", "")[:100],
|
||||
"snippet": topic.get("Text", ""),
|
||||
"url": topic.get("FirstURL", ""),
|
||||
"source": "DuckDuckGo",
|
||||
"date": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
print(f"News search error: {e}")
|
||||
|
||||
# Return fallback generic news context
|
||||
return self._get_fallback_news()
|
||||
|
||||
def _get_fallback_news(self) -> List[Dict]:
|
||||
"""Return generic gold market context when search fails."""
|
||||
return [
|
||||
{
|
||||
"title": "Gold Market Overview",
|
||||
"snippet": "Gold prices influenced by USD strength, inflation expectations, and geopolitical events",
|
||||
"url": "",
|
||||
"source": "General Context",
|
||||
"date": datetime.now().isoformat()
|
||||
},
|
||||
{
|
||||
"title": "Key Gold Drivers",
|
||||
"snippet": "Federal Reserve policy, US Dollar Index (DXY), real yields, and global risk sentiment",
|
||||
"url": "",
|
||||
"source": "General Context",
|
||||
"date": datetime.now().isoformat()
|
||||
}
|
||||
]
|
||||
|
||||
async def get_news_summary(self, max_items: int = 3) -> str:
|
||||
"""
|
||||
Get a formatted summary of recent gold news for AI prompts.
|
||||
|
||||
Args:
|
||||
max_items: Maximum number of news items to include
|
||||
|
||||
Returns:
|
||||
Formatted string with news headlines and snippets
|
||||
"""
|
||||
news_items = await self.search_gold_news(max_results=max_items)
|
||||
|
||||
if not news_items:
|
||||
return "📰 Recent News: No recent news available. Analysis based on technical factors only."
|
||||
|
||||
summary = "📰 RECENT MARKET NEWS:\n"
|
||||
for i, item in enumerate(news_items, 1):
|
||||
summary += f"{i}. {item['title']}\n"
|
||||
if item['snippet'] and item['snippet'] != item['title']:
|
||||
summary += f" {item['snippet'][:150]}...\n"
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
# Global service instance
|
||||
news_search_service = NewsSearchService()
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Ollama Local AI Service
|
||||
|
||||
Provides local AI capabilities for lightweight tasks like:
|
||||
- Quick sentiment analysis
|
||||
- Simple text summarization
|
||||
- Fast pattern classification
|
||||
- Embeddings generation
|
||||
|
||||
Falls back to OpenRouter for complex tasks.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OllamaService:
|
||||
"""Service for local AI using Ollama."""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.OLLAMA_BASE_URL
|
||||
self.model = settings.OLLAMA_MODEL
|
||||
self.embed_model = settings.OLLAMA_MODEL_EMBED
|
||||
self.timeout = settings.OLLAMA_TIMEOUT
|
||||
self._available = None # Cached availability status
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
"""Check if Ollama is running and has the required model."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
models = [m["name"] for m in data.get("models", [])]
|
||||
self._available = self.model in models or any(self.model.split(":")[0] in m for m in models)
|
||||
return self._available
|
||||
except Exception as e:
|
||||
logger.debug(f"Ollama not available: {e}")
|
||||
self._available = False
|
||||
return False
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 500,
|
||||
model: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Generate text using local Ollama model.
|
||||
|
||||
Args:
|
||||
prompt: The user prompt
|
||||
system: Optional system prompt
|
||||
temperature: Sampling temperature (0-1)
|
||||
max_tokens: Maximum tokens to generate
|
||||
model: Override default model
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
"""
|
||||
if not await self.is_available():
|
||||
logger.warning("Ollama not available, skipping local generation")
|
||||
return None
|
||||
|
||||
use_model = model or self.model
|
||||
|
||||
payload = {
|
||||
"model": use_model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": max_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
if system:
|
||||
payload["system"] = system
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/generate",
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("response", "").strip()
|
||||
else:
|
||||
logger.error(f"Ollama generate failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama generate error: {e}")
|
||||
return None
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 500,
|
||||
model: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Chat completion using local Ollama model.
|
||||
|
||||
Args:
|
||||
messages: List of {"role": "user/assistant/system", "content": "..."}
|
||||
temperature: Sampling temperature
|
||||
max_tokens: Maximum tokens to generate
|
||||
model: Override default model
|
||||
|
||||
Returns:
|
||||
Assistant response or None if failed
|
||||
"""
|
||||
if not await self.is_available():
|
||||
return None
|
||||
|
||||
use_model = model or self.model
|
||||
|
||||
payload = {
|
||||
"model": use_model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": max_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("message", {}).get("content", "").strip()
|
||||
else:
|
||||
logger.error(f"Ollama chat failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama chat error: {e}")
|
||||
return None
|
||||
|
||||
async def embed(
|
||||
self,
|
||||
text: str,
|
||||
model: Optional[str] = None
|
||||
) -> Optional[List[float]]:
|
||||
"""
|
||||
Generate embeddings using local model.
|
||||
|
||||
Args:
|
||||
text: Text to embed
|
||||
model: Override default embedding model
|
||||
|
||||
Returns:
|
||||
Embedding vector or None if failed
|
||||
"""
|
||||
if not await self.is_available():
|
||||
return None
|
||||
|
||||
use_model = model or self.embed_model
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/embeddings",
|
||||
json={"model": use_model, "prompt": text}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("embedding")
|
||||
else:
|
||||
logger.error(f"Ollama embed failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama embed error: {e}")
|
||||
return None
|
||||
|
||||
async def quick_sentiment(self, text: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Quick sentiment analysis using local model.
|
||||
Optimized for speed over accuracy.
|
||||
|
||||
Args:
|
||||
text: Text to analyze
|
||||
|
||||
Returns:
|
||||
{"sentiment": "positive/negative/neutral", "confidence": 0.0-1.0}
|
||||
"""
|
||||
system = """You are a sentiment analyzer. Respond ONLY with JSON in this exact format:
|
||||
{"sentiment": "positive" or "negative" or "neutral", "confidence": 0.0 to 1.0}
|
||||
No other text."""
|
||||
|
||||
prompt = f"Analyze the sentiment of this text:\n\n{text[:500]}" # Limit input
|
||||
|
||||
result = await self.generate(
|
||||
prompt=prompt,
|
||||
system=system,
|
||||
temperature=0.1,
|
||||
max_tokens=50
|
||||
)
|
||||
|
||||
if result:
|
||||
try:
|
||||
import json
|
||||
# Try to extract JSON from response
|
||||
if "{" in result:
|
||||
json_str = result[result.find("{"):result.rfind("}")+1]
|
||||
return json.loads(json_str)
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
async def quick_classify(
|
||||
self,
|
||||
text: str,
|
||||
categories: List[str]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Quick text classification into predefined categories.
|
||||
|
||||
Args:
|
||||
text: Text to classify
|
||||
categories: List of possible categories
|
||||
|
||||
Returns:
|
||||
Selected category or None
|
||||
"""
|
||||
categories_str = ", ".join(categories)
|
||||
system = f"You are a classifier. Respond with ONLY one of these categories: {categories_str}. No other text."
|
||||
|
||||
prompt = f"Classify this text into one category:\n\n{text[:500]}"
|
||||
|
||||
result = await self.generate(
|
||||
prompt=prompt,
|
||||
system=system,
|
||||
temperature=0.1,
|
||||
max_tokens=20
|
||||
)
|
||||
|
||||
if result:
|
||||
# Find matching category
|
||||
result_lower = result.lower().strip()
|
||||
for cat in categories:
|
||||
if cat.lower() in result_lower:
|
||||
return cat
|
||||
|
||||
return None
|
||||
|
||||
async def quick_summarize(self, text: str, max_sentences: int = 2) -> Optional[str]:
|
||||
"""
|
||||
Quick text summarization.
|
||||
|
||||
Args:
|
||||
text: Text to summarize
|
||||
max_sentences: Maximum sentences in summary
|
||||
|
||||
Returns:
|
||||
Summary or None
|
||||
"""
|
||||
system = f"Summarize in {max_sentences} sentence(s) or less. Be concise and direct."
|
||||
|
||||
result = await self.generate(
|
||||
prompt=text[:2000], # Limit input
|
||||
system=system,
|
||||
temperature=0.3,
|
||||
max_tokens=150
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Global instance
|
||||
ollama_service = OllamaService()
|
||||
|
||||
|
||||
async def get_ai_response(
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
use_local: bool = True,
|
||||
fallback_to_cloud: bool = True
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Unified AI response function that tries local first, then cloud.
|
||||
|
||||
Args:
|
||||
prompt: User prompt
|
||||
system: System prompt
|
||||
use_local: Whether to try Ollama first
|
||||
fallback_to_cloud: Whether to fallback to OpenRouter if local fails
|
||||
|
||||
Returns:
|
||||
AI response or None
|
||||
"""
|
||||
# Try local first if enabled
|
||||
if use_local and settings.USE_LOCAL_AI:
|
||||
result = await ollama_service.generate(prompt, system)
|
||||
if result:
|
||||
logger.info("Used local Ollama for AI response")
|
||||
return result
|
||||
|
||||
# Fallback to cloud
|
||||
if fallback_to_cloud and settings.OPENROUTER_API_KEY:
|
||||
from app.services.openrouter import openrouter_service
|
||||
# This would need a simple generate method in openrouter
|
||||
logger.info("Falling back to OpenRouter for AI response")
|
||||
# For now, return None - full integration would go here
|
||||
pass
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,663 @@
|
||||
"""
|
||||
Trading Plan Templates
|
||||
Comprehensive trading plan generators for different schools and scenarios
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import datetime, date
|
||||
from enum import Enum
|
||||
from .trading_schools import TradingSchool
|
||||
|
||||
|
||||
class PlanType(str, Enum):
|
||||
"""Types of trading plans"""
|
||||
INTRADAY = "intraday" # Day trading
|
||||
SWING = "swing" # Multi-day holds
|
||||
POSITION = "position" # Weeks to months
|
||||
SCALPING = "scalping" # Quick in/out
|
||||
EVENT_DRIVEN = "event_driven" # News/economic events
|
||||
RANGE_BOUND = "range_bound" # Sideways markets
|
||||
BREAKOUT = "breakout" # Breakout strategies
|
||||
REVERSAL = "reversal" # Reversal trading
|
||||
TREND_FOLLOWING = "trend_following" # Trend continuation
|
||||
|
||||
|
||||
class MarketCondition(str, Enum):
|
||||
"""Market conditions"""
|
||||
TRENDING_UP = "trending_up"
|
||||
TRENDING_DOWN = "trending_down"
|
||||
RANGING = "ranging"
|
||||
VOLATILE = "volatile"
|
||||
QUIET = "quiet"
|
||||
BREAKOUT_PENDING = "breakout_pending"
|
||||
POST_NEWS = "post_news"
|
||||
|
||||
|
||||
class PlanTemplates:
|
||||
"""Generate trading plans based on methodology and conditions"""
|
||||
|
||||
@staticmethod
|
||||
def generate_ict_smc_plan(
|
||||
current_price: float,
|
||||
market_condition: MarketCondition,
|
||||
session: str = "london_ny"
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate ICT/Smart Money Concepts trading plan"""
|
||||
|
||||
# Adaptive targets based on price
|
||||
atr_estimate = current_price * 0.015 # ~1.5% for gold
|
||||
|
||||
if session == "london":
|
||||
killzone_start = "03:00 EST"
|
||||
killzone_end = "05:00 EST"
|
||||
elif session == "ny":
|
||||
killzone_start = "08:00 EST"
|
||||
killzone_end = "11:00 EST"
|
||||
else:
|
||||
killzone_start = "08:00 EST"
|
||||
killzone_end = "11:00 EST"
|
||||
|
||||
return {
|
||||
"plan_type": PlanType.INTRADAY,
|
||||
"methodology": "ICT / Smart Money Concepts",
|
||||
"session_focus": session.upper(),
|
||||
"killzone": f"{killzone_start} - {killzone_end}",
|
||||
"analysis_framework": [
|
||||
"1. MARKET STRUCTURE ANALYSIS",
|
||||
" □ Identify current trend (HH/HL for bullish, LH/LL for bearish)",
|
||||
" □ Mark last BOS (Break of Structure) or ChoCh (Change of Character)",
|
||||
" □ Determine market state: Trending vs Ranging",
|
||||
"",
|
||||
"2. KEY LEVEL IDENTIFICATION",
|
||||
" □ Mark all Fair Value Gaps (FVG/Imbalance)",
|
||||
" □ Identify Order Blocks (last down candle before up move, vice versa)",
|
||||
" □ Note liquidity pools (equal highs/lows, stop hunts)",
|
||||
" □ Draw Premium/Discount zones (50% of range)",
|
||||
"",
|
||||
"3. ENTRY STRATEGY",
|
||||
" □ Wait for liquidity sweep (stop hunt)",
|
||||
" □ Price retraces to FVG or Order Block",
|
||||
" □ Optimal Trade Entry: 0.618-0.79 Fibonacci of last leg",
|
||||
" □ Enter during killzone for best probability",
|
||||
" □ Look for displacement after entry (strong move)",
|
||||
"",
|
||||
"4. RISK MANAGEMENT",
|
||||
" □ Stop loss: 5-10 points beyond Order Block",
|
||||
f" □ Position size: Based on ${atr_estimate:.2f} ATR",
|
||||
" □ First target: Next FVG or liquidity",
|
||||
" □ Final target: Opposite side liquidity or major structure",
|
||||
" □ Move stop to break-even after 1:1 RR"
|
||||
],
|
||||
"entry_checklist": [
|
||||
"✓ Market structure identified (bullish/bearish)",
|
||||
"✓ BOS or ChoCh confirmed",
|
||||
"✓ FVG or Order Block located",
|
||||
"✓ Waiting for retracement to OTE (0.618-0.79)",
|
||||
"✓ Entry during killzone hours",
|
||||
"✓ Clear invalidation point defined"
|
||||
],
|
||||
"trade_scenarios": {
|
||||
"bullish_setup": {
|
||||
"prerequisites": [
|
||||
"Price creates higher high (BOS)",
|
||||
"Retracement to bullish FVG or Order Block",
|
||||
"Entry at 0.618-0.79 Fib of last bullish leg"
|
||||
],
|
||||
"entry": f"${current_price - (atr_estimate * 0.7):.2f} (at OB/FVG)",
|
||||
"stop_loss": f"${current_price - (atr_estimate * 1.2):.2f} (below OB)",
|
||||
"target_1": f"${current_price + (atr_estimate * 0.8):.2f} (FVG fill)",
|
||||
"target_2": f"${current_price + (atr_estimate * 1.5):.2f} (liquidity)",
|
||||
"rr_ratio": "1:3"
|
||||
},
|
||||
"bearish_setup": {
|
||||
"prerequisites": [
|
||||
"Price creates lower low (BOS)",
|
||||
"Retracement to bearish FVG or Order Block",
|
||||
"Entry at 0.618-0.79 Fib of last bearish leg"
|
||||
],
|
||||
"entry": f"${current_price + (atr_estimate * 0.7):.2f} (at OB/FVG)",
|
||||
"stop_loss": f"${current_price + (atr_estimate * 1.2):.2f} (above OB)",
|
||||
"target_1": f"${current_price - (atr_estimate * 0.8):.2f} (FVG fill)",
|
||||
"target_2": f"${current_price - (atr_estimate * 1.5):.2f} (liquidity)",
|
||||
"rr_ratio": "1:3"
|
||||
}
|
||||
},
|
||||
"max_trades": 2,
|
||||
"max_daily_loss": 250,
|
||||
"notes": [
|
||||
"⚠️ CRITICAL RULES:",
|
||||
"• Only trade during killzone hours (highest probability)",
|
||||
"• Must have clear FVG or Order Block - no guessing",
|
||||
"• Wait for displacement (strong candle) for confirmation",
|
||||
"• Avoid trading during major news releases",
|
||||
"• If stopped out twice, done for the session",
|
||||
"",
|
||||
"📊 MARKET MAKER MODEL:",
|
||||
"1. Accumulation: Quiet consolidation, FVG formation",
|
||||
"2. Manipulation: Liquidity sweep (stop hunt) against trend",
|
||||
"3. Distribution: True move in intended direction",
|
||||
"",
|
||||
"🎯 OPTIMAL TRADE ENTRY (OTE):",
|
||||
"• 0.618 Fib: Conservative entry",
|
||||
"• 0.705 Fib: Sweet spot",
|
||||
"• 0.79 Fib: Aggressive entry (higher risk)",
|
||||
"",
|
||||
"💡 PRO TIPS:",
|
||||
"• London session: Watch for Judas swing (false move)",
|
||||
"• NY session: Strongest moves, follow London direction",
|
||||
"• Avoid Asian session: Low liquidity, choppy",
|
||||
"• Best setups: Monday-Thursday (avoid Friday chop)"
|
||||
]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_wyckoff_plan(
|
||||
current_price: float,
|
||||
market_condition: MarketCondition
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate Wyckoff Method trading plan"""
|
||||
|
||||
range_size = current_price * 0.03 # 3% trading range estimate
|
||||
|
||||
return {
|
||||
"plan_type": PlanType.SWING,
|
||||
"methodology": "Wyckoff Method",
|
||||
"analysis_framework": [
|
||||
"1. PHASE IDENTIFICATION",
|
||||
" □ Accumulation (PS → SC → AR → ST → Spring → Test → SOS → LPS → BU)",
|
||||
" □ Markup (Uptrend with re-accumulation phases)",
|
||||
" □ Distribution (PSY → BC → AR → ST → UTAD → LPSY → SOW)",
|
||||
" □ Markdown (Downtrend with re-distribution phases)",
|
||||
"",
|
||||
"2. VOLUME ANALYSIS",
|
||||
" □ High volume on spring = institutional buying",
|
||||
" □ Low volume on test = supply absorbed",
|
||||
" □ High volume on UTAD = distribution warning",
|
||||
" □ Effort vs Result: High volume + small range = absorption",
|
||||
"",
|
||||
"3. SCHEMATIC ANALYSIS",
|
||||
" □ Preliminary Support (PS) - first sign of buying",
|
||||
" □ Selling Climax (SC) - panic selling, widest spread",
|
||||
" □ Automatic Rally (AR) - relief bounce",
|
||||
" □ Secondary Test (ST) - tests SC low on lower volume",
|
||||
" □ Spring - traps sellers, stops below support",
|
||||
" □ Sign of Strength (SOS) - decisive move up",
|
||||
" □ Last Point of Support (LPS) - final buy opportunity",
|
||||
"",
|
||||
"4. CAUSE & EFFECT",
|
||||
f" □ Trading Range: ~${range_size:.2f}",
|
||||
f" □ Measured Move: ~${range_size * 2:.2f}",
|
||||
" □ Count: Accumulation time predicts markup distance"
|
||||
],
|
||||
"entry_strategies": {
|
||||
"accumulation_phase": {
|
||||
"entry_point": "After spring, on LPS (Last Point of Support)",
|
||||
"confirmation": "Volume decrease on pullback, increase on SOS",
|
||||
"entry_price": f"${current_price - (range_size * 0.3):.2f}",
|
||||
"stop_loss": f"${current_price - (range_size * 0.6):.2f}",
|
||||
"target": f"${current_price + (range_size * 1.5):.2f}",
|
||||
"holding_period": "Days to weeks"
|
||||
},
|
||||
"distribution_phase": {
|
||||
"entry_point": "After UTAD (Upthrust After Distribution)",
|
||||
"confirmation": "High volume on weakness, low volume on strength",
|
||||
"entry_price": f"${current_price + (range_size * 0.3):.2f}",
|
||||
"stop_loss": f"${current_price + (range_size * 0.6):.2f}",
|
||||
"target": f"${current_price - (range_size * 1.5):.2f}",
|
||||
"holding_period": "Days to weeks"
|
||||
}
|
||||
},
|
||||
"volume_spread_analysis": [
|
||||
"VSA SIGNALS TO WATCH:",
|
||||
"• No Supply: Up bar, narrow spread, low volume = bullish",
|
||||
"• No Demand: Down bar, narrow spread, low volume = bearish",
|
||||
"• Stopping Volume: Down bar, wide spread, high volume = bottom",
|
||||
"• Climax: Wide spread, very high volume = exhaustion",
|
||||
"• Test: Down bar, narrow spread, low volume after climax = bullish",
|
||||
"• Weakness: Up bar, wide spread, low volume = top forming"
|
||||
],
|
||||
"three_laws": [
|
||||
"1. LAW OF SUPPLY & DEMAND",
|
||||
" • High demand, low supply = prices rise",
|
||||
" • Low demand, high supply = prices fall",
|
||||
"",
|
||||
"2. LAW OF CAUSE & EFFECT",
|
||||
" • Larger accumulation = larger markup",
|
||||
" • Time in range predicts extent of move",
|
||||
"",
|
||||
"3. LAW OF EFFORT VS RESULT",
|
||||
" • High volume (effort) should produce price change (result)",
|
||||
" • Low volume (low effort) producing large moves = following smart money",
|
||||
" • High volume with no price change = absorption (distribution or accumulation)"
|
||||
],
|
||||
"max_trades": 1, # Wyckoff is patient, fewer trades
|
||||
"max_daily_loss": 200,
|
||||
"notes": [
|
||||
"📚 WYCKOFF WISDOM:",
|
||||
"• \"Determine the trend and trade with it, not against it\"",
|
||||
"• \"Wait for the right moment, then strike with force\"",
|
||||
"• \"The market is controlled by the Composite Operator\"",
|
||||
"",
|
||||
"⏰ PATIENCE IS KEY:",
|
||||
"• Full Wyckoff cycle can take weeks or months",
|
||||
"• Don't rush - wait for clear phases",
|
||||
"• Best entries: After spring or after UTAD",
|
||||
"",
|
||||
"📊 CHART READING:",
|
||||
"• Use 4H and Daily charts for phase identification",
|
||||
"• Use 1H for entry timing",
|
||||
"• Volume is CRITICAL - without volume, it's not Wyckoff",
|
||||
"",
|
||||
"⚠️ WARNINGS:",
|
||||
"• Don't trade in middle of range (wait for edges)",
|
||||
"• Fake springs exist - wait for SOS confirmation",
|
||||
"• Not every range is Wyckoff - need volume characteristics"
|
||||
]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_multi_method_confluence_plan(
|
||||
current_price: float,
|
||||
market_condition: MarketCondition
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate plan using multiple methodologies for maximum confluence"""
|
||||
|
||||
atr = current_price * 0.015
|
||||
|
||||
return {
|
||||
"plan_type": PlanType.SWING,
|
||||
"methodology": "Multi-Method Confluence (ICT + Fibonacci + S/D + Price Action)",
|
||||
"confluence_zones": [
|
||||
"ZONE IDENTIFICATION - ALL METHODS MUST ALIGN:",
|
||||
"",
|
||||
"1. SMART MONEY CONCEPTS:",
|
||||
" □ Fair Value Gap (FVG) or Order Block identified",
|
||||
" □ BOS or ChoCh confirmed",
|
||||
" □ Within discount zone (below 50% of range for buys)",
|
||||
"",
|
||||
"2. FIBONACCI ANALYSIS:",
|
||||
" □ 0.618 or 0.786 retracement level",
|
||||
" □ Previous swing low to swing high measured",
|
||||
" □ Fib level aligns with FVG/OB zone",
|
||||
"",
|
||||
"3. SUPPLY & DEMAND:",
|
||||
" □ Fresh demand zone (for buys) or supply zone (for sells)",
|
||||
" □ Rally-Base-Rally or Drop-Base-Drop pattern",
|
||||
" □ Zone not tested more than once",
|
||||
"",
|
||||
"4. PRICE ACTION:",
|
||||
" □ Support/Resistance level confirmed",
|
||||
" □ Pin bar, engulfing, or inside bar at level",
|
||||
" □ Structure break and retest",
|
||||
"",
|
||||
"✅ REQUIRED CONFLUENCE: Minimum 3 out of 4 methods confirming same zone"
|
||||
],
|
||||
"setup_requirements": {
|
||||
"maximum_confluence": {
|
||||
"description": "All 4 methods agree - highest probability",
|
||||
"requirements": [
|
||||
"FVG/Order Block present",
|
||||
"0.618-0.786 Fibonacci level",
|
||||
"Fresh S/D zone",
|
||||
"Key S/R level + candlestick pattern"
|
||||
],
|
||||
"example_entry": f"${current_price - (atr * 0.8):.2f}",
|
||||
"example_stop": f"${current_price - (atr * 1.3):.2f}",
|
||||
"example_target": f"${current_price + (atr * 2.5):.2f}",
|
||||
"position_size": "Full size (2-3% risk)",
|
||||
"win_rate": "70-80%",
|
||||
"rr_ratio": "1:3 minimum"
|
||||
},
|
||||
"high_confluence": {
|
||||
"description": "3 out of 4 methods agree",
|
||||
"requirements": [
|
||||
"Any 3 methods confirming same zone",
|
||||
"Timeframe confluence (HTF + LTF alignment)"
|
||||
],
|
||||
"position_size": "75% of full size",
|
||||
"win_rate": "65-75%",
|
||||
"rr_ratio": "1:2.5 minimum"
|
||||
},
|
||||
"moderate_confluence": {
|
||||
"description": "2 out of 4 methods - avoid or very small size",
|
||||
"recommendation": "Skip unless highly experienced",
|
||||
"position_size": "25% if taken",
|
||||
"win_rate": "55-65%"
|
||||
}
|
||||
},
|
||||
"step_by_step_process": [
|
||||
"STEP 1: MULTI-TIMEFRAME ANALYSIS",
|
||||
"□ Monthly/Weekly: Identify major trend and key levels",
|
||||
"□ Daily: Mark swing highs/lows, draw Fibonacci",
|
||||
"□ 4H: Identify S/D zones, FVGs, Order Blocks",
|
||||
"□ 1H: Wait for price to approach confluence zone",
|
||||
"□ 15M: Look for entry trigger (candlestick pattern)",
|
||||
"",
|
||||
"STEP 2: ZONE MARKING",
|
||||
"□ Mark all FVGs and Order Blocks (ICT)",
|
||||
"□ Draw Fibonacci from last major swing (0.382, 0.5, 0.618, 0.786)",
|
||||
"□ Identify fresh S/D zones (Supply/Demand)",
|
||||
"□ Mark key horizontal S/R levels (Price Action)",
|
||||
"□ Highlight zones where 3-4 methods overlap",
|
||||
"",
|
||||
"STEP 3: CONFLUENCE VERIFICATION",
|
||||
f"□ Price approaches confluence zone: ${current_price - atr:.2f} - ${current_price - (atr * 0.6):.2f}",
|
||||
"□ Verify zone freshness (not tested multiple times)",
|
||||
"□ Check session timing (prefer London/NY for gold)",
|
||||
"□ Assess market condition (avoid choppy, low volume periods)",
|
||||
"",
|
||||
"STEP 4: ENTRY TRIGGER",
|
||||
"□ Wait for price to enter confluence zone",
|
||||
"□ Look for rejection: Pin bar, engulfing pattern, or inside bar",
|
||||
"□ Can use limit order at zone OR wait for confirmation",
|
||||
"□ Entry preference: Confirmation candle (safer) vs limit (better RR)",
|
||||
"",
|
||||
"STEP 5: TRADE MANAGEMENT",
|
||||
"□ Stop loss: 5-10 points beyond zone (below/above all confluence factors)",
|
||||
"□ Target 1 (50%): Next FVG, S/D zone, or Fib extension (1.272)",
|
||||
"□ Target 2 (50%): Major structure, opposite liquidity, or Fib 1.618",
|
||||
"□ Trail stop: Use ATR-based trail or move to break-even after T1",
|
||||
"",
|
||||
"STEP 6: POST-TRADE REVIEW",
|
||||
"□ Did all methods confirm?",
|
||||
"□ What was win rate for this confluence setup?",
|
||||
"□ Note for future: Which method was strongest predictor?",
|
||||
"□ Journal: Screenshot setup and outcome"
|
||||
],
|
||||
"example_bullish_trade": {
|
||||
"scenario": "Bullish confluence zone setup",
|
||||
"confluence_zone": f"${current_price - (atr * 0.9):.2f} - ${current_price - (atr * 0.7):.2f}",
|
||||
"methods_confirming": [
|
||||
f"✓ Bullish FVG at ${current_price - (atr * 0.8):.2f}",
|
||||
f"✓ 0.618 Fib retracement at ${current_price - (atr * 0.75):.2f}",
|
||||
f"✓ Fresh demand zone from ${current_price - (atr * 0.9):.2f} to ${current_price - (atr * 0.7):.2f}",
|
||||
f"✓ Daily support level at ${current_price - (atr * 0.8):.2f}"
|
||||
],
|
||||
"entry": f"${current_price - (atr * 0.75):.2f} (limit order in zone OR on pin bar confirmation)",
|
||||
"stop_loss": f"${current_price - (atr * 1.3):.2f} (below all confluence factors)",
|
||||
"target_1": f"${current_price + (atr * 0.5):.2f} (next minor resistance/FVG)",
|
||||
"target_2": f"${current_price + (atr * 2.0):.2f} (major structure/opposite S/D zone)",
|
||||
"risk_reward": "1:3.5",
|
||||
"position_management": "Close 50% at T1, trail remaining 50% with ATR(14) * 1.5"
|
||||
},
|
||||
"max_trades": 2,
|
||||
"max_daily_loss": 300,
|
||||
"notes": [
|
||||
"🎯 CONFLUENCE TRADING RULES:",
|
||||
"• MINIMUM 3 methods must confirm same zone",
|
||||
"• More confluence = higher probability = larger position",
|
||||
"• Never force a trade - wait for perfect setup",
|
||||
"• These setups are rare (1-3 per week on gold) - be patient!",
|
||||
"",
|
||||
"⏰ TIMING:",
|
||||
"• Best during London/NY sessions (liquidity)",
|
||||
"• Avoid: Asian session, major news events, Friday afternoons",
|
||||
"• Prefer Monday-Thursday for best follow-through",
|
||||
"",
|
||||
"📊 EXPECTATION:",
|
||||
"• Win rate: 70-80% with proper confluence",
|
||||
"• Average RR: 1:3 to 1:5",
|
||||
"• Frequency: 1-3 high-quality setups per week",
|
||||
"• This is a QUALITY over quantity approach",
|
||||
"",
|
||||
"⚠️ DISCIPLINE CHECKLIST:",
|
||||
"• ❌ Don't trade without minimum 3-method confluence",
|
||||
"• ❌ Don't increase risk on 'gut feeling'",
|
||||
"• ❌ Don't chase price if it leaves the zone",
|
||||
"• ✅ Wait for price to return to confluence zone",
|
||||
"• ✅ Journal every setup (even if you don't take it)",
|
||||
"• ✅ Review weekly: Which confluences worked best?",
|
||||
"",
|
||||
"💎 PROFESSIONAL EDGE:",
|
||||
"• Institutions look for same confluences - you're trading WITH smart money",
|
||||
"• Multiple confirmations = reduced false signals",
|
||||
"• Patient traders win - this method rewards discipline",
|
||||
"• Track your confluence setups: Over time, you'll find your highest-probability patterns"
|
||||
]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_session_based_plan(
|
||||
current_price: float,
|
||||
target_session: str = "london_ny_overlap"
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate session-specific trading plan for gold"""
|
||||
|
||||
atr = current_price * 0.015
|
||||
|
||||
sessions = {
|
||||
"asian": {
|
||||
"time": "6 PM - 3 AM EST",
|
||||
"characteristics": "Low volatility, range-bound, choppy",
|
||||
"strategy": "Range trading or avoid",
|
||||
"avg_range": f"${atr * 0.5:.2f} - ${atr * 0.8:.2f}"
|
||||
},
|
||||
"london": {
|
||||
"time": "3 AM - 12 PM EST",
|
||||
"characteristics": "High volatility, trend moves, breakouts",
|
||||
"strategy": "Breakout or trend continuation",
|
||||
"avg_range": f"${atr * 1.2:.2f} - ${atr * 1.8:.2f}",
|
||||
"killzone": "3 AM - 5 AM EST"
|
||||
},
|
||||
"ny": {
|
||||
"time": "8 AM - 5 PM EST",
|
||||
"characteristics": "Highest volatility, strong directional moves",
|
||||
"strategy": "Continuation of London or reversal",
|
||||
"avg_range": f"${atr * 1.5:.2f} - ${atr * 2.0:.2f}",
|
||||
"killzone": "8 AM - 11 AM EST"
|
||||
},
|
||||
"london_ny_overlap": {
|
||||
"time": "8 AM - 12 PM EST",
|
||||
"characteristics": "Maximum liquidity, most volume, best opportunities",
|
||||
"strategy": "All strategies valid, highest probability",
|
||||
"avg_range": f"${atr * 1.8:.2f} - ${atr * 2.5:.2f}"
|
||||
}
|
||||
}
|
||||
|
||||
session_info = sessions.get(target_session, sessions["london_ny_overlap"])
|
||||
|
||||
return {
|
||||
"plan_type": PlanType.INTRADAY,
|
||||
"methodology": f"{target_session.upper().replace('_', ' ')} Session Trading",
|
||||
"session_details": session_info,
|
||||
"daily_playbook": [
|
||||
"GOLD TRADING SESSION PLAYBOOK:",
|
||||
"",
|
||||
"🌏 ASIAN SESSION (6 PM - 3 AM EST):",
|
||||
"• Price action: Consolidation, range-bound",
|
||||
"• Volume: Lowest of the day",
|
||||
"• Strategy: Mark Asian range high/low for breakouts",
|
||||
"• Approach: Generally avoid or trade mean reversion in range",
|
||||
f"• Expected range: {sessions['asian']['avg_range']}",
|
||||
"",
|
||||
"🇬🇧 LONDON SESSION (3 AM - 12 PM EST):",
|
||||
"• Price action: Breakouts, trend establishment",
|
||||
"• Volume: High (60% of daily gold volume)",
|
||||
"• Strategy: Trade breakouts of Asian range",
|
||||
"• Killzone: 3-5 AM EST (highest probability)",
|
||||
f"• Expected range: {sessions['london']['avg_range']}",
|
||||
"• Watch for: Judas Swing (false move 3-4 AM, real move 5-8 AM)",
|
||||
"",
|
||||
"🇺🇸 NY SESSION (8 AM - 5 PM EST):",
|
||||
"• Price action: Continuation or reversal",
|
||||
"• Volume: Highest (overlap with London 8 AM-12 PM)",
|
||||
"• Strategy: Follow London direction or trade reversals",
|
||||
"• Killzone: 8-11 AM EST (absolute best time)",
|
||||
f"• Expected range: {sessions['ny']['avg_range']}",
|
||||
"• Watch for: US economic data releases (8:30 AM, 10 AM)",
|
||||
"",
|
||||
"🏆 LONDON/NY OVERLAP (8 AM - 12 PM EST):",
|
||||
"• Price action: Maximum movement, strong trends",
|
||||
"• Volume: Peak liquidity",
|
||||
"• Strategy: ALL strategies valid, focus here",
|
||||
f"• Expected range: {sessions['london_ny_overlap']['avg_range']}",
|
||||
"• This is THE WINDOW for gold day trading"
|
||||
],
|
||||
"intraday_scenarios": {
|
||||
"scenario_1_breakout": {
|
||||
"name": "Asian Range Breakout (Most Common)",
|
||||
"setup": [
|
||||
"1. Mark Asian session high and low (6 PM - 3 AM)",
|
||||
f"2. Asian range: typically ${atr * 0.5:.2f} - ${atr * 0.8:.2f}",
|
||||
"3. Wait for London open (3 AM EST)",
|
||||
"4. Watch for breakout of range + close outside",
|
||||
"5. Enter on retest of broken level OR on break candle"
|
||||
],
|
||||
"entry_long": f"${current_price + (atr * 0.3):.2f} (break above Asian high)",
|
||||
"stop_long": f"${current_price - (atr * 0.4):.2f} (below Asian low)",
|
||||
"target_long": f"${current_price + (atr * 1.5):.2f} (1.5x Asian range)",
|
||||
"timing": "3-5 AM EST (London killzone)"
|
||||
},
|
||||
"scenario_2_judas_swing": {
|
||||
"name": "Judas Swing (ICT Concept)",
|
||||
"setup": [
|
||||
"1. London opens with move in one direction (3-4 AM)",
|
||||
"2. Move is FALSE - designed to trap traders",
|
||||
"3. Price reverses sharply (4-6 AM)",
|
||||
"4. Real move happens opposite to initial direction",
|
||||
"5. Enter on reversal confirmation"
|
||||
],
|
||||
"example": "Gold breaks up at 3 AM → Reverses down 4 AM → Continues down rest of session",
|
||||
"entry": "After reversal candle, when false high is broken back down",
|
||||
"stop": "Above false high + buffer",
|
||||
"target": f"${atr * 1.5:.2f} - ${atr * 2.0:.2f} move in true direction"
|
||||
},
|
||||
"scenario_3_continuation": {
|
||||
"name": "NY Continuation (Follows London)",
|
||||
"setup": [
|
||||
"1. London session establishes clear direction",
|
||||
"2. NY open (8 AM) continues same direction",
|
||||
"3. Pullback to FVG or Order Block during overlap",
|
||||
"4. Enter on continuation"
|
||||
],
|
||||
"entry": f"${current_price:.2f} (at pullback zone)",
|
||||
"stop": f"${current_price - (atr * 0.8):.2f} (beyond retracement)",
|
||||
"target": f"${current_price + (atr * 1.5):.2f} (session extension)",
|
||||
"timing": "8 AM - 11 AM EST"
|
||||
},
|
||||
"scenario_4_reversal": {
|
||||
"name": "NY Reversal (Opposite London)",
|
||||
"setup": [
|
||||
"1. London session exhausts in one direction",
|
||||
"2. Signs of exhaustion: Wicks, slowing momentum, volume decrease",
|
||||
"3. NY open triggers reversal",
|
||||
"4. Enter on confirmed reversal pattern"
|
||||
],
|
||||
"entry": f"${current_price:.2f} (on reversal candle close)",
|
||||
"stop": f"${current_price + (atr * 0.8):.2f} (beyond reversal level)",
|
||||
"target": f"${current_price - (atr * 1.5):.2f} (back to Asian range or key level)",
|
||||
"timing": "8 AM - 10 AM EST",
|
||||
"note": "Less common than continuation, wait for strong confirmation"
|
||||
}
|
||||
},
|
||||
"time_based_rules": [
|
||||
"⏰ TIME-BASED TRADING RULES:",
|
||||
"",
|
||||
"DO NOT TRADE:",
|
||||
"• Before 3 AM EST (Asian session - too choppy)",
|
||||
"• After 12 PM EST (liquidity dries up, whipsaws increase)",
|
||||
"• During major US news releases (wait 15-30 min after)",
|
||||
"• Friday after 10 AM EST (early close, low volume)",
|
||||
"",
|
||||
"BEST TRADING WINDOWS:",
|
||||
"• 3-5 AM EST: London killzone (breakouts)",
|
||||
"• 8-11 AM EST: NY killzone (strongest moves)",
|
||||
"• 8-10 AM EST: Absolute prime time (London/NY overlap peak)",
|
||||
"",
|
||||
"VOLUME PROFILE:",
|
||||
"• 3-8 AM: Building volume, establishing direction",
|
||||
"• 8-11 AM: Peak volume, maximum movement",
|
||||
"• 11 AM-12 PM: Reduced volatility, range trading",
|
||||
"• After 12 PM: Avoid or tight ranges only"
|
||||
],
|
||||
"daily_routine": [
|
||||
"📋 SESSION TRADER DAILY ROUTINE:",
|
||||
"",
|
||||
"2:30 AM EST - Pre-London Preparation:",
|
||||
"□ Review overnight news and economic calendar",
|
||||
"□ Mark Asian session high/low",
|
||||
"□ Identify key levels from previous day",
|
||||
"□ Check DXY, yields, and market correlations",
|
||||
"□ Plan: What will you do if price breaks up? Breaks down?",
|
||||
"",
|
||||
"3:00 AM EST - London Open:",
|
||||
"□ Watch for initial direction",
|
||||
"□ Is it breaking Asian range or staying within?",
|
||||
"□ Look for Judas Swing setup (false move)",
|
||||
"□ Mark any FVGs or Order Blocks forming",
|
||||
"",
|
||||
"7:30 AM EST - Pre-NY Prep:",
|
||||
"□ Assess London session direction (up/down/ranging)",
|
||||
"□ Check for US economic releases at 8:30 AM",
|
||||
"□ Identify: Will NY continue or reverse?",
|
||||
"□ Plan entry zones for both scenarios",
|
||||
"",
|
||||
"8:00 AM EST - NY Open (Prime Time):",
|
||||
"□ Execute plan based on setup",
|
||||
"□ Take trades ONLY if setup is perfect",
|
||||
"□ Maximum 2 trades during this window",
|
||||
"□ Focus on quality over quantity",
|
||||
"",
|
||||
"11:00 AM EST - Session Wind-Down:",
|
||||
"□ Close or protect any open positions",
|
||||
"□ Move stops to break-even minimum",
|
||||
"□ Avoid new entries after 11 AM",
|
||||
"",
|
||||
"12:00 PM EST - Day Complete:",
|
||||
"□ Close all positions or trail stops",
|
||||
"□ Journal trades and setups",
|
||||
"□ No more trading for the day - walk away",
|
||||
"□ Review: What worked? What didn't?"
|
||||
],
|
||||
"max_trades": 3,
|
||||
"max_daily_loss": 250,
|
||||
"notes": [
|
||||
"🌟 SESSION TRADING WISDOM:",
|
||||
"",
|
||||
"• \"The best trades happen in the first 3 hours of London and NY sessions\"",
|
||||
"• \"Asian session is for planning, not trading (for most retail traders)\"",
|
||||
"• \"The Judas Swing is real - London often fakes a move before the real direction\"",
|
||||
"• \"When London and NY agree on direction, moves are powerful\"",
|
||||
"",
|
||||
"📊 STATISTICS (Approximate for Gold):",
|
||||
"• 60% of daily range happens during London session",
|
||||
"• 30% happens during NY session",
|
||||
"• 10% happens during Asian session",
|
||||
"• Highest probability trades: 8-10 AM EST (80%+ of best setups)",
|
||||
"",
|
||||
"⚠️ COMMON MISTAKES:",
|
||||
"• Trading too early (before 3 AM EST)",
|
||||
"• Trading too late (after 12 PM EST)",
|
||||
"• Not respecting the Judas Swing (getting trapped)",
|
||||
"• Overtrading during low-probability times",
|
||||
"• Ignoring session characteristics (trying to breakout trade in Asian session)",
|
||||
"",
|
||||
"💡 PRO TIPS:",
|
||||
"• Set alarms: 2:45 AM (London prep), 7:45 AM (NY prep)",
|
||||
"• Most profitable gold traders trade ONLY 8-11 AM EST",
|
||||
"• If you miss the killzones, skip the day (there's always tomorrow)",
|
||||
"• Friday: Close all positions by 10 AM EST, weekend risk not worth it"
|
||||
]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_all_plan_types() -> Dict[str, str]:
|
||||
"""Get all available plan types"""
|
||||
return {
|
||||
"ict_smc": "ICT / Smart Money Concepts",
|
||||
"wyckoff": "Wyckoff Method",
|
||||
"elliott_wave": "Elliott Wave Theory",
|
||||
"supply_demand": "Supply & Demand Zones",
|
||||
"fibonacci": "Fibonacci Trading",
|
||||
"multi_confluence": "Multi-Method Confluence",
|
||||
"session_trading": "London/NY Session Trading",
|
||||
"price_action": "Pure Price Action",
|
||||
"fundamental": "Fundamental Analysis",
|
||||
"scalping": "Scalping (1-5 min)",
|
||||
"swing": "Swing Trading (Days)",
|
||||
"position": "Position Trading (Weeks+)"
|
||||
}
|
||||
|
||||
|
||||
# Global instance
|
||||
plan_templates = PlanTemplates()
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Awaitable, Optional, Sequence
|
||||
|
||||
from app.schemas.schemas import PositionMetrics, PatternSignal
|
||||
from app.services.metals.bullionvault_service import get_bullionvault_gold_price
|
||||
from app.services.metals.gold_price_fetcher import gold_price_fetcher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PriceAnchorService:
|
||||
"""Rescales simulated metric snapshots to the live gold price feed."""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 30) -> None:
|
||||
self._ttl = ttl_seconds
|
||||
self._cache_price: Optional[float] = None
|
||||
self._cache_ts: float = 0.0
|
||||
|
||||
async def get_anchor_price(self, symbol: str = "XAUUSD") -> Optional[float]:
|
||||
now = time.time()
|
||||
if self._cache_price and (now - self._cache_ts) < self._ttl:
|
||||
return self._cache_price
|
||||
|
||||
fetchers: Sequence[Callable[[], Awaitable[Optional[float]]]] = (
|
||||
self._get_bullionvault_price,
|
||||
self._get_fallback_price,
|
||||
)
|
||||
for fetch in fetchers:
|
||||
try:
|
||||
price = await fetch()
|
||||
except Exception as exc: # pragma: no cover - best effort logging only
|
||||
logger.warning("Price anchor fetch failed: %s", exc)
|
||||
continue
|
||||
if price and price > 0:
|
||||
self._cache_price = float(price)
|
||||
self._cache_ts = now
|
||||
return self._cache_price
|
||||
return self._cache_price
|
||||
|
||||
def get_anchor_price_sync(self, symbol: str = "XAUUSD") -> Optional[float]:
|
||||
"""Synchronous version that returns cached price only"""
|
||||
now = time.time()
|
||||
if self._cache_price and (now - self._cache_ts) < self._ttl:
|
||||
return self._cache_price
|
||||
return self._cache_price
|
||||
|
||||
async def _get_bullionvault_price(self) -> Optional[float]:
|
||||
data = await get_bullionvault_gold_price("USD")
|
||||
return float(data["price"]) if data and data.get("price") else None
|
||||
|
||||
async def _get_fallback_price(self) -> Optional[float]:
|
||||
data = await gold_price_fetcher.get_current_gold_price()
|
||||
return float(data["price"]) if data and data.get("price") else None
|
||||
|
||||
def apply_anchor(self, metrics: PositionMetrics, anchor_price: Optional[float]) -> PositionMetrics:
|
||||
if not anchor_price or metrics.current_price <= 0:
|
||||
return metrics
|
||||
|
||||
scale = anchor_price / metrics.current_price
|
||||
if abs(scale - 1.0) < 0.005:
|
||||
# Already close enough to the anchor, skip unnecessary work
|
||||
return metrics
|
||||
|
||||
if not 0.2 <= scale <= 5:
|
||||
logger.warning("Skipping unrealistic price anchor scaling (scale=%.4f)", scale)
|
||||
return metrics
|
||||
|
||||
scaled = metrics.model_copy(deep=True)
|
||||
|
||||
def scale_value(value: Optional[float], decimals: int = 4) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
return round(value * scale, decimals)
|
||||
|
||||
def scale_list(values: list[float]) -> list[float]:
|
||||
return [round(v * scale, 2) for v in values]
|
||||
|
||||
scaled.current_price = round(anchor_price, 2)
|
||||
scaled.previous_close = scale_value(scaled.previous_close, 2)
|
||||
scaled.high = scale_value(scaled.high, 2)
|
||||
scaled.low = scale_value(scaled.low, 2)
|
||||
scaled.atr14 = scale_value(scaled.atr14)
|
||||
scaled.ema21 = scale_value(scaled.ema21)
|
||||
scaled.sma55 = scale_value(scaled.sma55)
|
||||
scaled.sma100 = scale_value(scaled.sma100)
|
||||
scaled.sma200 = scale_value(scaled.sma200)
|
||||
scaled.bb_basis = scale_value(scaled.bb_basis)
|
||||
scaled.bb_upper = scale_value(scaled.bb_upper)
|
||||
scaled.bb_lower = scale_value(scaled.bb_lower)
|
||||
scaled.zlsma = scale_value(scaled.zlsma)
|
||||
scaled.chandelier_long_stop = scale_value(scaled.chandelier_long_stop, 2)
|
||||
scaled.chandelier_short_stop = scale_value(scaled.chandelier_short_stop, 2)
|
||||
scaled.momentum12 = scale_value(scaled.momentum12)
|
||||
scaled.support_levels = scale_list(scaled.support_levels)
|
||||
scaled.resistance_levels = scale_list(scaled.resistance_levels)
|
||||
|
||||
scaled.pattern_signals = [
|
||||
signal.model_copy(update={"price": scale_value(signal.price, 2)})
|
||||
for signal in scaled.pattern_signals
|
||||
]
|
||||
|
||||
if scaled.previous_close is not None:
|
||||
scaled.change = round(scaled.current_price - scaled.previous_close, 4)
|
||||
if scaled.previous_close:
|
||||
scaled.change_percent = round((scaled.change / scaled.previous_close) * 100, 4)
|
||||
else:
|
||||
scaled.change = scale_value(scaled.change)
|
||||
if scaled.previous_close:
|
||||
scaled.change_percent = round((scaled.change or 0.0) / scaled.previous_close * 100, 4)
|
||||
|
||||
return scaled
|
||||
|
||||
|
||||
price_anchor_service = PriceAnchorService()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Helpers for loading the latest trading simulation snapshot from the database."""
|
||||
from typing import Any, Dict
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.trading_persistent import get_or_create_simulation, get_portfolio_state_from_db
|
||||
|
||||
|
||||
def load_simulation_state(db: Session, user_id: str = "default") -> Dict[str, Any]:
|
||||
"""Return the current simulation state as a serializable dict."""
|
||||
try:
|
||||
simulation = get_or_create_simulation(db, user_id)
|
||||
portfolio = get_portfolio_state_from_db(simulation, db)
|
||||
return portfolio.dict()
|
||||
except Exception as e:
|
||||
# If database tables don't exist, return default state
|
||||
print(f"Warning: Could not load simulation state: {e}")
|
||||
return {
|
||||
"cash": 100000.0,
|
||||
"position": None,
|
||||
"trades": [],
|
||||
"total_pnl": 0.0,
|
||||
"win_rate": 0.0,
|
||||
"avg_win": 0.0,
|
||||
"avg_loss": 0.0,
|
||||
"trade_count": 0
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
"""
|
||||
Trading Schools & Methodologies
|
||||
Comprehensive collection of trading approaches combining different schools of thought
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TradingSchool(str, Enum):
|
||||
"""Major trading methodologies and schools"""
|
||||
ICT = "ict" # Inner Circle Trader / Smart Money Concepts
|
||||
WYCKOFF = "wyckoff" # Wyckoff Method
|
||||
ELLIOTT_WAVE = "elliott_wave" # Elliott Wave Theory
|
||||
MARKET_PROFILE = "market_profile" # Market Profile / Volume Profile
|
||||
ORDER_FLOW = "order_flow" # Order Flow / Footprint
|
||||
PRICE_ACTION = "price_action" # Pure Price Action
|
||||
TECHNICAL_ANALYSIS = "technical_analysis" # Classical Technical Analysis
|
||||
SUPPLY_DEMAND = "supply_demand" # Supply & Demand Zones
|
||||
FIBONACCI = "fibonacci" # Fibonacci-based Trading
|
||||
FUNDAMENTAL = "fundamental" # Fundamental Analysis for Gold
|
||||
SENTIMENT = "sentiment" # Market Sentiment Analysis
|
||||
SEASONAL = "seasonal" # Seasonal Patterns
|
||||
INTERMARKET = "intermarket" # Intermarket Analysis
|
||||
|
||||
|
||||
class TradingStrategy:
|
||||
"""Base class for trading strategies"""
|
||||
|
||||
@staticmethod
|
||||
def get_all_schools() -> Dict[str, Dict[str, Any]]:
|
||||
"""Get comprehensive information about all trading schools"""
|
||||
return {
|
||||
"ict_smc": {
|
||||
"name": "ICT / Smart Money Concepts",
|
||||
"school": TradingSchool.ICT,
|
||||
"description": "Inner Circle Trader methodology focusing on institutional order flow, liquidity sweeps, and market structure",
|
||||
"key_concepts": [
|
||||
"Order Blocks (OB)",
|
||||
"Fair Value Gaps (FVG/Imbalance)",
|
||||
"Liquidity Voids",
|
||||
"Break of Structure (BOS)",
|
||||
"Change of Character (ChoCh)",
|
||||
"Displacement",
|
||||
"Premium/Discount Zones",
|
||||
"London/NY Killzones",
|
||||
"Judas Swing",
|
||||
"Optimal Trade Entry (OTE 0.618-0.79)",
|
||||
"Stop Hunt/Liquidity Grab",
|
||||
"Market Maker Model (Accumulation, Manipulation, Distribution)"
|
||||
],
|
||||
"timeframes": ["5m", "15m", "1h", "4h", "1D"],
|
||||
"indicators": [], # Pure price action, minimal indicators
|
||||
"best_for": ["Day trading", "Swing trading", "Gold/Forex"],
|
||||
"sessions": ["London (3-5 AM EST)", "NY (8-11 AM EST)"],
|
||||
"entry_criteria": [
|
||||
"Identify market structure (bullish/bearish)",
|
||||
"Wait for BOS or ChoCh",
|
||||
"Find FVG or Order Block",
|
||||
"Look for liquidity sweep",
|
||||
"Enter on retracement to OTE (0.618-0.79 Fib)",
|
||||
"Target opposite liquidity"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Above/below order block or FVG",
|
||||
"take_profit": "Opposite side liquidity, FVG, or major structure",
|
||||
"rr_ratio": "Minimum 1:2, typically 1:3+"
|
||||
}
|
||||
},
|
||||
"wyckoff": {
|
||||
"name": "Wyckoff Method",
|
||||
"school": TradingSchool.WYCKOFF,
|
||||
"description": "Volume-based methodology analyzing accumulation, distribution, and composite operator behavior",
|
||||
"key_concepts": [
|
||||
"Accumulation (Spring, Backup, SOS)",
|
||||
"Distribution (UTAD, SOW)",
|
||||
"Re-accumulation",
|
||||
"Re-distribution",
|
||||
"Cause and Effect",
|
||||
"Effort vs Result",
|
||||
"Composite Man/Operator",
|
||||
"Three Laws (Supply/Demand, Cause/Effect, Effort/Result)",
|
||||
"Volume Spread Analysis (VSA)",
|
||||
"Schematic Patterns (AR, ST, Creek, Spring)"
|
||||
],
|
||||
"timeframes": ["4h", "1D", "1W"],
|
||||
"indicators": ["Volume", "Volume Profile", "OBV"],
|
||||
"best_for": ["Position trading", "Swing trading"],
|
||||
"phases": ["Accumulation Phase", "Markup Phase", "Distribution Phase", "Markdown Phase"],
|
||||
"entry_criteria": [
|
||||
"Identify current phase",
|
||||
"Wait for spring (accumulation) or upthrust (distribution)",
|
||||
"Confirm with volume",
|
||||
"Enter on Sign of Strength (SOS) or Last Point of Support (LPS)",
|
||||
"Target: Measured move based on trading range"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Below spring or support area",
|
||||
"take_profit": "Measured move from accumulation range",
|
||||
"rr_ratio": "Minimum 1:2"
|
||||
}
|
||||
},
|
||||
"elliott_wave": {
|
||||
"name": "Elliott Wave Theory",
|
||||
"school": TradingSchool.ELLIOTT_WAVE,
|
||||
"description": "Fractal pattern analysis based on wave structures and Fibonacci relationships",
|
||||
"key_concepts": [
|
||||
"Impulse Waves (1-2-3-4-5)",
|
||||
"Corrective Waves (A-B-C)",
|
||||
"Wave Degrees (Grand Super Cycle to Sub-Minuette)",
|
||||
"Fibonacci Extensions (1.618, 2.618)",
|
||||
"Fibonacci Retracements (0.382, 0.5, 0.618)",
|
||||
"Wave Personality (Wave 3 strongest)",
|
||||
"Alternation Principle",
|
||||
"Channeling Techniques",
|
||||
"Wave Equality",
|
||||
"Ending Diagonals",
|
||||
"Leading Diagonals"
|
||||
],
|
||||
"timeframes": ["1h", "4h", "1D", "1W"],
|
||||
"indicators": ["Fibonacci", "EMA", "RSI for divergence"],
|
||||
"best_for": ["Swing trading", "Position trading"],
|
||||
"entry_criteria": [
|
||||
"Identify current wave structure",
|
||||
"Enter at wave 2 or 4 retracement (0.618)",
|
||||
"Enter at wave C completion (corrective)",
|
||||
"Confirm with volume and momentum",
|
||||
"Target: Wave 3 = 1.618x Wave 1, Wave 5 = Wave 1"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Below wave 1 start or key Fibonacci level",
|
||||
"take_profit": "Fibonacci extensions (1.618, 2.618)",
|
||||
"rr_ratio": "Minimum 1:3"
|
||||
}
|
||||
},
|
||||
"market_profile": {
|
||||
"name": "Market Profile / Volume Profile",
|
||||
"school": TradingSchool.MARKET_PROFILE,
|
||||
"description": "Time and volume-based analysis identifying value areas and market acceptance",
|
||||
"key_concepts": [
|
||||
"Point of Control (POC)",
|
||||
"Value Area (VA)",
|
||||
"Value Area High (VAH)",
|
||||
"Value Area Low (VAL)",
|
||||
"Initial Balance (IB)",
|
||||
"TPO (Time Price Opportunity)",
|
||||
"High Volume Nodes (HVN)",
|
||||
"Low Volume Nodes (LVN)",
|
||||
"Excess",
|
||||
"Poor Highs/Lows",
|
||||
"Single Prints",
|
||||
"Profiles (P-shaped, b-shaped, D-shaped)"
|
||||
],
|
||||
"timeframes": ["30m", "1h", "1D"],
|
||||
"indicators": ["Volume Profile", "VWAP", "Volume"],
|
||||
"best_for": ["Day trading", "Swing trading"],
|
||||
"entry_criteria": [
|
||||
"Identify POC and Value Area",
|
||||
"Enter at Value Area extremes (VAL/VAH)",
|
||||
"Trade rejections from LVN",
|
||||
"Target: Opposite side of value area or POC",
|
||||
"Look for acceptance/rejection at key levels"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Beyond value area or single prints",
|
||||
"take_profit": "POC, opposite VA extreme, or LVN",
|
||||
"rr_ratio": "Minimum 1:2"
|
||||
}
|
||||
},
|
||||
"order_flow": {
|
||||
"name": "Order Flow Trading",
|
||||
"school": TradingSchool.ORDER_FLOW,
|
||||
"description": "Real-time bid/ask analysis, footprint charts, and institutional order detection",
|
||||
"key_concepts": [
|
||||
"Delta (Buy - Sell volume)",
|
||||
"Cumulative Delta",
|
||||
"Volume Imbalance",
|
||||
"Absorption",
|
||||
"Stacked Imbalances",
|
||||
"Exhaustion",
|
||||
"Iceberg Orders",
|
||||
"Tape Reading",
|
||||
"Bid/Ask Ladder",
|
||||
"Footprint Charts",
|
||||
"Volume Clusters",
|
||||
"Unfinished Business"
|
||||
],
|
||||
"timeframes": ["1m", "5m", "15m"],
|
||||
"indicators": ["Delta", "Volume Profile", "Cumulative Delta"],
|
||||
"best_for": ["Scalping", "Day trading"],
|
||||
"entry_criteria": [
|
||||
"Identify delta divergence",
|
||||
"Look for absorption at key levels",
|
||||
"Watch for stacked imbalances",
|
||||
"Enter on confirmation of institutional flow",
|
||||
"Target: Next volume cluster or imbalance"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Tight stops beyond absorption zone",
|
||||
"take_profit": "Volume imbalance fill or delta reversal",
|
||||
"rr_ratio": "Minimum 1:1.5 (high win rate strategy)"
|
||||
}
|
||||
},
|
||||
"price_action": {
|
||||
"name": "Pure Price Action",
|
||||
"school": TradingSchool.PRICE_ACTION,
|
||||
"description": "Trading based solely on candlestick patterns, support/resistance, and market structure",
|
||||
"key_concepts": [
|
||||
"Support and Resistance",
|
||||
"Trend Lines",
|
||||
"Horizontal Levels",
|
||||
"Higher Highs / Higher Lows (HH/HL)",
|
||||
"Lower Highs / Lower Lows (LH/LL)",
|
||||
"Pin Bars",
|
||||
"Inside Bars",
|
||||
"Outside Bars",
|
||||
"Engulfing Patterns",
|
||||
"Double Tops/Bottoms",
|
||||
"Head & Shoulders",
|
||||
"Triangles, Flags, Pennants",
|
||||
"Break and Retest"
|
||||
],
|
||||
"timeframes": ["15m", "1h", "4h", "1D"],
|
||||
"indicators": [], # None, pure price action
|
||||
"best_for": ["All trading styles"],
|
||||
"entry_criteria": [
|
||||
"Identify trend and structure",
|
||||
"Wait for pattern formation at key level",
|
||||
"Enter on confirmation candle",
|
||||
"Target: Next major S/R level",
|
||||
"Look for confluence of multiple factors"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Beyond pattern or S/R level",
|
||||
"take_profit": "Risk-reward based on structure",
|
||||
"rr_ratio": "Minimum 1:2"
|
||||
}
|
||||
},
|
||||
"supply_demand": {
|
||||
"name": "Supply & Demand Zones",
|
||||
"school": TradingSchool.SUPPLY_DEMAND,
|
||||
"description": "Zone-based trading focusing on areas of institutional activity and imbalance",
|
||||
"key_concepts": [
|
||||
"Demand Zones (buying pressure)",
|
||||
"Supply Zones (selling pressure)",
|
||||
"Fresh Zones (untested)",
|
||||
"Tested Zones (touched once)",
|
||||
"Rally-Base-Rally (RBR)",
|
||||
"Drop-Base-Drop (DBD)",
|
||||
"Rally-Base-Drop (RBD)",
|
||||
"Drop-Base-Rally (DBR)",
|
||||
"Flip Zones (S/D conversion)",
|
||||
"Strong Zones (sharp moves)",
|
||||
"Weak Zones (slow consolidation)"
|
||||
],
|
||||
"timeframes": ["15m", "1h", "4h", "1D"],
|
||||
"indicators": ["Minimal - sometimes volume"],
|
||||
"best_for": ["Day trading", "Swing trading"],
|
||||
"entry_criteria": [
|
||||
"Identify fresh demand/supply zones",
|
||||
"Wait for price to return to zone",
|
||||
"Enter on confirmation (pin bar, engulfing)",
|
||||
"Target: Opposite supply/demand zone",
|
||||
"Use limit orders in zone"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Beyond zone (few pips/points)",
|
||||
"take_profit": "Next major zone or measured move",
|
||||
"rr_ratio": "Minimum 1:3"
|
||||
}
|
||||
},
|
||||
"fibonacci_trading": {
|
||||
"name": "Fibonacci-Based Trading",
|
||||
"school": TradingSchool.FIBONACCI,
|
||||
"description": "Trading using Fibonacci ratios for retracements, extensions, and time analysis",
|
||||
"key_concepts": [
|
||||
"Fibonacci Retracement (0.236, 0.382, 0.5, 0.618, 0.786)",
|
||||
"Fibonacci Extension (1.272, 1.414, 1.618, 2.618)",
|
||||
"Fibonacci Fans",
|
||||
"Fibonacci Arcs",
|
||||
"Fibonacci Time Zones",
|
||||
"Golden Ratio (1.618)",
|
||||
"Confluence Zones",
|
||||
"AB=CD Pattern",
|
||||
"Gartley Patterns",
|
||||
"Harmonic Patterns (Bat, Butterfly, Crab)"
|
||||
],
|
||||
"timeframes": ["1h", "4h", "1D"],
|
||||
"indicators": ["Fibonacci tools", "RSI for confirmation"],
|
||||
"best_for": ["Swing trading", "Position trading"],
|
||||
"entry_criteria": [
|
||||
"Identify completed impulse move",
|
||||
"Draw Fibonacci from swing low to swing high (or vice versa)",
|
||||
"Wait for retracement to 0.618 or 0.786",
|
||||
"Confirm with candlestick pattern or indicator",
|
||||
"Target: Fibonacci extensions (1.618, 2.618)"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Beyond 0.786 or 1.0 level",
|
||||
"take_profit": "Fibonacci extensions",
|
||||
"rr_ratio": "Minimum 1:2"
|
||||
}
|
||||
},
|
||||
"gold_fundamental": {
|
||||
"name": "Gold Fundamental Analysis",
|
||||
"school": TradingSchool.FUNDAMENTAL,
|
||||
"description": "Trading gold based on macroeconomic factors and fundamental drivers",
|
||||
"key_concepts": [
|
||||
"US Dollar Strength (DXY inverse correlation)",
|
||||
"Real Interest Rates (negative = bullish gold)",
|
||||
"Inflation (CPI, PCE)",
|
||||
"Fed Policy (rate decisions, QE/QT)",
|
||||
"Geopolitical Tensions (safe haven)",
|
||||
"Central Bank Buying",
|
||||
"Bond Yields (10-year Treasury)",
|
||||
"Risk Sentiment (VIX, SPX correlation)",
|
||||
"Physical Demand (jewelry, industrial)",
|
||||
"Gold ETF Flows (GLD, IAU)",
|
||||
"Mining Production",
|
||||
"Seasonal Patterns (Indian wedding season)"
|
||||
],
|
||||
"timeframes": ["1D", "1W", "1M"],
|
||||
"indicators": ["DXY", "10Y Yield", "VIX", "Correlation analysis"],
|
||||
"best_for": ["Position trading", "Long-term investing"],
|
||||
"entry_criteria": [
|
||||
"Analyze macroeconomic backdrop",
|
||||
"USD weakness = gold strength",
|
||||
"Rising inflation + dovish Fed = bullish",
|
||||
"Geopolitical crisis = safe haven bid",
|
||||
"Technical confirmation on daily/weekly"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Based on technical structure",
|
||||
"take_profit": "Major psychological levels ($2000, $2100, etc.)",
|
||||
"rr_ratio": "Variable, often 1:3+"
|
||||
}
|
||||
},
|
||||
"multi_timeframe": {
|
||||
"name": "Multi-Timeframe Analysis",
|
||||
"school": TradingSchool.TECHNICAL_ANALYSIS,
|
||||
"description": "Top-down analysis using multiple timeframes for confluence",
|
||||
"key_concepts": [
|
||||
"Top-Down Approach (Monthly → Weekly → Daily → 4H → 1H)",
|
||||
"Timeframe Confluence",
|
||||
"Higher TF Trend",
|
||||
"Lower TF Entry",
|
||||
"Trend Alignment",
|
||||
"S/R Level Confluence",
|
||||
"3 Timeframe Rule",
|
||||
"Risk-On/Risk-Off Daily",
|
||||
"Bias from HTF, Entry from LTF"
|
||||
],
|
||||
"timeframes": ["1M", "1W", "1D", "4H", "1H", "15M"],
|
||||
"indicators": ["EMA 21/55/200", "RSI", "MACD"],
|
||||
"best_for": ["All trading styles"],
|
||||
"entry_criteria": [
|
||||
"Identify HTF trend (Daily/Weekly)",
|
||||
"Find HTF S/R levels",
|
||||
"Wait for retracement on MTF",
|
||||
"Enter on LTF confirmation",
|
||||
"All timeframes aligned"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Based on LTF structure",
|
||||
"take_profit": "HTF targets",
|
||||
"rr_ratio": "Minimum 1:3"
|
||||
}
|
||||
},
|
||||
"london_ny_session": {
|
||||
"name": "London/NY Session Trading",
|
||||
"school": TradingSchool.ICT,
|
||||
"description": "Trading based on major forex session characteristics and time-based patterns",
|
||||
"key_concepts": [
|
||||
"Asian Session (Low Volatility, Range)",
|
||||
"London Open (3 AM EST - High Volatility)",
|
||||
"London Killzone (2-5 AM EST)",
|
||||
"NY Open (8 AM EST - Highest Volatility)",
|
||||
"NY Killzone (8-11 AM EST)",
|
||||
"London/NY Overlap (8 AM-12 PM EST)",
|
||||
"Judas Swing (False move before real direction)",
|
||||
"London Close (12 PM EST)",
|
||||
"Asian Range Breakout",
|
||||
"Time-Based Entries"
|
||||
],
|
||||
"timeframes": ["5m", "15m", "1h"],
|
||||
"indicators": ["Minimal - ATR for volatility"],
|
||||
"best_for": ["Day trading gold/forex"],
|
||||
"entry_criteria": [
|
||||
"Identify Asian range",
|
||||
"Watch for London open breakout",
|
||||
"Fade false move (Judas Swing)",
|
||||
"Enter on true direction confirmation",
|
||||
"Most activity in London/NY killzones"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Opposite side of range or FVG",
|
||||
"take_profit": "Intraday targets, session highs/lows",
|
||||
"rr_ratio": "Minimum 1:2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_combined_strategies() -> Dict[str, Dict[str, Any]]:
|
||||
"""Get hybrid strategies combining multiple schools"""
|
||||
return {
|
||||
"smc_fibonacci": {
|
||||
"name": "SMC + Fibonacci Confluence",
|
||||
"schools": [TradingSchool.ICT, TradingSchool.FIBONACCI],
|
||||
"description": "Combine Smart Money Concepts with Fibonacci for high-probability entries",
|
||||
"setup": [
|
||||
"1. Identify market structure (BOS/ChoCh) using SMC",
|
||||
"2. Mark FVG and Order Blocks",
|
||||
"3. Draw Fibonacci from last swing low to swing high",
|
||||
"4. Look for confluence: FVG/OB + 0.618/0.79 Fib level",
|
||||
"5. Enter at confluence zone during killzone",
|
||||
"6. Target: Opposite liquidity + Fib extension"
|
||||
],
|
||||
"indicators": [],
|
||||
"timeframes": ["15m", "1h", "4h"],
|
||||
"win_rate": "65-75%",
|
||||
"rr_ratio": "1:3"
|
||||
},
|
||||
"wyckoff_vsa": {
|
||||
"name": "Wyckoff + Volume Spread Analysis",
|
||||
"schools": [TradingSchool.WYCKOFF, TradingSchool.ORDER_FLOW],
|
||||
"description": "Combine Wyckoff accumulation/distribution with volume analysis",
|
||||
"setup": [
|
||||
"1. Identify Wyckoff phase (Accumulation/Distribution)",
|
||||
"2. Look for spring or upthrust",
|
||||
"3. Confirm with volume: High volume on spring = bullish",
|
||||
"4. Check for effort vs result divergence",
|
||||
"5. Enter on LPS (Last Point of Support) or LPSY",
|
||||
"6. Target: Measured move from trading range"
|
||||
],
|
||||
"indicators": ["Volume", "Volume Profile", "OBV"],
|
||||
"timeframes": ["4h", "1D"],
|
||||
"win_rate": "60-70%",
|
||||
"rr_ratio": "1:3"
|
||||
},
|
||||
"elliott_fibonacci": {
|
||||
"name": "Elliott Wave + Fibonacci",
|
||||
"schools": [TradingSchool.ELLIOTT_WAVE, TradingSchool.FIBONACCI],
|
||||
"description": "Natural combination - Elliott Wave theory is based on Fibonacci",
|
||||
"setup": [
|
||||
"1. Count wave structure (Impulse 1-2-3-4-5)",
|
||||
"2. Wait for Wave 2 or 4 correction",
|
||||
"3. Fib retracement: Wave 2 = 0.618, Wave 4 = 0.382",
|
||||
"4. Enter at Fib level with confirmation",
|
||||
"5. Target: Wave 3 = 1.618x Wave 1, Wave 5 = Wave 1",
|
||||
"6. Use Fib extensions for profit targets"
|
||||
],
|
||||
"indicators": ["Fibonacci", "EMA 21/55", "RSI"],
|
||||
"timeframes": ["1h", "4h", "1D"],
|
||||
"win_rate": "60-70%",
|
||||
"rr_ratio": "1:3"
|
||||
},
|
||||
"supply_demand_session": {
|
||||
"name": "Supply/Demand + Session Trading",
|
||||
"schools": [TradingSchool.SUPPLY_DEMAND, TradingSchool.ICT],
|
||||
"description": "Trade fresh S/D zones during high-liquidity sessions",
|
||||
"setup": [
|
||||
"1. Mark fresh supply/demand zones on 4H/1D",
|
||||
"2. Wait for price to approach zone during killzone",
|
||||
"3. Enter on confirmation in London/NY session",
|
||||
"4. Higher probability during high-volume periods",
|
||||
"5. Target: Opposite zone or session high/low"
|
||||
],
|
||||
"indicators": ["Volume", "ATR"],
|
||||
"timeframes": ["15m", "1h", "4h"],
|
||||
"win_rate": "65-75%",
|
||||
"rr_ratio": "1:3"
|
||||
},
|
||||
"multi_method_confluence": {
|
||||
"name": "Multi-Method Confluence",
|
||||
"schools": [TradingSchool.ICT, TradingSchool.FIBONACCI, TradingSchool.SUPPLY_DEMAND, TradingSchool.PRICE_ACTION],
|
||||
"description": "Ultimate confluence: Multiple methodologies confirming same zone",
|
||||
"setup": [
|
||||
"1. Identify trend and structure (Price Action)",
|
||||
"2. Mark Supply/Demand zones",
|
||||
"3. Draw Fibonacci retracements",
|
||||
"4. Identify FVG and Order Blocks (SMC)",
|
||||
"5. Find confluence: All methods pointing to same zone",
|
||||
"6. Enter only at maximum confluence during killzone",
|
||||
"7. Target: Multiple method targets"
|
||||
],
|
||||
"indicators": [],
|
||||
"timeframes": ["15m", "1h", "4h"],
|
||||
"win_rate": "70-80%",
|
||||
"rr_ratio": "1:3+",
|
||||
"difficulty": "Advanced"
|
||||
},
|
||||
"fundamental_technical": {
|
||||
"name": "Fundamental + Technical Combo",
|
||||
"schools": [TradingSchool.FUNDAMENTAL, TradingSchool.TECHNICAL_ANALYSIS],
|
||||
"description": "Use fundamentals for bias, technicals for entry/exit",
|
||||
"setup": [
|
||||
"1. Analyze gold fundamentals (USD, rates, geopolitics)",
|
||||
"2. Determine fundamental bias (bullish/bearish)",
|
||||
"3. Wait for technical setup aligned with bias",
|
||||
"4. Use SMC, S/D, or Fibonacci for precise entry",
|
||||
"5. Enter with fundamental and technical confluence",
|
||||
"6. Hold longer-term positions"
|
||||
],
|
||||
"indicators": ["DXY", "10Y Yield", "EMA 50/200", "RSI"],
|
||||
"timeframes": ["1D", "1W"],
|
||||
"win_rate": "65-75%",
|
||||
"rr_ratio": "1:4+",
|
||||
"holding_period": "Days to weeks"
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_indicator_presets_for_school(school: TradingSchool) -> Dict[str, Any]:
|
||||
"""Get recommended indicators for each trading school"""
|
||||
presets = {
|
||||
TradingSchool.ICT: {
|
||||
"indicators": [], # Pure price action
|
||||
"tools": ["Market Structure", "FVG Finder", "Order Block Detector"],
|
||||
"note": "ICT/SMC uses minimal to no indicators"
|
||||
},
|
||||
TradingSchool.WYCKOFF: {
|
||||
"indicators": ["Volume", "OBV", "Volume Profile"],
|
||||
"tools": ["Volume Spread Analysis"],
|
||||
"note": "Volume is critical for Wyckoff"
|
||||
},
|
||||
TradingSchool.ELLIOTT_WAVE: {
|
||||
"indicators": ["Fibonacci", "EMA 21", "EMA 55", "RSI"],
|
||||
"tools": ["Wave Counter", "Fibonacci Extensions"],
|
||||
"note": "Fibonacci is integral to Elliott Wave"
|
||||
},
|
||||
TradingSchool.MARKET_PROFILE: {
|
||||
"indicators": ["Volume Profile", "VWAP", "Volume"],
|
||||
"tools": ["TPO Chart", "Value Area Calculation"],
|
||||
"note": "Time and volume distribution is key"
|
||||
},
|
||||
TradingSchool.ORDER_FLOW: {
|
||||
"indicators": ["Delta", "Cumulative Delta", "Volume"],
|
||||
"tools": ["Footprint Chart", "Bid/Ask Ladder", "Order Book"],
|
||||
"note": "Requires specialized order flow tools"
|
||||
},
|
||||
TradingSchool.PRICE_ACTION: {
|
||||
"indicators": [], # Minimal
|
||||
"tools": ["Candlestick Patterns", "S/R Levels", "Trend Lines"],
|
||||
"note": "Pure price action, no indicators"
|
||||
},
|
||||
TradingSchool.SUPPLY_DEMAND: {
|
||||
"indicators": ["Volume (optional)"],
|
||||
"tools": ["Zone Drawer", "Base Identifier"],
|
||||
"note": "Zones are key, indicators optional"
|
||||
},
|
||||
TradingSchool.FIBONACCI: {
|
||||
"indicators": ["Fibonacci Retracement", "Fibonacci Extension", "RSI", "MACD"],
|
||||
"tools": ["Fib Tools", "Harmonic Pattern Scanner"],
|
||||
"note": "Fibonacci levels are primary tool"
|
||||
},
|
||||
TradingSchool.FUNDAMENTAL: {
|
||||
"indicators": ["DXY", "10Y Yield", "VIX", "Correlation Heatmap"],
|
||||
"tools": ["Economic Calendar", "Central Bank Tracker"],
|
||||
"note": "Macro analysis is primary, technicals for timing"
|
||||
},
|
||||
TradingSchool.TECHNICAL_ANALYSIS: {
|
||||
"indicators": ["EMA 21/55/200", "RSI 14", "MACD", "BB 20", "ATR 14"],
|
||||
"tools": ["Multi-Timeframe Analysis"],
|
||||
"note": "Classic indicator suite"
|
||||
}
|
||||
}
|
||||
return presets.get(school, {})
|
||||
|
||||
@staticmethod
|
||||
def get_risk_models() -> Dict[str, Dict[str, Any]]:
|
||||
"""Advanced risk management models"""
|
||||
return {
|
||||
"kelly_criterion": {
|
||||
"name": "Kelly Criterion Position Sizing",
|
||||
"formula": "f* = (bp - q) / b",
|
||||
"variables": {
|
||||
"f*": "Fraction of capital to risk",
|
||||
"b": "Odds received (reward:risk ratio - 1)",
|
||||
"p": "Probability of winning",
|
||||
"q": "Probability of losing (1 - p)"
|
||||
},
|
||||
"example": {
|
||||
"win_rate": 0.60,
|
||||
"rr_ratio": 2.0,
|
||||
"calculation": "f* = (2 * 0.60 - 0.40) / 2 = 0.40 or 40%",
|
||||
"recommended": "Use half-Kelly (20%) for safety"
|
||||
},
|
||||
"best_for": "High win rate, consistent strategies"
|
||||
},
|
||||
"fixed_fractional": {
|
||||
"name": "Fixed Fractional Risk",
|
||||
"description": "Risk fixed percentage of capital per trade",
|
||||
"recommended": {
|
||||
"conservative": "1-2% per trade",
|
||||
"moderate": "2-3% per trade",
|
||||
"aggressive": "3-5% per trade"
|
||||
},
|
||||
"best_for": "All traders, most reliable method"
|
||||
},
|
||||
"volatility_based": {
|
||||
"name": "ATR-Based Position Sizing",
|
||||
"description": "Adjust position size based on market volatility",
|
||||
"formula": "Position Size = (Account Risk $) / (ATR * Multiplier)",
|
||||
"example": {
|
||||
"account": 100000,
|
||||
"risk_pct": 0.02,
|
||||
"atr": 15.0,
|
||||
"multiplier": 1.5,
|
||||
"position_size": "(100000 * 0.02) / (15 * 1.5) = 88.89 units"
|
||||
},
|
||||
"best_for": "Volatility-sensitive strategies"
|
||||
},
|
||||
"time_based": {
|
||||
"name": "Time-Based Risk Adjustment",
|
||||
"description": "Reduce risk during low liquidity or high event risk",
|
||||
"rules": {
|
||||
"normal_hours": "Full position size",
|
||||
"low_liquidity": "50% position size",
|
||||
"news_events": "25% position size or avoid",
|
||||
"weekend_gaps": "Reduced or no overnight positions"
|
||||
},
|
||||
"best_for": "Day traders, news-sensitive markets"
|
||||
},
|
||||
"correlation_based": {
|
||||
"name": "Correlation-Adjusted Risk",
|
||||
"description": "Account for correlated positions",
|
||||
"rules": {
|
||||
"uncorrelated": "Full risk per position",
|
||||
"low_correlation": "75% risk adjustment",
|
||||
"high_correlation": "50% risk adjustment",
|
||||
"perfect_correlation": "Count as one position"
|
||||
},
|
||||
"example": "Gold + Silver high correlation → reduce combined risk",
|
||||
"best_for": "Multi-asset traders"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Global instance
|
||||
trading_schools = TradingStrategy()
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Iterable, Awaitable
|
||||
|
||||
from app.config import settings
|
||||
from app.streaming.binance_hub import hub as binance_hub
|
||||
from app.streaming.data_provider import data_provider
|
||||
|
||||
|
||||
async def bootstrap_streams() -> None:
|
||||
"""Ensure configured streams are hot even before clients connect."""
|
||||
if not settings.STREAM_AUTO_BOOTSTRAP:
|
||||
return
|
||||
|
||||
symbols: Iterable[str] = settings.STREAM_WARM_SYMBOLS or []
|
||||
timeframe = settings.STREAM_WARM_TIMEFRAME or "1m"
|
||||
coros: list[Awaitable[None]] = []
|
||||
|
||||
for raw in symbols:
|
||||
sym = (raw or "").strip()
|
||||
if not sym:
|
||||
continue
|
||||
if sym.upper().startswith("XAU"):
|
||||
coros.append(data_provider.ensure_stream(sym, timeframe))
|
||||
else:
|
||||
coros.append(binance_hub.ensure_stream(sym, timeframe))
|
||||
|
||||
if coros:
|
||||
await asyncio.gather(*coros, return_exceptions=True)
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""CSV/Parquet replay feed.
|
||||
|
||||
Loads OHLCV data from disk and replays it into live_store at a configurable
|
||||
speed. Useful for offline demos or backtesting visualizations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Set, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.streaming.live_store import live_store
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CSVKey:
|
||||
symbol: str
|
||||
timeframe: str
|
||||
|
||||
|
||||
class CSVFeedProvider:
|
||||
def __init__(self, data_dir: str | Path | None = None) -> None:
|
||||
self._subs: Dict[CSVKey, Set[asyncio.Queue]] = {}
|
||||
self._tasks: Dict[CSVKey, asyncio.Task] = {}
|
||||
self._pinned: Set[CSVKey] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._data_dir = Path(data_dir or Path.cwd() / "data" / "parquet" / "live")
|
||||
self._speed = 1.0 # 1x realtime replay
|
||||
|
||||
def get_status(self) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for key, subs in self._subs.items():
|
||||
out.append(
|
||||
{
|
||||
"symbol": key.symbol,
|
||||
"timeframe": key.timeframe,
|
||||
"subscribers": len(subs),
|
||||
"source": "csv_replay",
|
||||
"data_dir": str(self._data_dir),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
async def subscribe(self, symbol: str, timeframe: str = "1m") -> Tuple[asyncio.Queue, Any]:
|
||||
key = CSVKey(symbol.upper().replace("/", ""), timeframe)
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
async with self._lock:
|
||||
subs = self._subs.setdefault(key, set())
|
||||
subs.add(queue)
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_replay(key))
|
||||
|
||||
async def _unsubscribe() -> None:
|
||||
async with self._lock:
|
||||
s = self._subs.get(key)
|
||||
if s and queue in s:
|
||||
s.remove(queue)
|
||||
try:
|
||||
queue.put_nowait(None)
|
||||
except Exception:
|
||||
pass
|
||||
if s and len(s) == 0 and key not in self._pinned:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
self._subs.pop(key, None)
|
||||
|
||||
return queue, _unsubscribe
|
||||
|
||||
async def ensure_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
key = CSVKey(symbol.upper().replace("/", ""), timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.add(key)
|
||||
self._subs.setdefault(key, set())
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_replay(key))
|
||||
|
||||
def set_speed(self, speed: float) -> None:
|
||||
self._speed = max(0.1, speed)
|
||||
|
||||
async def _run_replay(self, key: CSVKey) -> None:
|
||||
file_path = self._resolve_file(key.symbol, key.timeframe)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Replay file not found: {file_path}")
|
||||
|
||||
df = self._load_file(file_path)
|
||||
for row in df.itertuples():
|
||||
evt = {
|
||||
"symbol": key.symbol,
|
||||
"timeframe": key.timeframe,
|
||||
"open_time": datetime.utcfromtimestamp(int(row.time)).isoformat(),
|
||||
"close_time": datetime.utcfromtimestamp(int(row.time)).isoformat(),
|
||||
"open": float(row.open),
|
||||
"high": float(row.high),
|
||||
"low": float(row.low),
|
||||
"close": float(row.close),
|
||||
"volume": float(getattr(row, "volume", 0.0)),
|
||||
"is_closed": True,
|
||||
"source": "csv_replay",
|
||||
}
|
||||
live_store.ingest_bar(
|
||||
symbol=key.symbol,
|
||||
timeframe=key.timeframe,
|
||||
bar={
|
||||
"time": int(row.time),
|
||||
"open": evt["open"],
|
||||
"high": evt["high"],
|
||||
"low": evt["low"],
|
||||
"close": evt["close"],
|
||||
"volume": evt["volume"],
|
||||
},
|
||||
)
|
||||
subs = self._subs.get(key) or set()
|
||||
for queue in list(subs):
|
||||
try:
|
||||
if queue.full():
|
||||
queue.get_nowait()
|
||||
queue.put_nowait(evt)
|
||||
except Exception:
|
||||
subs.discard(queue)
|
||||
await asyncio.sleep((60 / self._speed)) # default 1m bars -> 1 minute
|
||||
|
||||
def _resolve_file(self, symbol: str, timeframe: str) -> Path:
|
||||
filename = f"{symbol}_{timeframe}.parquet"
|
||||
return self._data_dir / filename
|
||||
|
||||
def _load_file(self, path: Path) -> pd.DataFrame:
|
||||
if path.suffix == ".csv":
|
||||
return pd.read_csv(path)
|
||||
return pd.read_parquet(path)
|
||||
|
||||
|
||||
csv_feed = CSVFeedProvider()
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config import settings
|
||||
from app.streaming.local_feed import local_feed
|
||||
from app.streaming.metatrader_feed import metatrader_feed
|
||||
from app.streaming.csv_feed import csv_feed
|
||||
from app.streaming.historical_replay import historical_replay
|
||||
|
||||
|
||||
# Registry for future providers. For now only the local simulator is available.
|
||||
_PROVIDER_REGISTRY = {
|
||||
"historical_replay": historical_replay,
|
||||
"local_simulator": local_feed,
|
||||
"metatrader": metatrader_feed,
|
||||
"csv_replay": csv_feed,
|
||||
}
|
||||
|
||||
provider_key = settings.DATA_PROVIDER.lower().strip()
|
||||
data_provider = _PROVIDER_REGISTRY.get(provider_key)
|
||||
|
||||
if data_provider is None:
|
||||
raise ValueError(
|
||||
f"Unsupported DATA_PROVIDER '{settings.DATA_PROVIDER}'. Available: {', '.join(_PROVIDER_REGISTRY)}"
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple, Set
|
||||
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
from app.config import settings
|
||||
from app.streaming.live_store import live_store, _TIMEFRAME_SECONDS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReplayKey:
|
||||
symbol: str
|
||||
timeframe: str
|
||||
|
||||
|
||||
class HistoricalReplayProvider:
|
||||
"""Streams OHLCV data by replaying parquet partitions as live candles."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: str | Path | None = None,
|
||||
speed: float | None = None,
|
||||
days: int | None = None,
|
||||
loop: bool | None = None,
|
||||
) -> None:
|
||||
default_root = Path(__file__).resolve().parents[3] / "data" / "parquet" / "live"
|
||||
resolved_root = Path(root) if root else Path(settings.HISTORICAL_REPLAY_ROOT or default_root)
|
||||
self._root = resolved_root
|
||||
self._speed = max(speed or settings.HISTORICAL_REPLAY_SPEED, 0.1)
|
||||
self._days_back = max(days or settings.HISTORICAL_REPLAY_DAYS, 1)
|
||||
self._loop = settings.HISTORICAL_REPLAY_LOOP if loop is None else loop
|
||||
|
||||
self._tasks: Dict[ReplayKey, asyncio.Task] = {}
|
||||
self._pinned: Set[ReplayKey] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._active_counts: Dict[ReplayKey, int] = {}
|
||||
self._bar_cache: Dict[ReplayKey, Tuple[float, List[Dict[str, Any]]]] = {}
|
||||
self._positions: Dict[ReplayKey, int] = {}
|
||||
self._last_times: Dict[ReplayKey, int] = {}
|
||||
|
||||
def _sleep_seconds(self, timeframe: str) -> float:
|
||||
base = _TIMEFRAME_SECONDS.get(timeframe.lower(), 60)
|
||||
return max(base / self._speed, 0.5)
|
||||
|
||||
def _ensure_history(self, key: ReplayKey) -> None:
|
||||
try:
|
||||
if not live_store.get_history(key.symbol, key.timeframe):
|
||||
live_store.load_historical_data(key.symbol, key.timeframe, days_back=self._days_back)
|
||||
except Exception:
|
||||
# Best-effort warmup; ignore errors so streaming can proceed
|
||||
pass
|
||||
|
||||
def _resolve_partitions(self, key: ReplayKey) -> List[Tuple[datetime, Path]]:
|
||||
base_path = self._root / key.symbol / key.timeframe
|
||||
partitions: List[Tuple[datetime, Path]] = []
|
||||
if not base_path.exists():
|
||||
return partitions
|
||||
|
||||
for part in base_path.glob("date=*"):
|
||||
if not part.is_dir():
|
||||
continue
|
||||
_, _, date_part = part.name.partition("=")
|
||||
try:
|
||||
dt = datetime.strptime(date_part, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
partitions.append((dt, part))
|
||||
partitions.sort(key=lambda x: x[0])
|
||||
return partitions
|
||||
|
||||
def _load_bars(self, key: ReplayKey) -> List[Dict[str, Any]]:
|
||||
cached = self._bar_cache.get(key)
|
||||
now = time.time()
|
||||
if cached and now - cached[0] < 300:
|
||||
return cached[1]
|
||||
|
||||
partitions = self._resolve_partitions(key)
|
||||
if not partitions:
|
||||
self._bar_cache[key] = (now, [])
|
||||
return []
|
||||
|
||||
cutoff_date = partitions[-1][0] - timedelta(days=self._days_back - 1)
|
||||
eligible = [p for p in partitions if p[0] >= cutoff_date]
|
||||
if not eligible:
|
||||
eligible = partitions[-self._days_back :] if len(partitions) >= self._days_back else partitions
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for _, part in eligible:
|
||||
files = sorted(part.glob("*.parquet"))
|
||||
for file in files:
|
||||
try:
|
||||
table = pq.read_table(file, columns=["time", "open", "high", "low", "close", "volume"])
|
||||
except Exception:
|
||||
continue
|
||||
for row in table.to_pylist():
|
||||
try:
|
||||
rows.append(
|
||||
{
|
||||
"time": int(row["time"]),
|
||||
"open": float(row["open"]),
|
||||
"high": float(row["high"]),
|
||||
"low": float(row["low"]),
|
||||
"close": float(row["close"]),
|
||||
"volume": float(row.get("volume") or 0.0),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
rows.sort(key=lambda r: r["time"])
|
||||
self._bar_cache[key] = (now, rows)
|
||||
return rows
|
||||
|
||||
async def subscribe(self, symbol: str, timeframe: str = "1m") -> Tuple[asyncio.Queue, Any]:
|
||||
if timeframe != "1m":
|
||||
raise ValueError("HistoricalReplayProvider currently supports timeframe '1m' only")
|
||||
key = ReplayKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=200)
|
||||
live_store.subscribe(key.symbol, key.timeframe, queue)
|
||||
async with self._lock:
|
||||
self._active_counts[key] = self._active_counts.get(key, 0) + 1
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_replay(key))
|
||||
|
||||
async def _unsubscribe() -> None:
|
||||
live_store.unsubscribe(key.symbol, key.timeframe, queue)
|
||||
async with self._lock:
|
||||
self._active_counts[key] = max(0, self._active_counts.get(key, 0) - 1)
|
||||
if self._active_counts.get(key, 0) == 0 and key not in self._pinned:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
self._active_counts.pop(key, None)
|
||||
return queue, _unsubscribe
|
||||
|
||||
async def ensure_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
if timeframe != "1m":
|
||||
raise ValueError("HistoricalReplayProvider currently supports timeframe '1m' only")
|
||||
key = ReplayKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.add(key)
|
||||
self._active_counts.setdefault(key, 0)
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_replay(key))
|
||||
|
||||
async def release_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
key = ReplayKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.discard(key)
|
||||
if self._active_counts.get(key, 0) == 0:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
self._active_counts.pop(key, None)
|
||||
|
||||
async def _run_replay(self, key: ReplayKey) -> None:
|
||||
self._ensure_history(key)
|
||||
sleep_secs = self._sleep_seconds(key.timeframe)
|
||||
tf_seconds = _TIMEFRAME_SECONDS.get(key.timeframe.lower(), 60)
|
||||
self._positions.setdefault(key, 0)
|
||||
self._last_times.setdefault(key, 0)
|
||||
|
||||
while True:
|
||||
try:
|
||||
bars = self._load_bars(key)
|
||||
if not bars:
|
||||
await asyncio.sleep(5.0)
|
||||
continue
|
||||
|
||||
idx = self._positions.get(key, 0)
|
||||
if idx >= len(bars):
|
||||
if not self._loop:
|
||||
await asyncio.sleep(sleep_secs)
|
||||
continue
|
||||
idx = 0
|
||||
bar = bars[idx]
|
||||
self._positions[key] = idx + 1
|
||||
|
||||
now = int(time.time())
|
||||
aligned = (now // tf_seconds) * tf_seconds
|
||||
last_time = self._last_times.get(key) or 0
|
||||
if aligned <= last_time:
|
||||
aligned = last_time + tf_seconds
|
||||
|
||||
payload = {
|
||||
"time": aligned,
|
||||
"open": bar["open"],
|
||||
"high": bar["high"],
|
||||
"low": bar["low"],
|
||||
"close": bar["close"],
|
||||
"volume": bar.get("volume", 0.0),
|
||||
}
|
||||
live_store.ingest_bar(symbol=key.symbol, timeframe=key.timeframe, bar=payload)
|
||||
self._last_times[key] = aligned
|
||||
|
||||
await asyncio.sleep(sleep_secs)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
await asyncio.sleep(min(5.0, sleep_secs))
|
||||
|
||||
|
||||
historical_replay = HistoricalReplayProvider()
|
||||
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Set, Tuple
|
||||
|
||||
from app.streaming.live_store import live_store
|
||||
from app.services.price_simulator import gold_simulator
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeedKey:
|
||||
symbol: str
|
||||
timeframe: str
|
||||
|
||||
|
||||
class LocalFeedProvider:
|
||||
"""In-memory price feed backed by the gold price simulator."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tasks: Dict[FeedKey, asyncio.Task] = {}
|
||||
self._pinned: Set[FeedKey] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._active_counts: Dict[FeedKey, int] = {}
|
||||
|
||||
def get_status(self) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for key, task in self._tasks.items():
|
||||
hist = live_store.get_history(key.symbol, key.timeframe)
|
||||
last_ts = hist[-1]["time"] if hist else None
|
||||
last_iso = None
|
||||
if isinstance(last_ts, (int, float)):
|
||||
try:
|
||||
last_iso = datetime.utcfromtimestamp(int(last_ts)).isoformat() + "Z"
|
||||
except Exception:
|
||||
last_iso = None
|
||||
out.append(
|
||||
{
|
||||
"symbol": key.symbol,
|
||||
"timeframe": key.timeframe,
|
||||
"subscribers": self._active_counts.get(key, 0),
|
||||
"last_event_time": last_iso,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
async def subscribe(self, symbol: str, timeframe: str = "1m") -> Tuple[asyncio.Queue, Any]:
|
||||
if timeframe != "1m":
|
||||
raise ValueError("LocalFeedProvider currently supports timeframe '1m' only")
|
||||
key = FeedKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
live_store.subscribe(key.symbol, key.timeframe, queue)
|
||||
async with self._lock:
|
||||
self._active_counts[key] = self._active_counts.get(key, 0) + 1
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_simulator_poller(key))
|
||||
|
||||
async def _unsubscribe() -> None:
|
||||
live_store.unsubscribe(key.symbol, key.timeframe, queue)
|
||||
async with self._lock:
|
||||
self._active_counts[key] = max(0, self._active_counts.get(key, 0) - 1)
|
||||
if self._active_counts.get(key, 0) == 0 and key not in self._pinned:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
self._active_counts.pop(key, None)
|
||||
|
||||
return queue, _unsubscribe
|
||||
|
||||
async def ensure_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
if timeframe != "1m":
|
||||
raise ValueError("LocalFeedProvider currently supports timeframe '1m' only")
|
||||
key = FeedKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.add(key)
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_simulator_poller(key))
|
||||
|
||||
async def release_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
key = FeedKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.discard(key)
|
||||
if self._active_counts.get(key, 0) == 0:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
|
||||
async def _run_simulator_poller(self, key: FeedKey) -> None:
|
||||
symbol = key.symbol
|
||||
simulator = gold_simulator
|
||||
if simulator.current_price < 2400 or simulator.current_price > 2900:
|
||||
simulator.current_price = 2650.0
|
||||
simulator.base_price = 2650.0
|
||||
|
||||
last_ts: int | None = None
|
||||
poll_interval = 3
|
||||
|
||||
while True:
|
||||
try:
|
||||
candle = simulator.get_live_candle(interval="1min")
|
||||
tsec = candle.time
|
||||
|
||||
if last_ts is None or tsec > last_ts:
|
||||
dt = datetime.utcfromtimestamp(tsec)
|
||||
evt = {
|
||||
"symbol": symbol,
|
||||
"timeframe": key.timeframe,
|
||||
"open_time": dt.isoformat(),
|
||||
"close_time": dt.isoformat(),
|
||||
"open": candle.open,
|
||||
"high": candle.high,
|
||||
"low": candle.low,
|
||||
"close": candle.close,
|
||||
"volume": candle.volume or 0.0,
|
||||
"is_closed": True,
|
||||
"source": "local_simulator",
|
||||
}
|
||||
try:
|
||||
live_store.ingest_bar(
|
||||
symbol=symbol,
|
||||
timeframe="1m",
|
||||
bar={
|
||||
"time": tsec,
|
||||
"open": evt["open"],
|
||||
"high": evt["high"],
|
||||
"low": evt["low"],
|
||||
"close": evt["close"],
|
||||
"volume": evt["volume"],
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last_ts = tsec
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
|
||||
local_feed = LocalFeedProvider()
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""MetaTrader-powered price feed (scaffold).
|
||||
|
||||
The actual MT5 integration lives here so we can keep the rest of the app agnostic.
|
||||
Implement the ``_connect`` and ``_fetch`` routines once MetaTrader 5 is available
|
||||
on the host machine.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
try:
|
||||
import MetaTrader5 as mt5 # type: ignore
|
||||
except Exception: # pragma: no cover - MetaTrader library not always installed
|
||||
mt5 = None
|
||||
|
||||
from app.streaming.live_store import live_store
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MTKey:
|
||||
symbol: str
|
||||
timeframe: str
|
||||
|
||||
|
||||
class MetaTraderFeed:
|
||||
def __init__(self) -> None:
|
||||
self._tasks: Dict[MTKey, asyncio.Task] = {}
|
||||
self._pinned: set[MTKey] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._active_counts: Dict[MTKey, int] = {}
|
||||
|
||||
def get_status(self) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for key, task in self._tasks.items():
|
||||
hist = live_store.get_history(key.symbol, key.timeframe)
|
||||
last_ts = hist[-1]["time"] if hist else None
|
||||
last_iso = None
|
||||
if isinstance(last_ts, (int, float)):
|
||||
last_iso = datetime.utcfromtimestamp(int(last_ts)).isoformat() + "Z"
|
||||
out.append(
|
||||
{
|
||||
"symbol": key.symbol,
|
||||
"timeframe": key.timeframe,
|
||||
"subscribers": self._active_counts.get(key, 0),
|
||||
"last_event_time": last_iso,
|
||||
"connected": mt5 is not None and mt5.terminal_info() is not None,
|
||||
"poller_running": not task.done(),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
async def subscribe(self, symbol: str, timeframe: str = "1m") -> Tuple[asyncio.Queue, Any]:
|
||||
key = MTKey(symbol.upper().replace("/", ""), timeframe)
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
live_store.subscribe(key.symbol, key.timeframe, queue)
|
||||
|
||||
async with self._lock:
|
||||
self._active_counts[key] = self._active_counts.get(key, 0) + 1
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_mt_poller(key))
|
||||
|
||||
async def _unsubscribe() -> None:
|
||||
live_store.unsubscribe(key.symbol, key.timeframe, queue)
|
||||
async with self._lock:
|
||||
self._active_counts[key] = max(0, self._active_counts.get(key, 0) - 1)
|
||||
if self._active_counts.get(key, 0) == 0 and key not in self._pinned:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
self._active_counts.pop(key, None)
|
||||
|
||||
return queue, _unsubscribe
|
||||
|
||||
async def ensure_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
key = MTKey(symbol.upper().replace("/", ""), timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.add(key)
|
||||
self._active_counts.setdefault(key, 0)
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_mt_poller(key))
|
||||
|
||||
async def release_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
"""Allow external callers to drop pinning once no longer needed."""
|
||||
key = MTKey(symbol.upper().replace("/", ""), timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.discard(key)
|
||||
if self._active_counts.get(key, 0) == 0:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
self._active_counts.pop(key, None)
|
||||
|
||||
async def _run_mt_poller(self, key: MTKey) -> None:
|
||||
if mt5 is None:
|
||||
raise RuntimeError("MetaTrader5 package not installed. Install to enable MT feed.")
|
||||
|
||||
if not mt5.initialize():
|
||||
raise RuntimeError(f"Unable to initialize MetaTrader5: {mt5.last_error()}")
|
||||
|
||||
interval = self._resolve_timeframe(key.timeframe)
|
||||
last_ts: int | None = None
|
||||
poll_interval = 5
|
||||
|
||||
while True:
|
||||
try:
|
||||
rates = mt5.copy_rates_from_pos(key.symbol, interval, 0, 1)
|
||||
if not rates:
|
||||
await asyncio.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
row = rates[0]
|
||||
tsec = int(row["time"])
|
||||
if last_ts is not None and tsec <= last_ts:
|
||||
await asyncio.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
last_ts = tsec
|
||||
evt = {
|
||||
"symbol": key.symbol,
|
||||
"timeframe": key.timeframe,
|
||||
"open_time": datetime.utcfromtimestamp(tsec).isoformat(),
|
||||
"close_time": datetime.utcfromtimestamp(tsec).isoformat(),
|
||||
"open": float(row["open"]),
|
||||
"high": float(row["high"]),
|
||||
"low": float(row["low"]),
|
||||
"close": float(row["close"]),
|
||||
"volume": float(row.get("tick_volume", 0.0)),
|
||||
"is_closed": True,
|
||||
"source": "metatrader",
|
||||
}
|
||||
live_store.ingest_bar(
|
||||
symbol=key.symbol,
|
||||
timeframe=key.timeframe,
|
||||
bar={
|
||||
"time": tsec,
|
||||
"open": evt["open"],
|
||||
"high": evt["high"],
|
||||
"low": evt["low"],
|
||||
"close": evt["close"],
|
||||
"volume": evt["volume"],
|
||||
},
|
||||
)
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
def _resolve_timeframe(self, name: str):
|
||||
if mt5 is None:
|
||||
raise RuntimeError("MetaTrader5 package not available")
|
||||
mapping = {
|
||||
"1m": mt5.TIMEFRAME_M1,
|
||||
"5m": mt5.TIMEFRAME_M5,
|
||||
"15m": mt5.TIMEFRAME_M15,
|
||||
"30m": mt5.TIMEFRAME_M30,
|
||||
"60m": mt5.TIMEFRAME_H1,
|
||||
"1h": mt5.TIMEFRAME_H1,
|
||||
}
|
||||
return mapping.get(name.lower(), mt5.TIMEFRAME_M1)
|
||||
|
||||
|
||||
metatrader_feed = MetaTraderFeed()
|
||||
Reference in New Issue
Block a user