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
+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()