feat: Add Phase 4 advanced metrics and components
- Add advanced metrics dashboard with trade analytics - Add new trading components (EntryTypeAnalysis, MultiDayPositionTracker, NewsEventTracker, etc.) - Add strategy mode selector and trend confirmation - Add risk automation panel and slippage correlation analysis - Add daily trading plan enhancements with modal components - Add custom hooks (useApi, useLocalStorage, useAdvancedTradeMetrics) - Add broker service integration and trading API - Add test setup and vitest configuration - Include parquet data files for live market data - Add comprehensive documentation in docs/ folder
This commit is contained in:
@@ -0,0 +1,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()
|
||||
Reference in New Issue
Block a user