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:
2026-01-14 21:44:04 +08:00
parent 1e4cb87da3
commit 0c82c4f366
53 changed files with 8328 additions and 0 deletions

135
live_trading/README.md Normal file
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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

View 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()],
}