Full-stack trading bot with: - FastAPI backend with ICT strategy (Order Block + Liquidity Sweep detection) - Backtester engine with rolling window, spread simulation, and performance metrics - Hybrid market data service (yfinance + TwelveData with rate limiting + SQLite cache) - Simulated exchange for paper trading - React/TypeScript frontend with TradingView lightweight-charts v5 - Live dashboard with candlestick chart, OHLC legend, trade markers - Backtest page with configurable parameters, equity curve, and trade table - WebSocket support for real-time updates - Bot runner with asyncio loop for automated trading Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
172 lines
5.7 KiB
Python
172 lines
5.7 KiB
Python
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
import pandas as pd
|
|
from oandapyV20 import API
|
|
from oandapyV20.endpoints import accounts, instruments, orders, trades, pricing
|
|
|
|
from app.core.config import settings
|
|
from app.core.exchange.base import (
|
|
AbstractExchange,
|
|
AccountInfo,
|
|
OpenTrade,
|
|
OrderResult,
|
|
)
|
|
|
|
|
|
# Granularités OANDA valides
|
|
GRANULARITIES = {
|
|
"M1", "M5", "M15", "M30",
|
|
"H1", "H2", "H4", "H6", "H8", "H12",
|
|
"D", "W", "M",
|
|
}
|
|
|
|
# Nombre max de candles par requête OANDA
|
|
MAX_CANDLES_PER_REQUEST = 5000
|
|
|
|
|
|
def _parse_time(ts: str) -> datetime:
|
|
"""Parse RFC3339 timestamp OANDA → datetime UTC."""
|
|
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
|
|
|
|
class OandaExchange(AbstractExchange):
|
|
def __init__(self) -> None:
|
|
env = "practice" if settings.oanda_environment == "practice" else "live"
|
|
self._client = API(
|
|
access_token=settings.oanda_api_key,
|
|
environment=env,
|
|
)
|
|
self._account_id = settings.oanda_account_id
|
|
|
|
async def get_candles(
|
|
self,
|
|
instrument: str,
|
|
granularity: str = "H1",
|
|
count: int = 200,
|
|
from_time: Optional[datetime] = None,
|
|
to_time: Optional[datetime] = None,
|
|
) -> pd.DataFrame:
|
|
if granularity not in GRANULARITIES:
|
|
raise ValueError(f"Granularité invalide: {granularity}. Valides: {GRANULARITIES}")
|
|
|
|
params: dict = {"granularity": granularity, "price": "M"}
|
|
|
|
if from_time and to_time:
|
|
params["from"] = from_time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
params["to"] = to_time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
else:
|
|
params["count"] = min(count, MAX_CANDLES_PER_REQUEST)
|
|
|
|
r = instruments.InstrumentsCandles(instrument, params=params)
|
|
self._client.request(r)
|
|
|
|
candles = r.response.get("candles", [])
|
|
rows = []
|
|
for c in candles:
|
|
if c.get("complete", True):
|
|
mid = c["mid"]
|
|
rows.append({
|
|
"time": _parse_time(c["time"]),
|
|
"open": float(mid["o"]),
|
|
"high": float(mid["h"]),
|
|
"low": float(mid["l"]),
|
|
"close": float(mid["c"]),
|
|
"volume": int(c.get("volume", 0)),
|
|
})
|
|
|
|
df = pd.DataFrame(rows)
|
|
if not df.empty:
|
|
df.sort_values("time", inplace=True)
|
|
df.reset_index(drop=True, inplace=True)
|
|
return df
|
|
|
|
async def place_order(
|
|
self,
|
|
instrument: str,
|
|
units: float,
|
|
stop_loss: float,
|
|
take_profit: float,
|
|
) -> OrderResult:
|
|
data = {
|
|
"order": {
|
|
"type": "MARKET",
|
|
"instrument": instrument,
|
|
"units": str(int(units)),
|
|
"stopLossOnFill": {"price": f"{stop_loss:.5f}"},
|
|
"takeProfitOnFill": {"price": f"{take_profit:.5f}"},
|
|
"timeInForce": "FOK",
|
|
}
|
|
}
|
|
r = orders.OrderCreate(self._account_id, data=data)
|
|
self._client.request(r)
|
|
|
|
resp = r.response
|
|
fill = resp.get("orderFillTransaction") or resp.get("relatedTransactionIDs", {})
|
|
trade_id = resp.get("orderFillTransaction", {}).get("tradeOpened", {}).get("tradeID", "unknown")
|
|
entry_price = float(resp.get("orderFillTransaction", {}).get("price", 0))
|
|
direction = "buy" if units > 0 else "sell"
|
|
|
|
return OrderResult(
|
|
trade_id=trade_id,
|
|
instrument=instrument,
|
|
direction=direction,
|
|
units=abs(units),
|
|
entry_price=entry_price,
|
|
stop_loss=stop_loss,
|
|
take_profit=take_profit,
|
|
opened_at=datetime.now(timezone.utc),
|
|
)
|
|
|
|
async def close_trade(self, trade_id: str) -> float:
|
|
r = trades.TradeClose(self._account_id, tradeID=trade_id)
|
|
self._client.request(r)
|
|
pnl = float(r.response.get("orderFillTransaction", {}).get("pl", 0))
|
|
return pnl
|
|
|
|
async def get_open_trades(self) -> list[OpenTrade]:
|
|
r = trades.OpenTrades(self._account_id)
|
|
self._client.request(r)
|
|
result = []
|
|
for t in r.response.get("trades", []):
|
|
units = float(t["currentUnits"])
|
|
direction = "buy" if units > 0 else "sell"
|
|
sl = float(t.get("stopLoss", {}).get("price", 0)) if t.get("stopLoss") else 0.0
|
|
tp = float(t.get("takeProfit", {}).get("price", 0)) if t.get("takeProfit") else 0.0
|
|
result.append(OpenTrade(
|
|
trade_id=t["id"],
|
|
instrument=t["instrument"],
|
|
direction=direction,
|
|
units=abs(units),
|
|
entry_price=float(t["price"]),
|
|
stop_loss=sl,
|
|
take_profit=tp,
|
|
unrealized_pnl=float(t.get("unrealizedPL", 0)),
|
|
opened_at=_parse_time(t["openTime"]),
|
|
))
|
|
return result
|
|
|
|
async def get_account_info(self) -> AccountInfo:
|
|
r = accounts.AccountSummary(self._account_id)
|
|
self._client.request(r)
|
|
acc = r.response["account"]
|
|
return AccountInfo(
|
|
balance=float(acc["balance"]),
|
|
nav=float(acc["NAV"]),
|
|
unrealized_pnl=float(acc.get("unrealizedPL", 0)),
|
|
currency=acc.get("currency", "USD"),
|
|
)
|
|
|
|
async def get_price(self, instrument: str) -> float:
|
|
r = pricing.PricingInfo(
|
|
self._account_id,
|
|
params={"instruments": instrument},
|
|
)
|
|
self._client.request(r)
|
|
prices = r.response.get("prices", [])
|
|
if not prices:
|
|
raise ValueError(f"Aucun prix pour {instrument}")
|
|
bid = float(prices[0]["bids"][0]["price"])
|
|
ask = float(prices[0]["asks"][0]["price"])
|
|
return (bid + ask) / 2
|