feat: Add Phase 4 advanced metrics and components

- Add advanced metrics dashboard with trade analytics
- Add new trading components (EntryTypeAnalysis, MultiDayPositionTracker, NewsEventTracker, etc.)
- Add strategy mode selector and trend confirmation
- Add risk automation panel and slippage correlation analysis
- Add daily trading plan enhancements with modal components
- Add custom hooks (useApi, useLocalStorage, useAdvancedTradeMetrics)
- Add broker service integration and trading API
- Add test setup and vitest configuration
- Include parquet data files for live market data
- Add comprehensive documentation in docs/ folder
This commit is contained in:
Krikorios
2025-11-27 10:23:58 +02:00
parent b5e2b02cb8
commit 48e60d015f
2019 changed files with 39793 additions and 257 deletions
+79
View File
@@ -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
+531
View File
@@ -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()}
+413
View File
@@ -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)}"
)
+125
View File
@@ -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
)
+558
View File
@@ -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&current_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)}"
)
+27
View File
@@ -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}")
+495
View File
@@ -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)}",
)
+434
View File
@@ -0,0 +1,434 @@
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.orm import Session, selectinload
from typing import Dict, Optional, Any, List
from datetime import datetime, timezone
from app.db.database import get_db
from app.models.models import Simulation, Trade, Position, TradeAction, TradeMetadata
from app.services.risk import validate_order
from pydantic import BaseModel
router = APIRouter(prefix="/trading", tags=["Trading"])
# Pydantic models for request/response
class TradeRequest(BaseModel):
action: str
quantity: float
price: float
symbol: str = "XAU/USD"
notes: Optional[str] = None
stop_loss: Optional[float] = None
take_profit: Optional[float] = None
source: Optional[str] = None
platform: Optional[str] = None
risk_percent: Optional[float] = None
entry_time: Optional[str] = None
class PortfolioState(BaseModel):
cash: float
initial_capital: float
position: Optional[Dict[str, Any]] = None
trades: List[Dict[str, Any]]
equity_history: List[Dict[str, Any]]
total_pnl: float
total_pnl_percent: float
def get_or_create_simulation(db: Session, user_id: str = "default") -> Simulation:
"""Get existing simulation or create a new one"""
simulation = db.query(Simulation).filter(Simulation.user_id == user_id).first()
if not simulation:
simulation = Simulation(
user_id=user_id,
symbol="XAU/USD",
initial_capital=100000.0,
current_capital=100000.0,
total_pnl=0.0,
total_pnl_percent=0.0
)
db.add(simulation)
db.commit()
db.refresh(simulation)
return simulation
def _compute_equity_at_price(simulation: Simulation, price: float, db: Session) -> float:
"""Calculate equity based on current position and price"""
position = db.query(Position).filter(
Position.simulation_id == simulation.id
).first()
qty = position.quantity if position else 0.0
return float(simulation.current_capital + qty * price)
def get_portfolio_state_from_db(simulation: Simulation, db: Session) -> PortfolioState:
"""Convert DB simulation to portfolio state"""
# Get current position
position = db.query(Position).filter(
Position.simulation_id == simulation.id
).first()
position_dict = None
if position:
position_dict = {
"symbol": position.symbol,
"quantity": position.quantity,
"avg_price": position.avg_price,
"current_price": position.current_price,
"unrealized_pnl": position.unrealized_pnl,
"unrealized_pnl_percent": position.unrealized_pnl_percent
}
# Get all trades
trades = db.query(Trade).options(selectinload(Trade.details)).filter(
Trade.simulation_id == simulation.id
).order_by(Trade.timestamp).all()
trades_list = []
for trade in trades:
details = trade.details
trades_list.append({
"id": trade.id,
"action": trade.action.value,
"quantity": trade.quantity,
"price": trade.price,
"total": trade.total,
"pnl": trade.pnl,
"timestamp": int(trade.timestamp.timestamp()) if trade.timestamp else None,
"stop_loss": details.stop_loss if details else None,
"take_profit": details.take_profit if details else None,
"notes": details.notes if details else None,
"source": details.source if details else None,
"platform": details.platform if details else None,
"risk_percent": details.risk_percent if details else None,
"entry_time": details.entry_time.isoformat() if details and details.entry_time else None,
})
# Build equity history from trades
equity_history = []
running_equity = simulation.initial_capital
for trade in trades:
if trade.action == TradeAction.SELL and trade.pnl:
running_equity += trade.pnl
equity_history.append({
"time": int(trade.timestamp.timestamp()) if trade.timestamp else 0,
"equity": running_equity
})
return PortfolioState(
cash=simulation.current_capital,
initial_capital=simulation.initial_capital,
position=position_dict,
trades=trades_list,
equity_history=equity_history,
total_pnl=simulation.total_pnl,
total_pnl_percent=simulation.total_pnl_percent
)
@router.post("/execute")
async def execute_trade(
trade_request: TradeRequest,
db: Session = Depends(get_db),
user_id: str = "default"
):
"""
Execute a trade and persist to database.
- Validates risk rules
- Updates cash/position in DB
- Records trade with timestamp
- Returns updated portfolio state
"""
try:
action = trade_request.action.upper()
quantity = trade_request.quantity
price = trade_request.price
if action not in ["BUY", "SELL"]:
raise HTTPException(status_code=400, detail="Action must be BUY or SELL")
# Get or create simulation
simulation = get_or_create_simulation(db, user_id)
# Build state dict for risk validation
position = db.query(Position).filter(
Position.simulation_id == simulation.id
).first()
state_dict = {
"cash": simulation.current_capital,
"position": {
"quantity": position.quantity,
"avg_price": position.avg_price
} if position else None
}
# Risk validation
try:
validate_order(state_dict, action, quantity, price)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
total = quantity * price
pnl = None
if action == "BUY":
if total > simulation.current_capital:
raise HTTPException(status_code=400, detail="Insufficient funds")
simulation.current_capital -= total
if not position:
# Create new position
position = Position(
simulation_id=simulation.id,
symbol=trade_request.symbol,
quantity=quantity,
avg_price=price,
current_price=price,
unrealized_pnl=0.0,
unrealized_pnl_percent=0.0
)
db.add(position)
else:
# Update existing position (average up)
new_qty = position.quantity + quantity
new_avg = (position.avg_price * position.quantity + price * quantity) / new_qty
position.quantity = new_qty
position.avg_price = new_avg
position.current_price = price
elif action == "SELL":
if not position or quantity > position.quantity:
raise HTTPException(status_code=400, detail="Insufficient position")
simulation.current_capital += total
pnl = (price - position.avg_price) * quantity
# Update simulation totals
simulation.total_pnl += pnl
if simulation.initial_capital > 0:
simulation.total_pnl_percent = (simulation.total_pnl / simulation.initial_capital) * 100
position.quantity -= quantity
if position.quantity == 0:
# Close position
db.delete(position)
position = None
else:
position.current_price = price
# Create trade record
trade_timestamp = datetime.now(timezone.utc)
trade = Trade(
simulation_id=simulation.id,
action=TradeAction[action],
quantity=quantity,
price=price,
total=total,
pnl=pnl,
timestamp=trade_timestamp
)
db.add(trade)
db.flush()
entry_time_dt = None
if trade_request.entry_time:
try:
entry_time_dt = datetime.fromisoformat(trade_request.entry_time)
if entry_time_dt.tzinfo is None:
entry_time_dt = entry_time_dt.replace(tzinfo=timezone.utc)
except ValueError:
entry_time_dt = trade_timestamp
metadata_fields = (
trade_request.source,
trade_request.platform,
trade_request.notes,
trade_request.stop_loss,
trade_request.take_profit,
trade_request.risk_percent,
entry_time_dt,
)
if any(field is not None for field in metadata_fields):
trade_metadata = TradeMetadata(
trade_id=trade.id,
source=trade_request.source,
platform=trade_request.platform,
notes=trade_request.notes,
stop_loss=trade_request.stop_loss,
take_profit=trade_request.take_profit,
risk_percent=trade_request.risk_percent,
entry_time=entry_time_dt,
)
db.add(trade_metadata)
# Commit all changes
db.commit()
db.refresh(simulation)
# Return updated portfolio state
portfolio_state = get_portfolio_state_from_db(simulation, db)
return {
"trade": {
"id": trade.id,
"action": action,
"quantity": quantity,
"price": price,
"total": total,
"pnl": pnl,
"timestamp": int(trade.timestamp.timestamp()) if trade.timestamp else None,
"stop_loss": trade_request.stop_loss,
"take_profit": trade_request.take_profit,
"notes": trade_request.notes,
"source": trade_request.source,
"platform": trade_request.platform,
"risk_percent": trade_request.risk_percent,
"entry_time": entry_time_dt.isoformat() if entry_time_dt else None,
},
"portfolio": portfolio_state.dict()
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Trade execution failed: {str(e)}")
@router.get("/portfolio")
async def get_portfolio(
db: Session = Depends(get_db),
user_id: str = "default"
):
"""Get current portfolio state from database"""
try:
simulation = get_or_create_simulation(db, user_id)
portfolio_state = get_portfolio_state_from_db(simulation, db)
return portfolio_state.dict()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get portfolio: {str(e)}")
@router.post("/reset")
async def reset_simulation(
db: Session = Depends(get_db),
user_id: str = "default"
):
"""Reset simulation to initial state"""
try:
simulation = db.query(Simulation).filter(Simulation.user_id == user_id).first()
if simulation:
# Delete all trades and positions (cascade will handle this)
db.delete(simulation)
db.commit()
# Create new simulation
new_simulation = Simulation(
user_id=user_id,
symbol="XAU/USD",
initial_capital=100000.0,
current_capital=100000.0,
total_pnl=0.0,
total_pnl_percent=0.0
)
db.add(new_simulation)
db.commit()
db.refresh(new_simulation)
portfolio_state = get_portfolio_state_from_db(new_simulation, db)
return {
"message": "Simulation reset successfully",
"portfolio": portfolio_state.dict()
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Reset failed: {str(e)}")
@router.get("/history")
async def get_trade_history(
db: Session = Depends(get_db),
user_id: str = "default",
limit: int = 100
):
"""Get trade history from database"""
try:
simulation = get_or_create_simulation(db, user_id)
trades = db.query(Trade).options(selectinload(Trade.details)).filter(
Trade.simulation_id == simulation.id
).order_by(Trade.timestamp.desc()).limit(limit).all()
return [
{
"id": trade.id,
"action": trade.action.value,
"quantity": trade.quantity,
"price": trade.price,
"total": trade.total,
"pnl": trade.pnl,
"timestamp": int(trade.timestamp.timestamp()) if trade.timestamp else None,
"stop_loss": trade.details.stop_loss if trade.details else None,
"take_profit": trade.details.take_profit if trade.details else None,
"notes": trade.details.notes if trade.details else None,
"source": trade.details.source if trade.details else None,
"platform": trade.details.platform if trade.details else None,
"risk_percent": trade.details.risk_percent if trade.details else None,
"entry_time": trade.details.entry_time.isoformat() if trade.details and trade.details.entry_time else None,
}
for trade in trades
]
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get history: {str(e)}")
@router.get("/stats")
async def get_trading_stats(
db: Session = Depends(get_db),
user_id: str = "default"
):
"""Get trading statistics"""
try:
simulation = get_or_create_simulation(db, user_id)
trades = db.query(Trade).filter(
Trade.simulation_id == simulation.id
).all()
total_trades = len(trades)
winning_trades = sum(1 for t in trades if t.pnl and t.pnl > 0)
losing_trades = sum(1 for t in trades if t.pnl and t.pnl < 0)
total_profit = sum(t.pnl for t in trades if t.pnl and t.pnl > 0)
total_loss = sum(abs(t.pnl) for t in trades if t.pnl and t.pnl < 0)
win_rate = (winning_trades / total_trades * 100) if total_trades > 0 else 0
profit_factor = (total_profit / total_loss) if total_loss > 0 else 0
return {
"total_trades": total_trades,
"winning_trades": winning_trades,
"losing_trades": losing_trades,
"win_rate": round(win_rate, 2),
"total_pnl": simulation.total_pnl,
"total_pnl_percent": simulation.total_pnl_percent,
"total_profit": total_profit,
"total_loss": total_loss,
"profit_factor": round(profit_factor, 2),
"current_capital": simulation.current_capital,
"initial_capital": simulation.initial_capital
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get stats: {str(e)}")
+411
View File
@@ -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."
+406
View File
@@ -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()
+473
View File
@@ -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)
+37
View File
@@ -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),
}
+105
View File
@@ -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,
}
+114
View File
@@ -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()
+326
View File
@@ -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
+663
View File
@@ -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()
+117
View File
@@ -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()
+27
View File
@@ -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
}
+641
View File
@@ -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()
+30
View File
@@ -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)
+138
View File
@@ -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()
+24
View File
@@ -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)}"
)
+209
View File
@@ -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()
+144
View File
@@ -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()
+168
View File
@@ -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()