Implement FastAPI backend and Vue 3 frontend for Lowkey Backtest UI
- Added FastAPI backend with core API endpoints for strategies, backtests, and data management. - Introduced Vue 3 frontend with a dark theme, enabling users to run backtests, adjust parameters, and compare results. - Implemented Pydantic schemas for request/response validation and SQLAlchemy models for database interactions. - Enhanced project structure with dedicated modules for services, routers, and components. - Updated dependencies in `pyproject.toml` and `frontend/package.json` to include FastAPI, SQLAlchemy, and Vue-related packages. - Improved `.gitignore` to exclude unnecessary files and directories.
This commit is contained in:
135
live_trading/README.md
Normal file
135
live_trading/README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Live Trading - Regime Reversion Strategy
|
||||
|
||||
This module implements live trading for the ML-based regime detection and mean reversion strategy on OKX perpetual futures.
|
||||
|
||||
## Overview
|
||||
|
||||
The strategy trades ETH perpetual futures based on:
|
||||
1. **BTC/ETH Spread Z-Score**: Identifies when ETH is cheap or expensive relative to BTC
|
||||
2. **Random Forest ML Model**: Predicts probability of successful mean reversion
|
||||
3. **Funding Rate Filter**: Avoids trades in overheated/oversold market conditions
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. API Keys
|
||||
|
||||
The bot loads OKX API credentials from `../BTC_spot_MVRV/.env`.
|
||||
|
||||
**IMPORTANT: OKX uses SEPARATE API keys for live vs demo trading!**
|
||||
|
||||
#### Option A: Demo Trading (Recommended for Testing)
|
||||
1. Go to [OKX Demo Trading](https://www.okx.com/demo-trading)
|
||||
2. Create a demo account if you haven't
|
||||
3. Generate API keys from the demo environment
|
||||
4. Set in `.env`:
|
||||
```env
|
||||
OKX_API_KEY=your_demo_api_key
|
||||
OKX_SECRET=your_demo_secret
|
||||
OKX_PASSWORD=your_demo_passphrase
|
||||
OKX_DEMO_MODE=true
|
||||
```
|
||||
|
||||
#### Option B: Live Trading (Real Funds)
|
||||
Use your existing live API keys with:
|
||||
```env
|
||||
OKX_API_KEY=your_live_api_key
|
||||
OKX_SECRET=your_live_secret
|
||||
OKX_PASSWORD=your_live_passphrase
|
||||
OKX_DEMO_MODE=false
|
||||
```
|
||||
|
||||
**Note:** You cannot use live API keys with `OKX_DEMO_MODE=true` or vice versa.
|
||||
OKX will return error `50101: APIKey does not match current environment`.
|
||||
|
||||
### 2. Dependencies
|
||||
|
||||
All dependencies are already in the project's `pyproject.toml`. No additional installation needed.
|
||||
|
||||
## Usage
|
||||
|
||||
### Run with Demo Account (Recommended First)
|
||||
|
||||
```bash
|
||||
cd /path/to/lowkey_backtest
|
||||
uv run python -m live_trading.main
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
|
||||
```bash
|
||||
# Custom position size
|
||||
uv run python -m live_trading.main --max-position 500
|
||||
|
||||
# Custom leverage
|
||||
uv run python -m live_trading.main --leverage 2
|
||||
|
||||
# Custom cycle interval (in seconds)
|
||||
uv run python -m live_trading.main --interval 1800
|
||||
|
||||
# Combine options
|
||||
uv run python -m live_trading.main --max-position 1000 --leverage 3 --interval 3600
|
||||
```
|
||||
|
||||
### Live Trading (Use with Caution)
|
||||
|
||||
```bash
|
||||
# Requires OKX_DEMO_MODE=false in .env
|
||||
uv run python -m live_trading.main --live
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
live_trading/
|
||||
__init__.py # Module initialization
|
||||
config.py # Configuration loading
|
||||
okx_client.py # OKX API wrapper
|
||||
data_feed.py # Real-time OHLCV data
|
||||
position_manager.py # Position tracking
|
||||
live_regime_strategy.py # Strategy logic
|
||||
main.py # Entry point
|
||||
.env.example # Environment template
|
||||
README.md # This file
|
||||
```
|
||||
|
||||
## Strategy Parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `z_entry_threshold` | 1.0 | Enter when \|Z-Score\| > threshold |
|
||||
| `z_window` | 24 | Rolling window for Z-Score (hours) |
|
||||
| `model_prob_threshold` | 0.5 | ML probability threshold for entry |
|
||||
| `funding_threshold` | 0.005 | Funding rate filter threshold |
|
||||
| `stop_loss_pct` | 6% | Stop-loss percentage |
|
||||
| `take_profit_pct` | 5% | Take-profit percentage |
|
||||
|
||||
## Files Generated
|
||||
|
||||
- `live_trading/positions.json` - Open positions persistence
|
||||
- `live_trading/trade_log.csv` - Trade history
|
||||
- `live_trading/regime_model.pkl` - Trained ML model
|
||||
- `logs/live_trading.log` - Trading logs
|
||||
|
||||
## Risk Warning
|
||||
|
||||
This is experimental trading software. Use at your own risk:
|
||||
- Always start with demo trading
|
||||
- Never risk more than you can afford to lose
|
||||
- Monitor the bot regularly
|
||||
- Have a kill switch ready (Ctrl+C)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### API Key Issues
|
||||
- Ensure API keys have trading permissions
|
||||
- For demo trading, use demo-specific API keys
|
||||
- Check that passphrase matches exactly
|
||||
|
||||
### No Signals Generated
|
||||
- The strategy requires the ML model to be trained
|
||||
- Need at least 200 candles of data
|
||||
- Model trains automatically on first run
|
||||
|
||||
### Position Sync Issues
|
||||
- The bot syncs with exchange positions on each cycle
|
||||
- If positions are closed manually, the bot will detect this
|
||||
6
live_trading/__init__.py
Normal file
6
live_trading/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Live Trading Module for Regime Reversion Strategy on OKX.
|
||||
|
||||
This module implements live trading using the ML-based regime detection
|
||||
and mean reversion strategy on OKX perpetual futures.
|
||||
"""
|
||||
120
live_trading/config.py
Normal file
120
live_trading/config.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Configuration for Live Trading.
|
||||
|
||||
Loads OKX API credentials from environment variables.
|
||||
Uses demo/sandbox mode by default for paper trading.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env from sibling project (BTC_spot_MVRV)
|
||||
ENV_PATH = Path(__file__).parent.parent.parent / "BTC_spot_MVRV" / ".env"
|
||||
if ENV_PATH.exists():
|
||||
load_dotenv(ENV_PATH)
|
||||
else:
|
||||
# Fallback to local .env
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass
|
||||
class OKXConfig:
|
||||
"""OKX API configuration."""
|
||||
api_key: str = field(default_factory=lambda: "")
|
||||
secret: str = field(default_factory=lambda: "")
|
||||
password: str = field(default_factory=lambda: "")
|
||||
demo_mode: bool = field(default_factory=lambda: True)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Load credentials based on demo mode setting."""
|
||||
# Check demo mode first
|
||||
self.demo_mode = os.getenv("OKX_DEMO_MODE", "true").lower() in ("true", "1", "yes")
|
||||
|
||||
if self.demo_mode:
|
||||
# Load demo-specific credentials if available
|
||||
self.api_key = os.getenv("OKX_DEMO_API_KEY", os.getenv("OKX_API_KEY", ""))
|
||||
self.secret = os.getenv("OKX_DEMO_SECRET", os.getenv("OKX_SECRET", ""))
|
||||
self.password = os.getenv("OKX_DEMO_PASSWORD", os.getenv("OKX_PASSWORD", ""))
|
||||
else:
|
||||
# Load live credentials
|
||||
self.api_key = os.getenv("OKX_API_KEY", "")
|
||||
self.secret = os.getenv("OKX_SECRET", "")
|
||||
self.password = os.getenv("OKX_PASSWORD", "")
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate that required credentials are present."""
|
||||
mode = "demo" if self.demo_mode else "live"
|
||||
if not self.api_key:
|
||||
raise ValueError(f"OKX API key not set for {mode} mode")
|
||||
if not self.secret:
|
||||
raise ValueError(f"OKX secret not set for {mode} mode")
|
||||
if not self.password:
|
||||
raise ValueError(f"OKX password not set for {mode} mode")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TradingConfig:
|
||||
"""Trading parameters configuration."""
|
||||
# Trading pairs
|
||||
eth_symbol: str = "ETH/USDT:USDT" # ETH perpetual (primary trading asset)
|
||||
btc_symbol: str = "BTC/USDT:USDT" # BTC perpetual (context asset)
|
||||
|
||||
# Timeframe
|
||||
timeframe: str = "1h"
|
||||
candles_to_fetch: int = 500 # Enough for feature calculation
|
||||
|
||||
# Position sizing
|
||||
max_position_usdt: float = 1000.0 # Max position size in USDT
|
||||
min_position_usdt: float = 10.0 # Min position size in USDT
|
||||
leverage: int = 1 # Leverage (1x = no leverage)
|
||||
margin_mode: str = "cross" # "cross" or "isolated"
|
||||
|
||||
# Risk management
|
||||
stop_loss_pct: float = 0.06 # 6% stop loss
|
||||
take_profit_pct: float = 0.05 # 5% take profit
|
||||
max_concurrent_positions: int = 1 # Max open positions
|
||||
|
||||
# Strategy parameters (from regime_strategy.py)
|
||||
z_entry_threshold: float = 1.0 # Enter when |Z| > 1.0
|
||||
z_window: int = 24 # 24h rolling Z-score window
|
||||
model_prob_threshold: float = 0.5 # ML model probability threshold
|
||||
funding_threshold: float = 0.005 # Funding rate filter threshold
|
||||
|
||||
# Execution
|
||||
sleep_seconds: int = 3600 # Run every hour (1h candles)
|
||||
slippage_pct: float = 0.001 # 0.1% slippage buffer
|
||||
|
||||
|
||||
@dataclass
|
||||
class PathConfig:
|
||||
"""File paths configuration."""
|
||||
base_dir: Path = field(
|
||||
default_factory=lambda: Path(__file__).parent.parent
|
||||
)
|
||||
data_dir: Path = field(default=None)
|
||||
logs_dir: Path = field(default=None)
|
||||
model_path: Path = field(default=None)
|
||||
positions_file: Path = field(default=None)
|
||||
trade_log_file: Path = field(default=None)
|
||||
cq_data_path: Path = field(default=None)
|
||||
|
||||
def __post_init__(self):
|
||||
self.data_dir = self.base_dir / "data"
|
||||
self.logs_dir = self.base_dir / "logs"
|
||||
self.model_path = self.base_dir / "live_trading" / "regime_model.pkl"
|
||||
self.positions_file = self.base_dir / "live_trading" / "positions.json"
|
||||
self.trade_log_file = self.base_dir / "live_trading" / "trade_log.csv"
|
||||
self.cq_data_path = self.data_dir / "cq_training_data.csv"
|
||||
|
||||
# Ensure directories exist
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_config():
|
||||
"""Get all configuration objects."""
|
||||
okx = OKXConfig()
|
||||
trading = TradingConfig()
|
||||
paths = PathConfig()
|
||||
return okx, trading, paths
|
||||
216
live_trading/data_feed.py
Normal file
216
live_trading/data_feed.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Data Feed for Live Trading.
|
||||
|
||||
Fetches real-time OHLCV data from OKX and prepares features
|
||||
for the regime strategy.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import ta
|
||||
|
||||
from .okx_client import OKXClient
|
||||
from .config import TradingConfig, PathConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DataFeed:
|
||||
"""
|
||||
Real-time data feed for the regime strategy.
|
||||
|
||||
Fetches BTC and ETH OHLCV data from OKX and calculates
|
||||
the spread-based features required by the ML model.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
okx_client: OKXClient,
|
||||
trading_config: TradingConfig,
|
||||
path_config: PathConfig
|
||||
):
|
||||
self.client = okx_client
|
||||
self.config = trading_config
|
||||
self.paths = path_config
|
||||
self.cq_data: Optional[pd.DataFrame] = None
|
||||
self._load_cq_data()
|
||||
|
||||
def _load_cq_data(self) -> None:
|
||||
"""Load CryptoQuant on-chain data if available."""
|
||||
try:
|
||||
if self.paths.cq_data_path.exists():
|
||||
self.cq_data = pd.read_csv(
|
||||
self.paths.cq_data_path,
|
||||
index_col='timestamp',
|
||||
parse_dates=True
|
||||
)
|
||||
if self.cq_data.index.tz is None:
|
||||
self.cq_data.index = self.cq_data.index.tz_localize('UTC')
|
||||
logger.info(f"Loaded CryptoQuant data: {len(self.cq_data)} rows")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load CryptoQuant data: {e}")
|
||||
self.cq_data = None
|
||||
|
||||
def fetch_ohlcv_data(self) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""
|
||||
Fetch OHLCV data for BTC and ETH.
|
||||
|
||||
Returns:
|
||||
Tuple of (btc_df, eth_df) DataFrames
|
||||
"""
|
||||
# Fetch BTC data
|
||||
btc_ohlcv = self.client.fetch_ohlcv(
|
||||
self.config.btc_symbol,
|
||||
self.config.timeframe,
|
||||
self.config.candles_to_fetch
|
||||
)
|
||||
btc_df = self._ohlcv_to_dataframe(btc_ohlcv)
|
||||
|
||||
# Fetch ETH data
|
||||
eth_ohlcv = self.client.fetch_ohlcv(
|
||||
self.config.eth_symbol,
|
||||
self.config.timeframe,
|
||||
self.config.candles_to_fetch
|
||||
)
|
||||
eth_df = self._ohlcv_to_dataframe(eth_ohlcv)
|
||||
|
||||
logger.info(
|
||||
f"Fetched {len(btc_df)} BTC candles and {len(eth_df)} ETH candles"
|
||||
)
|
||||
|
||||
return btc_df, eth_df
|
||||
|
||||
def _ohlcv_to_dataframe(self, ohlcv: list) -> pd.DataFrame:
|
||||
"""Convert OHLCV list to DataFrame."""
|
||||
df = pd.DataFrame(
|
||||
ohlcv,
|
||||
columns=['timestamp', 'open', 'high', 'low', 'close', 'volume']
|
||||
)
|
||||
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms', utc=True)
|
||||
df.set_index('timestamp', inplace=True)
|
||||
return df
|
||||
|
||||
def calculate_features(
|
||||
self,
|
||||
btc_df: pd.DataFrame,
|
||||
eth_df: pd.DataFrame
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Calculate spread-based features for the regime strategy.
|
||||
|
||||
Args:
|
||||
btc_df: BTC OHLCV DataFrame
|
||||
eth_df: ETH OHLCV DataFrame
|
||||
|
||||
Returns:
|
||||
DataFrame with calculated features
|
||||
"""
|
||||
# Align indices
|
||||
common_idx = btc_df.index.intersection(eth_df.index)
|
||||
df_btc = btc_df.loc[common_idx].copy()
|
||||
df_eth = eth_df.loc[common_idx].copy()
|
||||
|
||||
# Calculate spread (ETH/BTC ratio)
|
||||
spread = df_eth['close'] / df_btc['close']
|
||||
|
||||
# Z-Score of spread
|
||||
z_window = self.config.z_window
|
||||
rolling_mean = spread.rolling(window=z_window).mean()
|
||||
rolling_std = spread.rolling(window=z_window).std()
|
||||
z_score = (spread - rolling_mean) / rolling_std
|
||||
|
||||
# Spread technicals
|
||||
spread_rsi = ta.momentum.RSIIndicator(spread, window=14).rsi()
|
||||
spread_roc = spread.pct_change(periods=5) * 100
|
||||
spread_change_1h = spread.pct_change(periods=1)
|
||||
|
||||
# Volume ratio
|
||||
vol_ratio = df_eth['volume'] / df_btc['volume']
|
||||
vol_ratio_ma = vol_ratio.rolling(window=12).mean()
|
||||
|
||||
# Volatility
|
||||
ret_btc = df_btc['close'].pct_change()
|
||||
ret_eth = df_eth['close'].pct_change()
|
||||
vol_btc = ret_btc.rolling(window=z_window).std()
|
||||
vol_eth = ret_eth.rolling(window=z_window).std()
|
||||
vol_spread_ratio = vol_eth / vol_btc
|
||||
|
||||
# Build features DataFrame
|
||||
features = pd.DataFrame(index=spread.index)
|
||||
features['spread'] = spread
|
||||
features['z_score'] = z_score
|
||||
features['spread_rsi'] = spread_rsi
|
||||
features['spread_roc'] = spread_roc
|
||||
features['spread_change_1h'] = spread_change_1h
|
||||
features['vol_ratio'] = vol_ratio
|
||||
features['vol_ratio_rel'] = vol_ratio / vol_ratio_ma
|
||||
features['vol_diff_ratio'] = vol_spread_ratio
|
||||
|
||||
# Add price data for reference
|
||||
features['btc_close'] = df_btc['close']
|
||||
features['eth_close'] = df_eth['close']
|
||||
features['eth_volume'] = df_eth['volume']
|
||||
|
||||
# Merge CryptoQuant data if available
|
||||
if self.cq_data is not None:
|
||||
cq_aligned = self.cq_data.reindex(features.index, method='ffill')
|
||||
|
||||
# Calculate derived features
|
||||
if 'btc_funding' in cq_aligned.columns and 'eth_funding' in cq_aligned.columns:
|
||||
cq_aligned['funding_diff'] = (
|
||||
cq_aligned['eth_funding'] - cq_aligned['btc_funding']
|
||||
)
|
||||
if 'btc_inflow' in cq_aligned.columns and 'eth_inflow' in cq_aligned.columns:
|
||||
cq_aligned['inflow_ratio'] = (
|
||||
cq_aligned['eth_inflow'] / (cq_aligned['btc_inflow'] + 1)
|
||||
)
|
||||
|
||||
features = features.join(cq_aligned)
|
||||
|
||||
return features.dropna()
|
||||
|
||||
def get_latest_data(self) -> Optional[pd.DataFrame]:
|
||||
"""
|
||||
Fetch and process latest market data.
|
||||
|
||||
Returns:
|
||||
DataFrame with features or None on error
|
||||
"""
|
||||
try:
|
||||
btc_df, eth_df = self.fetch_ohlcv_data()
|
||||
features = self.calculate_features(btc_df, eth_df)
|
||||
|
||||
if features.empty:
|
||||
logger.warning("No valid features calculated")
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f"Latest data: ETH={features['eth_close'].iloc[-1]:.2f}, "
|
||||
f"BTC={features['btc_close'].iloc[-1]:.2f}, "
|
||||
f"Z-Score={features['z_score'].iloc[-1]:.3f}"
|
||||
)
|
||||
|
||||
return features
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching market data: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def get_current_funding_rates(self) -> dict:
|
||||
"""
|
||||
Get current funding rates for BTC and ETH.
|
||||
|
||||
Returns:
|
||||
Dictionary with 'btc_funding' and 'eth_funding' rates
|
||||
"""
|
||||
btc_funding = self.client.get_funding_rate(self.config.btc_symbol)
|
||||
eth_funding = self.client.get_funding_rate(self.config.eth_symbol)
|
||||
|
||||
return {
|
||||
'btc_funding': btc_funding,
|
||||
'eth_funding': eth_funding,
|
||||
'funding_diff': eth_funding - btc_funding,
|
||||
}
|
||||
15
live_trading/env.template
Normal file
15
live_trading/env.template
Normal file
@@ -0,0 +1,15 @@
|
||||
# OKX API Credentials Template
|
||||
# Copy this file to .env and fill in your credentials
|
||||
# For demo trading, use your OKX demo account API keys
|
||||
# Generate keys at: https://www.okx.com/account/my-api (Demo Trading section)
|
||||
|
||||
OKX_API_KEY=your_api_key_here
|
||||
OKX_SECRET=your_secret_key_here
|
||||
OKX_PASSWORD=your_passphrase_here
|
||||
|
||||
# Demo Mode: Set to "true" for paper trading (sandbox)
|
||||
# Set to "false" for live trading with real funds
|
||||
OKX_DEMO_MODE=true
|
||||
|
||||
# CryptoQuant API (optional, for on-chain features)
|
||||
CRYPTOQUANT_API_KEY=your_cryptoquant_api_key_here
|
||||
284
live_trading/live_regime_strategy.py
Normal file
284
live_trading/live_regime_strategy.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Live Regime Reversion Strategy.
|
||||
|
||||
Adapts the backtest regime strategy for live trading.
|
||||
Uses a pre-trained ML model or trains on historical data.
|
||||
"""
|
||||
import logging
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.ensemble import RandomForestClassifier
|
||||
|
||||
from .config import TradingConfig, PathConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LiveRegimeStrategy:
|
||||
"""
|
||||
Live trading implementation of the ML-based regime detection
|
||||
and mean reversion strategy.
|
||||
|
||||
Logic:
|
||||
1. Calculates BTC/ETH spread Z-Score
|
||||
2. Uses Random Forest to predict reversion probability
|
||||
3. Applies funding rate filter
|
||||
4. Generates long/short signals on ETH perpetual
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
trading_config: TradingConfig,
|
||||
path_config: PathConfig
|
||||
):
|
||||
self.config = trading_config
|
||||
self.paths = path_config
|
||||
self.model: Optional[RandomForestClassifier] = None
|
||||
self.feature_cols: Optional[list] = None
|
||||
self._load_or_train_model()
|
||||
|
||||
def _load_or_train_model(self) -> None:
|
||||
"""Load pre-trained model or train a new one."""
|
||||
if self.paths.model_path.exists():
|
||||
try:
|
||||
with open(self.paths.model_path, 'rb') as f:
|
||||
saved = pickle.load(f)
|
||||
self.model = saved['model']
|
||||
self.feature_cols = saved['feature_cols']
|
||||
logger.info(f"Loaded model from {self.paths.model_path}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load model: {e}")
|
||||
|
||||
logger.info("No pre-trained model found. Will train on first data batch.")
|
||||
|
||||
def save_model(self) -> None:
|
||||
"""Save trained model to file."""
|
||||
if self.model is None:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.paths.model_path, 'wb') as f:
|
||||
pickle.dump({
|
||||
'model': self.model,
|
||||
'feature_cols': self.feature_cols,
|
||||
}, f)
|
||||
logger.info(f"Saved model to {self.paths.model_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not save model: {e}")
|
||||
|
||||
def train_model(self, features: pd.DataFrame) -> None:
|
||||
"""
|
||||
Train the Random Forest model on historical data.
|
||||
|
||||
Args:
|
||||
features: DataFrame with calculated features
|
||||
"""
|
||||
logger.info(f"Training model on {len(features)} samples...")
|
||||
|
||||
z_thresh = self.config.z_entry_threshold
|
||||
horizon = 102 # Optimal horizon from research
|
||||
profit_target = 0.005 # 0.5% profit threshold
|
||||
|
||||
# Define targets
|
||||
future_min = features['spread'].rolling(window=horizon).min().shift(-horizon)
|
||||
future_max = features['spread'].rolling(window=horizon).max().shift(-horizon)
|
||||
|
||||
target_short = features['spread'] * (1 - profit_target)
|
||||
target_long = features['spread'] * (1 + profit_target)
|
||||
|
||||
success_short = (features['z_score'] > z_thresh) & (future_min < target_short)
|
||||
success_long = (features['z_score'] < -z_thresh) & (future_max > target_long)
|
||||
|
||||
targets = np.select([success_short, success_long], [1, 1], default=0)
|
||||
|
||||
# Exclude non-feature columns
|
||||
exclude = ['spread', 'btc_close', 'eth_close', 'eth_volume']
|
||||
self.feature_cols = [c for c in features.columns if c not in exclude]
|
||||
|
||||
# Clean features
|
||||
X = features[self.feature_cols].fillna(0)
|
||||
X = X.replace([np.inf, -np.inf], 0)
|
||||
|
||||
# Remove rows with invalid targets
|
||||
valid_mask = ~np.isnan(targets) & future_min.notna().values & future_max.notna().values
|
||||
X_clean = X[valid_mask]
|
||||
y_clean = targets[valid_mask]
|
||||
|
||||
if len(X_clean) < 100:
|
||||
logger.warning("Not enough data to train model")
|
||||
return
|
||||
|
||||
# Train model
|
||||
self.model = RandomForestClassifier(
|
||||
n_estimators=300,
|
||||
max_depth=5,
|
||||
min_samples_leaf=30,
|
||||
class_weight={0: 1, 1: 3},
|
||||
random_state=42
|
||||
)
|
||||
self.model.fit(X_clean, y_clean)
|
||||
|
||||
logger.info(f"Model trained on {len(X_clean)} samples")
|
||||
self.save_model()
|
||||
|
||||
def generate_signal(
|
||||
self,
|
||||
features: pd.DataFrame,
|
||||
current_funding: dict
|
||||
) -> dict:
|
||||
"""
|
||||
Generate trading signal from latest features.
|
||||
|
||||
Args:
|
||||
features: DataFrame with calculated features
|
||||
current_funding: Dictionary with funding rate data
|
||||
|
||||
Returns:
|
||||
Signal dictionary with action, side, confidence, etc.
|
||||
"""
|
||||
if self.model is None:
|
||||
# Train model if not available
|
||||
if len(features) >= 200:
|
||||
self.train_model(features)
|
||||
else:
|
||||
return {'action': 'hold', 'reason': 'model_not_trained'}
|
||||
|
||||
if self.model is None:
|
||||
return {'action': 'hold', 'reason': 'insufficient_data_for_training'}
|
||||
|
||||
# Get latest row
|
||||
latest = features.iloc[-1]
|
||||
z_score = latest['z_score']
|
||||
eth_price = latest['eth_close']
|
||||
btc_price = latest['btc_close']
|
||||
|
||||
# Prepare features for prediction
|
||||
X = features[self.feature_cols].iloc[[-1]].fillna(0)
|
||||
X = X.replace([np.inf, -np.inf], 0)
|
||||
|
||||
# Get prediction probability
|
||||
prob = self.model.predict_proba(X)[0, 1]
|
||||
|
||||
# Apply thresholds
|
||||
z_thresh = self.config.z_entry_threshold
|
||||
prob_thresh = self.config.model_prob_threshold
|
||||
|
||||
# Determine signal direction
|
||||
signal = {
|
||||
'action': 'hold',
|
||||
'side': None,
|
||||
'probability': prob,
|
||||
'z_score': z_score,
|
||||
'eth_price': eth_price,
|
||||
'btc_price': btc_price,
|
||||
'reason': '',
|
||||
}
|
||||
|
||||
# Check for entry conditions
|
||||
if prob > prob_thresh:
|
||||
if z_score > z_thresh:
|
||||
# Spread high (ETH expensive relative to BTC) -> Short ETH
|
||||
signal['action'] = 'entry'
|
||||
signal['side'] = 'short'
|
||||
signal['reason'] = f'z_score={z_score:.2f}>threshold, prob={prob:.2f}'
|
||||
elif z_score < -z_thresh:
|
||||
# Spread low (ETH cheap relative to BTC) -> Long ETH
|
||||
signal['action'] = 'entry'
|
||||
signal['side'] = 'long'
|
||||
signal['reason'] = f'z_score={z_score:.2f}<-threshold, prob={prob:.2f}'
|
||||
else:
|
||||
signal['reason'] = f'z_score={z_score:.2f} within threshold'
|
||||
else:
|
||||
signal['reason'] = f'prob={prob:.2f}<threshold'
|
||||
|
||||
# Apply funding rate filter
|
||||
if signal['action'] == 'entry':
|
||||
btc_funding = current_funding.get('btc_funding', 0)
|
||||
funding_thresh = self.config.funding_threshold
|
||||
|
||||
if signal['side'] == 'long' and btc_funding > funding_thresh:
|
||||
# High positive funding = overheated, don't go long
|
||||
signal['action'] = 'hold'
|
||||
signal['reason'] = f'funding_filter_blocked_long (funding={btc_funding:.4f})'
|
||||
elif signal['side'] == 'short' and btc_funding < -funding_thresh:
|
||||
# High negative funding = oversold, don't go short
|
||||
signal['action'] = 'hold'
|
||||
signal['reason'] = f'funding_filter_blocked_short (funding={btc_funding:.4f})'
|
||||
|
||||
# Check for exit conditions (mean reversion complete)
|
||||
if signal['action'] == 'hold':
|
||||
# Z-score crossed back through 0
|
||||
if abs(z_score) < 0.3:
|
||||
signal['action'] = 'check_exit'
|
||||
signal['reason'] = f'z_score_reverted_to_mean ({z_score:.2f})'
|
||||
|
||||
logger.info(
|
||||
f"Signal: {signal['action']} {signal['side'] or ''} "
|
||||
f"(prob={prob:.2f}, z={z_score:.2f}, reason={signal['reason']})"
|
||||
)
|
||||
|
||||
return signal
|
||||
|
||||
def calculate_position_size(
|
||||
self,
|
||||
signal: dict,
|
||||
available_usdt: float
|
||||
) -> float:
|
||||
"""
|
||||
Calculate position size based on signal confidence.
|
||||
|
||||
Args:
|
||||
signal: Signal dictionary with probability
|
||||
available_usdt: Available USDT balance
|
||||
|
||||
Returns:
|
||||
Position size in USDT
|
||||
"""
|
||||
prob = signal.get('probability', 0.5)
|
||||
|
||||
# Base size is max_position_usdt
|
||||
base_size = min(available_usdt, self.config.max_position_usdt)
|
||||
|
||||
# Scale by probability (1.0x at 0.5 prob, up to 1.6x at 0.8 prob)
|
||||
scale = 1.0 + (prob - 0.5) * 2.0
|
||||
scale = max(1.0, min(scale, 2.0)) # Clamp between 1x and 2x
|
||||
|
||||
size = base_size * scale
|
||||
|
||||
# Ensure minimum position size
|
||||
if size < self.config.min_position_usdt:
|
||||
return 0.0
|
||||
|
||||
return min(size, available_usdt * 0.95) # Leave 5% buffer
|
||||
|
||||
def calculate_sl_tp(
|
||||
self,
|
||||
entry_price: float,
|
||||
side: str
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Calculate stop-loss and take-profit prices.
|
||||
|
||||
Args:
|
||||
entry_price: Entry price
|
||||
side: "long" or "short"
|
||||
|
||||
Returns:
|
||||
Tuple of (stop_loss_price, take_profit_price)
|
||||
"""
|
||||
sl_pct = self.config.stop_loss_pct
|
||||
tp_pct = self.config.take_profit_pct
|
||||
|
||||
if side == "long":
|
||||
stop_loss = entry_price * (1 - sl_pct)
|
||||
take_profit = entry_price * (1 + tp_pct)
|
||||
else: # short
|
||||
stop_loss = entry_price * (1 + sl_pct)
|
||||
take_profit = entry_price * (1 - tp_pct)
|
||||
|
||||
return stop_loss, take_profit
|
||||
390
live_trading/main.py
Normal file
390
live_trading/main.py
Normal file
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Live Trading Bot for Regime Reversion Strategy on OKX.
|
||||
|
||||
This script runs the regime-based mean reversion strategy
|
||||
on ETH perpetual futures using OKX exchange.
|
||||
|
||||
Usage:
|
||||
# Run with demo account (default)
|
||||
uv run python -m live_trading.main
|
||||
|
||||
# Run with specific settings
|
||||
uv run python -m live_trading.main --max-position 500 --leverage 2
|
||||
"""
|
||||
import argparse
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from live_trading.config import get_config, OKXConfig, TradingConfig, PathConfig
|
||||
from live_trading.okx_client import OKXClient
|
||||
from live_trading.data_feed import DataFeed
|
||||
from live_trading.position_manager import PositionManager
|
||||
from live_trading.live_regime_strategy import LiveRegimeStrategy
|
||||
|
||||
|
||||
def setup_logging(log_dir: Path) -> logging.Logger:
|
||||
"""Configure logging for the trading bot."""
|
||||
log_file = log_dir / "live_trading.log"
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(log_file),
|
||||
logging.StreamHandler(sys.stdout),
|
||||
],
|
||||
force=True
|
||||
)
|
||||
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LiveTradingBot:
|
||||
"""
|
||||
Main trading bot orchestrator.
|
||||
|
||||
Coordinates data fetching, signal generation, and order execution
|
||||
in a continuous loop.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
okx_config: OKXConfig,
|
||||
trading_config: TradingConfig,
|
||||
path_config: PathConfig
|
||||
):
|
||||
self.okx_config = okx_config
|
||||
self.trading_config = trading_config
|
||||
self.path_config = path_config
|
||||
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.running = True
|
||||
|
||||
# Initialize components
|
||||
self.logger.info("Initializing trading bot components...")
|
||||
|
||||
self.okx_client = OKXClient(okx_config, trading_config)
|
||||
self.data_feed = DataFeed(self.okx_client, trading_config, path_config)
|
||||
self.position_manager = PositionManager(
|
||||
self.okx_client, trading_config, path_config
|
||||
)
|
||||
self.strategy = LiveRegimeStrategy(trading_config, path_config)
|
||||
|
||||
# Register signal handlers for graceful shutdown
|
||||
signal.signal(signal.SIGINT, self._handle_shutdown)
|
||||
signal.signal(signal.SIGTERM, self._handle_shutdown)
|
||||
|
||||
self._print_startup_banner()
|
||||
|
||||
def _print_startup_banner(self) -> None:
|
||||
"""Print startup information."""
|
||||
mode = "DEMO/SANDBOX" if self.okx_config.demo_mode else "LIVE"
|
||||
|
||||
print("=" * 60)
|
||||
print(f" Regime Reversion Strategy - Live Trading Bot")
|
||||
print("=" * 60)
|
||||
print(f" Mode: {mode}")
|
||||
print(f" Trading Pair: {self.trading_config.eth_symbol}")
|
||||
print(f" Context Pair: {self.trading_config.btc_symbol}")
|
||||
print(f" Timeframe: {self.trading_config.timeframe}")
|
||||
print(f" Max Position: ${self.trading_config.max_position_usdt}")
|
||||
print(f" Leverage: {self.trading_config.leverage}x")
|
||||
print(f" Stop Loss: {self.trading_config.stop_loss_pct * 100:.1f}%")
|
||||
print(f" Take Profit: {self.trading_config.take_profit_pct * 100:.1f}%")
|
||||
print(f" Cycle Interval: {self.trading_config.sleep_seconds // 60} minutes")
|
||||
print("=" * 60)
|
||||
|
||||
if not self.okx_config.demo_mode:
|
||||
print("\n *** WARNING: LIVE TRADING MODE - REAL FUNDS AT RISK ***\n")
|
||||
|
||||
def _handle_shutdown(self, signum, frame) -> None:
|
||||
"""Handle shutdown signals gracefully."""
|
||||
self.logger.info("Shutdown signal received, stopping...")
|
||||
self.running = False
|
||||
|
||||
def run_trading_cycle(self) -> None:
|
||||
"""
|
||||
Execute one trading cycle.
|
||||
|
||||
1. Fetch latest market data
|
||||
2. Update open positions
|
||||
3. Generate trading signal
|
||||
4. Execute trades if signal triggers
|
||||
"""
|
||||
cycle_start = datetime.now(timezone.utc)
|
||||
self.logger.info(f"--- Trading Cycle Start: {cycle_start.isoformat()} ---")
|
||||
|
||||
try:
|
||||
# 1. Fetch market data
|
||||
features = self.data_feed.get_latest_data()
|
||||
if features is None or features.empty:
|
||||
self.logger.warning("No market data available, skipping cycle")
|
||||
return
|
||||
|
||||
# Get current prices
|
||||
eth_price = features['eth_close'].iloc[-1]
|
||||
btc_price = features['btc_close'].iloc[-1]
|
||||
|
||||
current_prices = {
|
||||
self.trading_config.eth_symbol: eth_price,
|
||||
self.trading_config.btc_symbol: btc_price,
|
||||
}
|
||||
|
||||
# 2. Update existing positions (check SL/TP)
|
||||
closed_trades = self.position_manager.update_positions(current_prices)
|
||||
if closed_trades:
|
||||
for trade in closed_trades:
|
||||
self.logger.info(
|
||||
f"Trade closed: {trade['trade_id']} "
|
||||
f"PnL=${trade['pnl_usd']:.2f} ({trade['reason']})"
|
||||
)
|
||||
|
||||
# 3. Sync with exchange positions
|
||||
self.position_manager.sync_with_exchange()
|
||||
|
||||
# 4. Get current funding rates
|
||||
funding = self.data_feed.get_current_funding_rates()
|
||||
|
||||
# 5. Generate trading signal
|
||||
signal = self.strategy.generate_signal(features, funding)
|
||||
|
||||
# 6. Execute trades based on signal
|
||||
if signal['action'] == 'entry':
|
||||
self._execute_entry(signal, eth_price)
|
||||
elif signal['action'] == 'check_exit':
|
||||
self._execute_exit(signal)
|
||||
|
||||
# 7. Log portfolio summary
|
||||
summary = self.position_manager.get_portfolio_summary()
|
||||
self.logger.info(
|
||||
f"Portfolio: {summary['open_positions']} positions, "
|
||||
f"exposure=${summary['total_exposure_usdt']:.2f}, "
|
||||
f"unrealized_pnl=${summary['total_unrealized_pnl']:.2f}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Trading cycle error: {e}", exc_info=True)
|
||||
# Save positions on error
|
||||
self.position_manager.save_positions()
|
||||
|
||||
cycle_duration = (datetime.now(timezone.utc) - cycle_start).total_seconds()
|
||||
self.logger.info(f"--- Cycle completed in {cycle_duration:.1f}s ---")
|
||||
|
||||
def _execute_entry(self, signal: dict, current_price: float) -> None:
|
||||
"""Execute entry trade."""
|
||||
symbol = self.trading_config.eth_symbol
|
||||
side = signal['side']
|
||||
|
||||
# Check if we can open a position
|
||||
if not self.position_manager.can_open_position():
|
||||
self.logger.info("Cannot open position: max positions reached")
|
||||
return
|
||||
|
||||
# Get account balance
|
||||
balance = self.okx_client.get_balance()
|
||||
available_usdt = balance['free']
|
||||
|
||||
# Calculate position size
|
||||
size_usdt = self.strategy.calculate_position_size(signal, available_usdt)
|
||||
if size_usdt <= 0:
|
||||
self.logger.info("Position size too small, skipping entry")
|
||||
return
|
||||
|
||||
size_eth = size_usdt / current_price
|
||||
|
||||
# Calculate SL/TP
|
||||
stop_loss, take_profit = self.strategy.calculate_sl_tp(current_price, side)
|
||||
|
||||
self.logger.info(
|
||||
f"Executing {side.upper()} entry: {size_eth:.4f} ETH @ {current_price:.2f} "
|
||||
f"(${size_usdt:.2f}), SL={stop_loss:.2f}, TP={take_profit:.2f}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Place market order
|
||||
order_side = "buy" if side == "long" else "sell"
|
||||
order = self.okx_client.place_market_order(symbol, order_side, size_eth)
|
||||
|
||||
# Get filled price (handle None values from OKX response)
|
||||
filled_price = order.get('average') or order.get('price') or current_price
|
||||
filled_amount = order.get('filled') or order.get('amount') or size_eth
|
||||
|
||||
# Ensure we have valid numeric values
|
||||
if filled_price is None or filled_price == 0:
|
||||
self.logger.warning(f"No fill price in order response, using current price: {current_price}")
|
||||
filled_price = current_price
|
||||
if filled_amount is None or filled_amount == 0:
|
||||
self.logger.warning(f"No fill amount in order response, using requested: {size_eth}")
|
||||
filled_amount = size_eth
|
||||
|
||||
# Recalculate SL/TP with filled price
|
||||
stop_loss, take_profit = self.strategy.calculate_sl_tp(filled_price, side)
|
||||
|
||||
# Get order ID from response
|
||||
order_id = order.get('id', '')
|
||||
|
||||
# Record position locally
|
||||
position = self.position_manager.open_position(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
entry_price=filled_price,
|
||||
size=filled_amount,
|
||||
stop_loss_price=stop_loss,
|
||||
take_profit_price=take_profit,
|
||||
order_id=order_id,
|
||||
)
|
||||
|
||||
if position:
|
||||
self.logger.info(
|
||||
f"Position opened: {position.trade_id}, "
|
||||
f"{filled_amount:.4f} ETH @ {filled_price:.2f}"
|
||||
)
|
||||
|
||||
# Try to set SL/TP on exchange
|
||||
try:
|
||||
self.okx_client.set_stop_loss_take_profit(
|
||||
symbol, side, filled_amount, stop_loss, take_profit
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not set SL/TP on exchange: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Order execution failed: {e}", exc_info=True)
|
||||
|
||||
def _execute_exit(self, signal: dict) -> None:
|
||||
"""Execute exit based on mean reversion signal."""
|
||||
symbol = self.trading_config.eth_symbol
|
||||
|
||||
# Get position for ETH
|
||||
position = self.position_manager.get_position_for_symbol(symbol)
|
||||
if not position:
|
||||
return
|
||||
|
||||
current_price = signal.get('eth_price', position.current_price)
|
||||
|
||||
self.logger.info(
|
||||
f"Mean reversion exit signal: closing {position.trade_id} "
|
||||
f"@ {current_price:.2f}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Close position on exchange
|
||||
exit_order = self.okx_client.close_position(symbol)
|
||||
exit_order_id = exit_order.get('id', '') if exit_order else ''
|
||||
|
||||
# Record closure locally
|
||||
self.position_manager.close_position(
|
||||
position.trade_id,
|
||||
current_price,
|
||||
reason="mean_reversion_complete",
|
||||
exit_order_id=exit_order_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Exit execution failed: {e}", exc_info=True)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Main trading loop."""
|
||||
self.logger.info("Starting trading loop...")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
self.run_trading_cycle()
|
||||
|
||||
if self.running:
|
||||
sleep_seconds = self.trading_config.sleep_seconds
|
||||
minutes = sleep_seconds // 60
|
||||
self.logger.info(f"Sleeping for {minutes} minutes...")
|
||||
|
||||
# Sleep in smaller chunks to allow faster shutdown
|
||||
for _ in range(sleep_seconds):
|
||||
if not self.running:
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.logger.info("Keyboard interrupt received")
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.error(f"Unexpected error in main loop: {e}", exc_info=True)
|
||||
time.sleep(60) # Wait before retry
|
||||
|
||||
# Cleanup
|
||||
self.logger.info("Shutting down...")
|
||||
self.position_manager.save_positions()
|
||||
self.logger.info("Shutdown complete")
|
||||
|
||||
|
||||
def parse_args():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Live Trading Bot for Regime Reversion Strategy"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-position",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Maximum position size in USDT"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--leverage",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Trading leverage (1-125)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interval",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Trading cycle interval in seconds"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--live",
|
||||
action="store_true",
|
||||
help="Use live trading mode (requires OKX_DEMO_MODE=false)"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
args = parse_args()
|
||||
|
||||
# Load configuration
|
||||
okx_config, trading_config, path_config = get_config()
|
||||
|
||||
# Apply command line overrides
|
||||
if args.max_position is not None:
|
||||
trading_config.max_position_usdt = args.max_position
|
||||
if args.leverage is not None:
|
||||
trading_config.leverage = args.leverage
|
||||
if args.interval is not None:
|
||||
trading_config.sleep_seconds = args.interval
|
||||
if args.live:
|
||||
okx_config.demo_mode = False
|
||||
|
||||
# Setup logging
|
||||
logger = setup_logging(path_config.logs_dir)
|
||||
|
||||
try:
|
||||
# Create and run bot
|
||||
bot = LiveTradingBot(okx_config, trading_config, path_config)
|
||||
bot.run()
|
||||
except ValueError as e:
|
||||
logger.error(f"Configuration error: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
338
live_trading/okx_client.py
Normal file
338
live_trading/okx_client.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""
|
||||
OKX Exchange Client for Live Trading.
|
||||
|
||||
Handles connection to OKX API, order execution, and account management.
|
||||
Supports demo/sandbox mode for paper trading.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import ccxt
|
||||
|
||||
from .config import OKXConfig, TradingConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OKXClient:
|
||||
"""
|
||||
OKX Exchange client wrapper using CCXT.
|
||||
|
||||
Supports both live and demo (sandbox) trading modes.
|
||||
Demo mode uses OKX's official sandbox environment.
|
||||
"""
|
||||
|
||||
def __init__(self, okx_config: OKXConfig, trading_config: TradingConfig):
|
||||
self.okx_config = okx_config
|
||||
self.trading_config = trading_config
|
||||
self.exchange: Optional[ccxt.okx] = None
|
||||
self._setup_exchange()
|
||||
|
||||
def _setup_exchange(self) -> None:
|
||||
"""Initialize CCXT OKX exchange instance."""
|
||||
self.okx_config.validate()
|
||||
|
||||
config = {
|
||||
'apiKey': self.okx_config.api_key,
|
||||
'secret': self.okx_config.secret,
|
||||
'password': self.okx_config.password,
|
||||
'sandbox': self.okx_config.demo_mode,
|
||||
'options': {
|
||||
'defaultType': 'swap', # Perpetual futures
|
||||
},
|
||||
'timeout': 30000,
|
||||
'enableRateLimit': True,
|
||||
}
|
||||
|
||||
self.exchange = ccxt.okx(config)
|
||||
|
||||
mode_str = "DEMO/SANDBOX" if self.okx_config.demo_mode else "LIVE"
|
||||
logger.info(f"OKX Exchange initialized in {mode_str} mode")
|
||||
|
||||
# Configure trading settings
|
||||
self._configure_trading_settings()
|
||||
|
||||
def _configure_trading_settings(self) -> None:
|
||||
"""Configure leverage and margin mode."""
|
||||
symbol = self.trading_config.eth_symbol
|
||||
leverage = self.trading_config.leverage
|
||||
margin_mode = self.trading_config.margin_mode
|
||||
|
||||
try:
|
||||
# Set position mode to one-way (net) first
|
||||
self.exchange.set_position_mode(False) # False = one-way mode
|
||||
logger.info("Position mode set to One-Way (Net)")
|
||||
except Exception as e:
|
||||
# Position mode might already be set
|
||||
logger.debug(f"Position mode setting: {e}")
|
||||
|
||||
try:
|
||||
# Set margin mode with leverage parameter (required by OKX)
|
||||
self.exchange.set_margin_mode(
|
||||
margin_mode,
|
||||
symbol,
|
||||
params={'lever': leverage}
|
||||
)
|
||||
logger.info(
|
||||
f"Margin mode set to {margin_mode} with {leverage}x leverage "
|
||||
f"for {symbol}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not set margin mode: {e}")
|
||||
# Try setting leverage separately
|
||||
try:
|
||||
self.exchange.set_leverage(leverage, symbol)
|
||||
logger.info(f"Leverage set to {leverage}x for {symbol}")
|
||||
except Exception as e2:
|
||||
logger.warning(f"Could not set leverage: {e2}")
|
||||
|
||||
def fetch_ohlcv(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str = "1h",
|
||||
limit: int = 500
|
||||
) -> list:
|
||||
"""
|
||||
Fetch OHLCV candle data.
|
||||
|
||||
Args:
|
||||
symbol: Trading pair symbol (e.g., "ETH/USDT:USDT")
|
||||
timeframe: Candle timeframe (e.g., "1h")
|
||||
limit: Number of candles to fetch
|
||||
|
||||
Returns:
|
||||
List of OHLCV data
|
||||
"""
|
||||
return self.exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
|
||||
|
||||
def get_balance(self) -> dict:
|
||||
"""
|
||||
Get account balance.
|
||||
|
||||
Returns:
|
||||
Balance dictionary with 'total' and 'free' USDT amounts
|
||||
"""
|
||||
balance = self.exchange.fetch_balance()
|
||||
return {
|
||||
'total': balance.get('USDT', {}).get('total', 0),
|
||||
'free': balance.get('USDT', {}).get('free', 0),
|
||||
}
|
||||
|
||||
def get_positions(self) -> list:
|
||||
"""
|
||||
Get open positions.
|
||||
|
||||
Returns:
|
||||
List of open position dictionaries
|
||||
"""
|
||||
positions = self.exchange.fetch_positions()
|
||||
return [p for p in positions if float(p.get('contracts', 0)) != 0]
|
||||
|
||||
def get_position(self, symbol: str) -> Optional[dict]:
|
||||
"""
|
||||
Get position for a specific symbol.
|
||||
|
||||
Args:
|
||||
symbol: Trading pair symbol
|
||||
|
||||
Returns:
|
||||
Position dictionary or None if no position
|
||||
"""
|
||||
positions = self.get_positions()
|
||||
for pos in positions:
|
||||
if pos.get('symbol') == symbol:
|
||||
return pos
|
||||
return None
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
amount: float,
|
||||
reduce_only: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Place a market order.
|
||||
|
||||
Args:
|
||||
symbol: Trading pair symbol
|
||||
side: "buy" or "sell"
|
||||
amount: Order amount in base currency
|
||||
reduce_only: If True, only reduce existing position
|
||||
|
||||
Returns:
|
||||
Order result dictionary
|
||||
"""
|
||||
params = {
|
||||
'tdMode': self.trading_config.margin_mode,
|
||||
}
|
||||
if reduce_only:
|
||||
params['reduceOnly'] = True
|
||||
|
||||
order = self.exchange.create_market_order(
|
||||
symbol, side, amount, params=params
|
||||
)
|
||||
logger.info(
|
||||
f"Market {side.upper()} order placed: {amount} {symbol} "
|
||||
f"@ market price, order_id={order['id']}"
|
||||
)
|
||||
return order
|
||||
|
||||
def place_limit_order(
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
amount: float,
|
||||
price: float,
|
||||
reduce_only: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Place a limit order.
|
||||
|
||||
Args:
|
||||
symbol: Trading pair symbol
|
||||
side: "buy" or "sell"
|
||||
amount: Order amount in base currency
|
||||
price: Limit price
|
||||
reduce_only: If True, only reduce existing position
|
||||
|
||||
Returns:
|
||||
Order result dictionary
|
||||
"""
|
||||
params = {
|
||||
'tdMode': self.trading_config.margin_mode,
|
||||
}
|
||||
if reduce_only:
|
||||
params['reduceOnly'] = True
|
||||
|
||||
order = self.exchange.create_limit_order(
|
||||
symbol, side, amount, price, params=params
|
||||
)
|
||||
logger.info(
|
||||
f"Limit {side.upper()} order placed: {amount} {symbol} "
|
||||
f"@ {price}, order_id={order['id']}"
|
||||
)
|
||||
return order
|
||||
|
||||
def set_stop_loss_take_profit(
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
amount: float,
|
||||
stop_loss_price: float,
|
||||
take_profit_price: float
|
||||
) -> tuple:
|
||||
"""
|
||||
Set stop-loss and take-profit orders.
|
||||
|
||||
Args:
|
||||
symbol: Trading pair symbol
|
||||
side: Position side ("long" or "short")
|
||||
amount: Position size
|
||||
stop_loss_price: Stop-loss trigger price
|
||||
take_profit_price: Take-profit trigger price
|
||||
|
||||
Returns:
|
||||
Tuple of (sl_order, tp_order)
|
||||
"""
|
||||
# For long position: SL sells, TP sells
|
||||
# For short position: SL buys, TP buys
|
||||
close_side = "sell" if side == "long" else "buy"
|
||||
|
||||
# Stop-loss order
|
||||
sl_params = {
|
||||
'tdMode': self.trading_config.margin_mode,
|
||||
'reduceOnly': True,
|
||||
'stopLossPrice': stop_loss_price,
|
||||
}
|
||||
|
||||
try:
|
||||
sl_order = self.exchange.create_order(
|
||||
symbol, 'market', close_side, amount,
|
||||
params={
|
||||
'tdMode': self.trading_config.margin_mode,
|
||||
'reduceOnly': True,
|
||||
'slTriggerPx': str(stop_loss_price),
|
||||
'slOrdPx': '-1', # Market price
|
||||
}
|
||||
)
|
||||
logger.info(f"Stop-loss set at {stop_loss_price}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not set stop-loss: {e}")
|
||||
sl_order = None
|
||||
|
||||
# Take-profit order
|
||||
try:
|
||||
tp_order = self.exchange.create_order(
|
||||
symbol, 'market', close_side, amount,
|
||||
params={
|
||||
'tdMode': self.trading_config.margin_mode,
|
||||
'reduceOnly': True,
|
||||
'tpTriggerPx': str(take_profit_price),
|
||||
'tpOrdPx': '-1', # Market price
|
||||
}
|
||||
)
|
||||
logger.info(f"Take-profit set at {take_profit_price}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not set take-profit: {e}")
|
||||
tp_order = None
|
||||
|
||||
return sl_order, tp_order
|
||||
|
||||
def close_position(self, symbol: str) -> Optional[dict]:
|
||||
"""
|
||||
Close an open position.
|
||||
|
||||
Args:
|
||||
symbol: Trading pair symbol
|
||||
|
||||
Returns:
|
||||
Order result or None if no position
|
||||
"""
|
||||
position = self.get_position(symbol)
|
||||
if not position:
|
||||
logger.info(f"No open position for {symbol}")
|
||||
return None
|
||||
|
||||
contracts = abs(float(position.get('contracts', 0)))
|
||||
if contracts == 0:
|
||||
return None
|
||||
|
||||
side = position.get('side', 'long')
|
||||
close_side = "sell" if side == "long" else "buy"
|
||||
|
||||
order = self.place_market_order(
|
||||
symbol, close_side, contracts, reduce_only=True
|
||||
)
|
||||
logger.info(f"Position closed for {symbol}")
|
||||
return order
|
||||
|
||||
def get_ticker(self, symbol: str) -> dict:
|
||||
"""
|
||||
Get current ticker/price for a symbol.
|
||||
|
||||
Args:
|
||||
symbol: Trading pair symbol
|
||||
|
||||
Returns:
|
||||
Ticker dictionary with 'last', 'bid', 'ask' prices
|
||||
"""
|
||||
return self.exchange.fetch_ticker(symbol)
|
||||
|
||||
def get_funding_rate(self, symbol: str) -> float:
|
||||
"""
|
||||
Get current funding rate for a perpetual symbol.
|
||||
|
||||
Args:
|
||||
symbol: Trading pair symbol
|
||||
|
||||
Returns:
|
||||
Current funding rate as decimal
|
||||
"""
|
||||
try:
|
||||
funding = self.exchange.fetch_funding_rate(symbol)
|
||||
return float(funding.get('fundingRate', 0))
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch funding rate: {e}")
|
||||
return 0.0
|
||||
369
live_trading/position_manager.py
Normal file
369
live_trading/position_manager.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
Position Manager for Live Trading.
|
||||
|
||||
Tracks open positions, manages risk, and handles SL/TP logic.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field, asdict
|
||||
|
||||
from .okx_client import OKXClient
|
||||
from .config import TradingConfig, PathConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Position:
|
||||
"""Represents an open trading position."""
|
||||
trade_id: str
|
||||
symbol: str
|
||||
side: str # "long" or "short"
|
||||
entry_price: float
|
||||
entry_time: str # ISO format
|
||||
size: float # Amount in base currency (e.g., ETH)
|
||||
size_usdt: float # Notional value in USDT
|
||||
stop_loss_price: float
|
||||
take_profit_price: float
|
||||
current_price: float = 0.0
|
||||
unrealized_pnl: float = 0.0
|
||||
unrealized_pnl_pct: float = 0.0
|
||||
order_id: str = "" # Entry order ID from exchange
|
||||
|
||||
def update_pnl(self, current_price: float) -> None:
|
||||
"""Update unrealized PnL based on current price."""
|
||||
self.current_price = current_price
|
||||
|
||||
if self.side == "long":
|
||||
self.unrealized_pnl = (current_price - self.entry_price) * self.size
|
||||
self.unrealized_pnl_pct = (current_price / self.entry_price - 1) * 100
|
||||
else: # short
|
||||
self.unrealized_pnl = (self.entry_price - current_price) * self.size
|
||||
self.unrealized_pnl_pct = (1 - current_price / self.entry_price) * 100
|
||||
|
||||
def should_stop_loss(self, current_price: float) -> bool:
|
||||
"""Check if stop-loss should trigger."""
|
||||
if self.side == "long":
|
||||
return current_price <= self.stop_loss_price
|
||||
return current_price >= self.stop_loss_price
|
||||
|
||||
def should_take_profit(self, current_price: float) -> bool:
|
||||
"""Check if take-profit should trigger."""
|
||||
if self.side == "long":
|
||||
return current_price >= self.take_profit_price
|
||||
return current_price <= self.take_profit_price
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> 'Position':
|
||||
"""Create Position from dictionary."""
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class PositionManager:
|
||||
"""
|
||||
Manages trading positions with persistence.
|
||||
|
||||
Tracks open positions, enforces risk limits, and handles
|
||||
position lifecycle (open, update, close).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
okx_client: OKXClient,
|
||||
trading_config: TradingConfig,
|
||||
path_config: PathConfig
|
||||
):
|
||||
self.client = okx_client
|
||||
self.config = trading_config
|
||||
self.paths = path_config
|
||||
self.positions: dict[str, Position] = {}
|
||||
self.trade_log: list[dict] = []
|
||||
self._load_positions()
|
||||
|
||||
def _load_positions(self) -> None:
|
||||
"""Load positions from file."""
|
||||
if self.paths.positions_file.exists():
|
||||
try:
|
||||
with open(self.paths.positions_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
for trade_id, pos_data in data.items():
|
||||
self.positions[trade_id] = Position.from_dict(pos_data)
|
||||
logger.info(f"Loaded {len(self.positions)} positions from file")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load positions: {e}")
|
||||
|
||||
def save_positions(self) -> None:
|
||||
"""Save positions to file."""
|
||||
try:
|
||||
data = {
|
||||
trade_id: pos.to_dict()
|
||||
for trade_id, pos in self.positions.items()
|
||||
}
|
||||
with open(self.paths.positions_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
logger.debug(f"Saved {len(self.positions)} positions")
|
||||
except Exception as e:
|
||||
logger.error(f"Could not save positions: {e}")
|
||||
|
||||
def can_open_position(self) -> bool:
|
||||
"""Check if we can open a new position."""
|
||||
return len(self.positions) < self.config.max_concurrent_positions
|
||||
|
||||
def get_position_for_symbol(self, symbol: str) -> Optional[Position]:
|
||||
"""Get position for a specific symbol."""
|
||||
for pos in self.positions.values():
|
||||
if pos.symbol == symbol:
|
||||
return pos
|
||||
return None
|
||||
|
||||
def open_position(
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
entry_price: float,
|
||||
size: float,
|
||||
stop_loss_price: float,
|
||||
take_profit_price: float,
|
||||
order_id: str = ""
|
||||
) -> Optional[Position]:
|
||||
"""
|
||||
Open a new position.
|
||||
|
||||
Args:
|
||||
symbol: Trading pair symbol
|
||||
side: "long" or "short"
|
||||
entry_price: Entry price
|
||||
size: Position size in base currency
|
||||
stop_loss_price: Stop-loss price
|
||||
take_profit_price: Take-profit price
|
||||
order_id: Entry order ID from exchange
|
||||
|
||||
Returns:
|
||||
Position object or None if failed
|
||||
"""
|
||||
if not self.can_open_position():
|
||||
logger.warning("Cannot open position: max concurrent positions reached")
|
||||
return None
|
||||
|
||||
# Check if already have position for this symbol
|
||||
existing = self.get_position_for_symbol(symbol)
|
||||
if existing:
|
||||
logger.warning(f"Already have position for {symbol}")
|
||||
return None
|
||||
|
||||
# Generate trade ID
|
||||
now = datetime.now(timezone.utc)
|
||||
trade_id = f"{symbol}_{now.strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
position = Position(
|
||||
trade_id=trade_id,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
entry_price=entry_price,
|
||||
entry_time=now.isoformat(),
|
||||
size=size,
|
||||
size_usdt=entry_price * size,
|
||||
stop_loss_price=stop_loss_price,
|
||||
take_profit_price=take_profit_price,
|
||||
current_price=entry_price,
|
||||
order_id=order_id,
|
||||
)
|
||||
|
||||
self.positions[trade_id] = position
|
||||
self.save_positions()
|
||||
|
||||
logger.info(
|
||||
f"Opened {side.upper()} position: {size} {symbol} @ {entry_price}, "
|
||||
f"SL={stop_loss_price}, TP={take_profit_price}"
|
||||
)
|
||||
|
||||
return position
|
||||
|
||||
def close_position(
|
||||
self,
|
||||
trade_id: str,
|
||||
exit_price: float,
|
||||
reason: str = "manual",
|
||||
exit_order_id: str = ""
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Close a position and record the trade.
|
||||
|
||||
Args:
|
||||
trade_id: Position trade ID
|
||||
exit_price: Exit price
|
||||
reason: Reason for closing (e.g., "stop_loss", "take_profit", "signal")
|
||||
exit_order_id: Exit order ID from exchange
|
||||
|
||||
Returns:
|
||||
Trade record dictionary
|
||||
"""
|
||||
if trade_id not in self.positions:
|
||||
logger.warning(f"Position {trade_id} not found")
|
||||
return None
|
||||
|
||||
position = self.positions[trade_id]
|
||||
position.update_pnl(exit_price)
|
||||
|
||||
# Calculate final PnL
|
||||
entry_time = datetime.fromisoformat(position.entry_time)
|
||||
exit_time = datetime.now(timezone.utc)
|
||||
hold_duration = (exit_time - entry_time).total_seconds() / 3600 # hours
|
||||
|
||||
trade_record = {
|
||||
'trade_id': trade_id,
|
||||
'symbol': position.symbol,
|
||||
'side': position.side,
|
||||
'entry_price': position.entry_price,
|
||||
'exit_price': exit_price,
|
||||
'size': position.size,
|
||||
'size_usdt': position.size_usdt,
|
||||
'pnl_usd': position.unrealized_pnl,
|
||||
'pnl_pct': position.unrealized_pnl_pct,
|
||||
'entry_time': position.entry_time,
|
||||
'exit_time': exit_time.isoformat(),
|
||||
'hold_duration_hours': hold_duration,
|
||||
'reason': reason,
|
||||
'order_id_entry': position.order_id,
|
||||
'order_id_exit': exit_order_id,
|
||||
}
|
||||
|
||||
self.trade_log.append(trade_record)
|
||||
del self.positions[trade_id]
|
||||
self.save_positions()
|
||||
self._append_trade_log(trade_record)
|
||||
|
||||
logger.info(
|
||||
f"Closed {position.side.upper()} position: {position.size} {position.symbol} "
|
||||
f"@ {exit_price}, PnL=${position.unrealized_pnl:.2f} ({position.unrealized_pnl_pct:.2f}%), "
|
||||
f"reason={reason}"
|
||||
)
|
||||
|
||||
return trade_record
|
||||
|
||||
def _append_trade_log(self, trade_record: dict) -> None:
|
||||
"""Append trade record to CSV log file."""
|
||||
import csv
|
||||
|
||||
file_exists = self.paths.trade_log_file.exists()
|
||||
|
||||
with open(self.paths.trade_log_file, 'a', newline='') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=trade_record.keys())
|
||||
if not file_exists:
|
||||
writer.writeheader()
|
||||
writer.writerow(trade_record)
|
||||
|
||||
def update_positions(self, current_prices: dict[str, float]) -> list[dict]:
|
||||
"""
|
||||
Update all positions with current prices and check SL/TP.
|
||||
|
||||
Args:
|
||||
current_prices: Dictionary of symbol -> current price
|
||||
|
||||
Returns:
|
||||
List of closed trade records
|
||||
"""
|
||||
closed_trades = []
|
||||
|
||||
for trade_id in list(self.positions.keys()):
|
||||
position = self.positions[trade_id]
|
||||
|
||||
if position.symbol not in current_prices:
|
||||
continue
|
||||
|
||||
current_price = current_prices[position.symbol]
|
||||
position.update_pnl(current_price)
|
||||
|
||||
# Check stop-loss
|
||||
if position.should_stop_loss(current_price):
|
||||
logger.warning(
|
||||
f"Stop-loss triggered for {trade_id} at {current_price}"
|
||||
)
|
||||
# Close position on exchange
|
||||
exit_order_id = ""
|
||||
try:
|
||||
exit_order = self.client.close_position(position.symbol)
|
||||
exit_order_id = exit_order.get('id', '') if exit_order else ''
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to close position on exchange: {e}")
|
||||
|
||||
record = self.close_position(trade_id, current_price, "stop_loss", exit_order_id)
|
||||
if record:
|
||||
closed_trades.append(record)
|
||||
continue
|
||||
|
||||
# Check take-profit
|
||||
if position.should_take_profit(current_price):
|
||||
logger.info(
|
||||
f"Take-profit triggered for {trade_id} at {current_price}"
|
||||
)
|
||||
# Close position on exchange
|
||||
exit_order_id = ""
|
||||
try:
|
||||
exit_order = self.client.close_position(position.symbol)
|
||||
exit_order_id = exit_order.get('id', '') if exit_order else ''
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to close position on exchange: {e}")
|
||||
|
||||
record = self.close_position(trade_id, current_price, "take_profit", exit_order_id)
|
||||
if record:
|
||||
closed_trades.append(record)
|
||||
|
||||
self.save_positions()
|
||||
return closed_trades
|
||||
|
||||
def sync_with_exchange(self) -> None:
|
||||
"""
|
||||
Sync local positions with exchange positions.
|
||||
|
||||
Reconciles any discrepancies between local tracking
|
||||
and actual exchange positions.
|
||||
"""
|
||||
try:
|
||||
exchange_positions = self.client.get_positions()
|
||||
exchange_symbols = {p['symbol'] for p in exchange_positions}
|
||||
|
||||
# Check for positions we have locally but not on exchange
|
||||
for trade_id in list(self.positions.keys()):
|
||||
pos = self.positions[trade_id]
|
||||
if pos.symbol not in exchange_symbols:
|
||||
logger.warning(
|
||||
f"Position {trade_id} not found on exchange, removing"
|
||||
)
|
||||
# Get last price and close
|
||||
try:
|
||||
ticker = self.client.get_ticker(pos.symbol)
|
||||
exit_price = ticker['last']
|
||||
except Exception:
|
||||
exit_price = pos.current_price
|
||||
|
||||
self.close_position(trade_id, exit_price, "sync_removed")
|
||||
|
||||
logger.info(f"Position sync complete: {len(self.positions)} local positions")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Position sync failed: {e}")
|
||||
|
||||
def get_portfolio_summary(self) -> dict:
|
||||
"""
|
||||
Get portfolio summary.
|
||||
|
||||
Returns:
|
||||
Dictionary with portfolio statistics
|
||||
"""
|
||||
total_exposure = sum(p.size_usdt for p in self.positions.values())
|
||||
total_unrealized_pnl = sum(p.unrealized_pnl for p in self.positions.values())
|
||||
|
||||
return {
|
||||
'open_positions': len(self.positions),
|
||||
'total_exposure_usdt': total_exposure,
|
||||
'total_unrealized_pnl': total_unrealized_pnl,
|
||||
'positions': [p.to_dict() for p in self.positions.values()],
|
||||
}
|
||||
Reference in New Issue
Block a user