Add core infrastructure: config, Binance adapter, docs, and auto-setup
- Add Pydantic settings with trading mode validation (paper/testnet/live) - Implement Binance USDⓈ-M Futures adapter with hedge mode, isolated margin - Add type definitions for orders, positions, and market data - Create documentation (PLAN.md, ARCHITECTURE.md, SECURITY.md) - Add setup.sh with uv/pip auto-detection for consistent dev environments - Configure Docker multi-stage build and docker-compose services - Add pyproject.toml with all dependencies and tool configs
This commit is contained in:
73
.env.example
Normal file
73
.env.example
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# TradeFinder Environment Configuration
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
# NEVER commit .env to version control
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BINANCE CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
# For testnet, get keys from: https://testnet.binancefuture.com/
|
||||||
|
# For production (later), get from: https://www.binance.com/en/my/settings/api-management
|
||||||
|
|
||||||
|
# Testnet API keys (safe for paper trading)
|
||||||
|
BINANCE_TESTNET_API_KEY=your_testnet_api_key_here
|
||||||
|
BINANCE_TESTNET_SECRET=your_testnet_secret_here
|
||||||
|
|
||||||
|
# Production API keys (ONLY for live trading - leave empty initially)
|
||||||
|
# BINANCE_API_KEY=
|
||||||
|
# BINANCE_SECRET=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TRADING CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
# Paper trading equity (in USDT)
|
||||||
|
PAPER_EQUITY_USDT=3000.0
|
||||||
|
|
||||||
|
# Risk parameters
|
||||||
|
RISK_PER_TRADE_PCT=2.0 # 1-3% risk per trade
|
||||||
|
MAX_EQUITY_PER_STRATEGY_PCT=25.0 # Max 25% per strategy
|
||||||
|
MAX_LEVERAGE=2 # Fixed leverage initially
|
||||||
|
|
||||||
|
# Symbols to trade (comma-separated)
|
||||||
|
SYMBOLS=BTCUSDT,ETHUSDT
|
||||||
|
|
||||||
|
# Timeframes
|
||||||
|
SIGNAL_TIMEFRAME=4h
|
||||||
|
EXECUTION_TIMEFRAME=1m
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
# DuckDB file path (relative to data mount)
|
||||||
|
DUCKDB_PATH=/data/tradefinder.duckdb
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FX RATES (for CHF/EUR conversion)
|
||||||
|
# =============================================================================
|
||||||
|
# You can use a free API like exchangerate-api.com
|
||||||
|
# FX_API_KEY=your_fx_api_key_here
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EMAIL REPORTS (optional, later feature)
|
||||||
|
# =============================================================================
|
||||||
|
# SMTP_HOST=smtp.gmail.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USER=your_email@gmail.com
|
||||||
|
# SMTP_PASSWORD=your_app_password
|
||||||
|
# REPORT_RECIPIENT=your_email@example.com
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# STREAMLIT UI
|
||||||
|
# =============================================================================
|
||||||
|
STREAMLIT_PORT=8501
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOGGING
|
||||||
|
# =============================================================================
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_FORMAT=json # json or console
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# MODE
|
||||||
|
# =============================================================================
|
||||||
|
# Options: paper, testnet, live
|
||||||
|
TRADING_MODE=testnet
|
||||||
77
.gitignore
vendored
Normal file
77
.gitignore
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.uv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
.pytest_cache/
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Ruff
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
*.duckdb
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Secrets (never commit these)
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
secrets/
|
||||||
69
Dockerfile
Normal file
69
Dockerfile
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# TradeFinder Dockerfile
|
||||||
|
# Multi-stage build for engine and UI targets
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BASE IMAGE
|
||||||
|
# =============================================================================
|
||||||
|
FROM python:3.11-slim AS base
|
||||||
|
|
||||||
|
# System dependencies for TA-Lib and general build
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
wget \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install TA-Lib C library
|
||||||
|
RUN wget -q http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz \
|
||||||
|
&& tar -xzf ta-lib-0.4.0-src.tar.gz \
|
||||||
|
&& cd ta-lib \
|
||||||
|
&& ./configure --prefix=/usr \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& cd .. \
|
||||||
|
&& rm -rf ta-lib ta-lib-0.4.0-src.tar.gz
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy project files
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip \
|
||||||
|
&& pip install --no-cache-dir -e ".[dev]"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ENGINE TARGET (trading engine, backtester, optimizer)
|
||||||
|
# =============================================================================
|
||||||
|
FROM base AS engine
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN useradd --create-home --shell /bin/bash trader
|
||||||
|
RUN mkdir -p /data && chown -R trader:trader /data /app
|
||||||
|
|
||||||
|
USER trader
|
||||||
|
|
||||||
|
# Default command
|
||||||
|
CMD ["python", "-m", "tradefinder.core.main"]
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# UI TARGET (Streamlit dashboard)
|
||||||
|
# =============================================================================
|
||||||
|
FROM base AS ui
|
||||||
|
|
||||||
|
# Install additional UI dependencies
|
||||||
|
RUN pip install --no-cache-dir streamlit plotly
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd --create-home --shell /bin/bash trader
|
||||||
|
RUN mkdir -p /data && chown -R trader:trader /data /app
|
||||||
|
|
||||||
|
USER trader
|
||||||
|
|
||||||
|
# Expose Streamlit port
|
||||||
|
EXPOSE 8501
|
||||||
|
|
||||||
|
# Default command
|
||||||
|
CMD ["streamlit", "run", "src/tradefinder/ui/app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
||||||
131
README.md
131
README.md
@@ -0,0 +1,131 @@
|
|||||||
|
# TradeFinder
|
||||||
|
|
||||||
|
Automated crypto trading system for BTC/ETH perpetual futures with regime-adaptive strategy selection, risk management, and full transparency via web UI.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Clone and Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd TradeFinder
|
||||||
|
|
||||||
|
# Run setup (auto-detects uv or uses pip)
|
||||||
|
chmod +x setup.sh
|
||||||
|
./setup.sh
|
||||||
|
|
||||||
|
# Activate virtual environment
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure API Keys
|
||||||
|
|
||||||
|
Get testnet API keys from: https://testnet.binancefuture.com
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit .env file
|
||||||
|
nano .env # or your preferred editor
|
||||||
|
|
||||||
|
# Add your keys:
|
||||||
|
# BINANCE_TESTNET_API_KEY=your_key_here
|
||||||
|
# BINANCE_TESTNET_SECRET=your_secret_here
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Setup (Alternative)
|
||||||
|
|
||||||
|
If you prefer manual setup:
|
||||||
|
|
||||||
|
### With uv (Recommended - 10-100x faster)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install uv if not present
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
|
# Create venv and install
|
||||||
|
uv venv .venv --python 3.11
|
||||||
|
uv pip install -e ".[dev]" --python .venv/bin/python
|
||||||
|
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
### With pip
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.11 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
TradeFinder/
|
||||||
|
├── src/tradefinder/
|
||||||
|
│ ├── adapters/ # Exchange connectivity
|
||||||
|
│ │ ├── base.py # Abstract interface
|
||||||
|
│ │ ├── binance_usdm.py # Binance Futures
|
||||||
|
│ │ └── types.py # Order, Position types
|
||||||
|
│ ├── core/ # Core engine
|
||||||
|
│ │ └── config.py # Settings management
|
||||||
|
│ ├── data/ # Market data (TODO)
|
||||||
|
│ ├── strategies/ # Trading strategies (TODO)
|
||||||
|
│ └── ui/ # Streamlit dashboard (TODO)
|
||||||
|
├── tests/ # Test suite
|
||||||
|
├── docs/ # Documentation
|
||||||
|
│ ├── PLAN.md # Project roadmap
|
||||||
|
│ ├── ARCHITECTURE.md # System design
|
||||||
|
│ └── SECURITY.md # Security guidelines
|
||||||
|
├── setup.sh # Auto-setup script
|
||||||
|
├── pyproject.toml # Project config
|
||||||
|
├── docker-compose.yml # Container setup
|
||||||
|
└── Dockerfile # Docker build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Implemented
|
||||||
|
- ✅ Configuration management (Pydantic settings)
|
||||||
|
- ✅ Binance USDⓈ-M Futures adapter
|
||||||
|
- Hedge mode support
|
||||||
|
- Isolated margin
|
||||||
|
- Leverage configuration
|
||||||
|
- Limit/stop-market orders
|
||||||
|
- ✅ Type definitions (Order, Position, etc.)
|
||||||
|
- ✅ Docker containerization
|
||||||
|
|
||||||
|
### Planned
|
||||||
|
- ⏳ Data ingestion (OHLCV, funding rates)
|
||||||
|
- ⏳ Regime classifier (trend/range/volatility)
|
||||||
|
- ⏳ Strategy suite (supertrend, squeeze, mean-rev)
|
||||||
|
- ⏳ Risk engine & allocator
|
||||||
|
- ⏳ Streamlit UI
|
||||||
|
- ⏳ Walk-forward optimization
|
||||||
|
|
||||||
|
## Trading Scope
|
||||||
|
|
||||||
|
| Parameter | Value |
|
||||||
|
|-----------|-------|
|
||||||
|
| Exchange | Binance USDⓈ-M Futures |
|
||||||
|
| Symbols | BTCUSDT, ETHUSDT |
|
||||||
|
| Mode | Hedge (long+short) |
|
||||||
|
| Margin | Isolated |
|
||||||
|
| Leverage | 2x (configurable) |
|
||||||
|
| Signal TF | 4h |
|
||||||
|
| Execution TF | 1m |
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Development Plan](docs/PLAN.md) - Roadmap & milestones
|
||||||
|
- [Architecture](docs/ARCHITECTURE.md) - System design
|
||||||
|
- [Security](docs/SECURITY.md) - API key handling
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
146
docker-compose.yml
Normal file
146
docker-compose.yml
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
# TradeFinder - Automated Crypto Trading System
|
||||||
|
# Mount point: /opt/trading/crypto (configurable via DATA_ROOT)
|
||||||
|
|
||||||
|
x-common: &common
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- tradefinder-net
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ==========================================================================
|
||||||
|
# CORE TRADING ENGINE
|
||||||
|
# ==========================================================================
|
||||||
|
engine:
|
||||||
|
<<: *common
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: engine
|
||||||
|
container_name: tf-engine
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- DUCKDB_PATH=/data/engine/tradefinder.duckdb
|
||||||
|
volumes:
|
||||||
|
- ${DATA_ROOT:-/opt/trading/crypto}/engine:/data/engine
|
||||||
|
- ${DATA_ROOT:-/opt/trading/crypto}/shared:/data/shared
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import tradefinder; print('ok')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
command: ["python", "-m", "tradefinder.core.main"]
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# STREAMLIT UI
|
||||||
|
# ==========================================================================
|
||||||
|
ui:
|
||||||
|
<<: *common
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: ui
|
||||||
|
container_name: tf-ui
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- DUCKDB_PATH=/data/engine/tradefinder.duckdb
|
||||||
|
ports:
|
||||||
|
- "${STREAMLIT_PORT:-8501}:8501"
|
||||||
|
volumes:
|
||||||
|
- ${DATA_ROOT:-/opt/trading/crypto}/engine:/data/engine:ro
|
||||||
|
- ${DATA_ROOT:-/opt/trading/crypto}/shared:/data/shared
|
||||||
|
depends_on:
|
||||||
|
- engine
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8501/_stcore/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
command: ["streamlit", "run", "src/tradefinder/ui/app.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# OPTIMIZER (runs weekly via scheduler or manually)
|
||||||
|
# ==========================================================================
|
||||||
|
optimizer:
|
||||||
|
<<: *common
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: engine
|
||||||
|
container_name: tf-optimizer
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- DUCKDB_PATH=/data/engine/tradefinder.duckdb
|
||||||
|
volumes:
|
||||||
|
- ${DATA_ROOT:-/opt/trading/crypto}/engine:/data/engine
|
||||||
|
- ${DATA_ROOT:-/opt/trading/crypto}/optimizer:/data/optimizer
|
||||||
|
- ${DATA_ROOT:-/opt/trading/crypto}/shared:/data/shared
|
||||||
|
profiles:
|
||||||
|
- optimizer # Only runs when explicitly started: docker compose --profile optimizer up optimizer
|
||||||
|
command: ["python", "-m", "tradefinder.core.optimize"]
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# REDIS (for real-time state, pub/sub, caching)
|
||||||
|
# ==========================================================================
|
||||||
|
redis:
|
||||||
|
<<: *common
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: tf-redis
|
||||||
|
volumes:
|
||||||
|
- ${DATA_ROOT:-/opt/trading/crypto}/redis:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# BACKTESTER (runs on-demand)
|
||||||
|
# ==========================================================================
|
||||||
|
backtester:
|
||||||
|
<<: *common
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: engine
|
||||||
|
container_name: tf-backtester
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- DUCKDB_PATH=/data/engine/tradefinder.duckdb
|
||||||
|
volumes:
|
||||||
|
- ${DATA_ROOT:-/opt/trading/crypto}/engine:/data/engine
|
||||||
|
- ${DATA_ROOT:-/opt/trading/crypto}/backtest:/data/backtest
|
||||||
|
- ${DATA_ROOT:-/opt/trading/crypto}/shared:/data/shared
|
||||||
|
profiles:
|
||||||
|
- backtest # Only runs when explicitly started
|
||||||
|
command: ["python", "-m", "tradefinder.core.backtest"]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
tradefinder-net:
|
||||||
|
driver: bridge
|
||||||
|
name: tradefinder-network
|
||||||
|
|
||||||
|
# Volume labels for clarity
|
||||||
|
# All data persisted under ${DATA_ROOT:-/opt/trading/crypto}/
|
||||||
|
# ├── engine/ # DuckDB, order logs, positions
|
||||||
|
# ├── optimizer/ # Optuna studies, best params
|
||||||
|
# ├── backtest/ # Backtest results, reports
|
||||||
|
# ├── redis/ # Redis AOF persistence
|
||||||
|
# └── shared/ # Shared configs, FX rates cache
|
||||||
370
docs/ARCHITECTURE.md
Normal file
370
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# TradeFinder Architecture
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ TradeFinder │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
|
||||||
|
│ │ Streamlit │ │ Engine │ │ Optimizer │ │ Backtester│ │
|
||||||
|
│ │ UI │◄───►│ (Core) │◄───►│ (Optuna) │ │ │ │
|
||||||
|
│ └─────────────┘ └──────┬──────┘ └─────────────┘ └───────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────────────┼───────────────────┐ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Regime │ │ Risk │ │ Order │ │
|
||||||
|
│ │ Classifier │ │ Engine │ │ Manager │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Strategies │ │ Allocator │ │ Exchange │ │
|
||||||
|
│ │ Suite │ │ │ │ Adapter │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
|
||||||
|
│ │ │
|
||||||
|
├──────────────────────────────────────────────────┼──────────────────────────┤
|
||||||
|
│ Data Layer │ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ │ │
|
||||||
|
│ │ DuckDB │ │ Redis │ │ │
|
||||||
|
│ │ (Analytics) │ │ (State) │ │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ │ │
|
||||||
|
└──────────────────────────────────────────────────┼──────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────┐
|
||||||
|
│ Binance │
|
||||||
|
│ Futures API │
|
||||||
|
│ (Testnet) │
|
||||||
|
└───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Details
|
||||||
|
|
||||||
|
### 1. Core Engine (`src/tradefinder/core/`)
|
||||||
|
|
||||||
|
The main orchestrator that coordinates all components.
|
||||||
|
|
||||||
|
```
|
||||||
|
core/
|
||||||
|
├── __init__.py
|
||||||
|
├── config.py # Pydantic settings
|
||||||
|
├── main.py # Entry point, event loop
|
||||||
|
├── engine.py # Trading engine orchestrator
|
||||||
|
├── backtest.py # Backtesting entry point
|
||||||
|
└── optimize.py # Optimization entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
**Responsibilities**:
|
||||||
|
- Load configuration from environment
|
||||||
|
- Initialize all components
|
||||||
|
- Run main trading loop (signal → risk → order)
|
||||||
|
- Handle graceful shutdown
|
||||||
|
|
||||||
|
### 2. Exchange Adapters (`src/tradefinder/adapters/`)
|
||||||
|
|
||||||
|
Abstract interface for exchange connectivity with Binance implementation.
|
||||||
|
|
||||||
|
```
|
||||||
|
adapters/
|
||||||
|
├── __init__.py
|
||||||
|
├── base.py # Abstract ExchangeAdapter interface
|
||||||
|
├── binance_usdm.py # Binance USDⓈ-M Futures implementation
|
||||||
|
└── types.py # Shared types (Order, Position, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Features**:
|
||||||
|
- Hedge mode support (dual position side)
|
||||||
|
- Isolated margin per symbol
|
||||||
|
- Order types: limit, stop-market, market
|
||||||
|
- WebSocket streams for real-time data
|
||||||
|
|
||||||
|
**Binance USDⓈ-M Specifics**:
|
||||||
|
```python
|
||||||
|
# Required API calls on initialization
|
||||||
|
POST /fapi/v1/positionSide/dual # Enable hedge mode
|
||||||
|
POST /fapi/v1/marginType # Set ISOLATED margin
|
||||||
|
POST /fapi/v1/leverage # Set leverage per symbol
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Data Layer (`src/tradefinder/data/`)
|
||||||
|
|
||||||
|
Market data ingestion, storage, and retrieval.
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
├── __init__.py
|
||||||
|
├── fetcher.py # REST historical data fetcher
|
||||||
|
├── streamer.py # WebSocket real-time streams
|
||||||
|
├── storage.py # DuckDB operations
|
||||||
|
└── schemas.py # Database schemas
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Sources**:
|
||||||
|
| Data Type | Source | Frequency |
|
||||||
|
|-----------|--------|-----------|
|
||||||
|
| OHLCV | REST (backfill) + WS (live) | 1m, 5m, 4h |
|
||||||
|
| Mark Price | WebSocket | Real-time |
|
||||||
|
| Funding Rate | REST + WS | 8h intervals |
|
||||||
|
| Order Book | WebSocket (optional) | Real-time |
|
||||||
|
|
||||||
|
**Storage Schema** (DuckDB):
|
||||||
|
```sql
|
||||||
|
CREATE TABLE candles (
|
||||||
|
symbol VARCHAR,
|
||||||
|
timeframe VARCHAR,
|
||||||
|
timestamp TIMESTAMP,
|
||||||
|
open DOUBLE,
|
||||||
|
high DOUBLE,
|
||||||
|
low DOUBLE,
|
||||||
|
close DOUBLE,
|
||||||
|
volume DOUBLE,
|
||||||
|
PRIMARY KEY (symbol, timeframe, timestamp)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE trades (
|
||||||
|
id VARCHAR PRIMARY KEY,
|
||||||
|
symbol VARCHAR,
|
||||||
|
side VARCHAR,
|
||||||
|
entry_price DOUBLE,
|
||||||
|
exit_price DOUBLE,
|
||||||
|
quantity DOUBLE,
|
||||||
|
pnl_usdt DOUBLE,
|
||||||
|
pnl_pct DOUBLE,
|
||||||
|
strategy VARCHAR,
|
||||||
|
entry_time TIMESTAMP,
|
||||||
|
exit_time TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Strategies (`src/tradefinder/strategies/`)
|
||||||
|
|
||||||
|
Trading strategy implementations with common interface.
|
||||||
|
|
||||||
|
```
|
||||||
|
strategies/
|
||||||
|
├── __init__.py
|
||||||
|
├── base.py # Abstract Strategy interface
|
||||||
|
├── supertrend.py # Trend-following
|
||||||
|
├── squeeze.py # Volatility breakout
|
||||||
|
├── mean_reversion.py # Range-bound
|
||||||
|
└── signals.py # Signal types
|
||||||
|
```
|
||||||
|
|
||||||
|
**Strategy Interface**:
|
||||||
|
```python
|
||||||
|
class Strategy(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def generate_signal(self, data: pd.DataFrame) -> Signal | None:
|
||||||
|
"""Analyze data and return entry/exit signal."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_stop_loss(self, entry_price: float, side: Side) -> float:
|
||||||
|
"""Calculate stop-loss price for risk sizing."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def parameters(self) -> dict[str, Any]:
|
||||||
|
"""Current strategy parameters (for UI display)."""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regime-Strategy Mapping**:
|
||||||
|
| Regime | Primary Strategy | Secondary |
|
||||||
|
|--------|------------------|-----------|
|
||||||
|
| Trending | Supertrend | - |
|
||||||
|
| Ranging | Mean Reversion | - |
|
||||||
|
| High Volatility | Squeeze Breakout | - |
|
||||||
|
| Uncertain | Reduce exposure | - |
|
||||||
|
|
||||||
|
### 5. Risk Engine (`src/tradefinder/core/risk.py`)
|
||||||
|
|
||||||
|
Position sizing and risk management.
|
||||||
|
|
||||||
|
**Position Sizing Formula**:
|
||||||
|
```
|
||||||
|
Position Size = (Account Equity × Risk %) / |Entry Price - Stop Loss|
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Equity: $3000
|
||||||
|
- Risk: 2% = $60
|
||||||
|
- Entry: $100,000 (BTC)
|
||||||
|
- Stop: $98,000
|
||||||
|
- Stop Distance: $2,000
|
||||||
|
|
||||||
|
Position = $60 / $2,000 = 0.03 BTC
|
||||||
|
Notional = 0.03 × $100,000 = $3,000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Risk Limits**:
|
||||||
|
| Parameter | Value |
|
||||||
|
|-----------|-------|
|
||||||
|
| Risk per trade | 1-3% |
|
||||||
|
| Max per strategy | 25% equity |
|
||||||
|
| Max total exposure | 100% equity |
|
||||||
|
| Max leverage | 2x (initial) |
|
||||||
|
|
||||||
|
### 6. Allocator (`src/tradefinder/core/allocator.py`)
|
||||||
|
|
||||||
|
Multi-strategy capital allocation.
|
||||||
|
|
||||||
|
**Allocation Rules**:
|
||||||
|
1. Each strategy gets max 25% of equity
|
||||||
|
2. Hedge mode allows simultaneous long/short
|
||||||
|
3. Reduce allocation during uncertain regimes
|
||||||
|
4. Track "portfolio heat" (total risk exposure)
|
||||||
|
|
||||||
|
### 7. Order Manager (`src/tradefinder/core/orders.py`)
|
||||||
|
|
||||||
|
Order lifecycle management.
|
||||||
|
|
||||||
|
**Order Flow**:
|
||||||
|
```
|
||||||
|
Signal → Risk Check → Size Calculation → Order Submission
|
||||||
|
│
|
||||||
|
┌─────────────────────────┼─────────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
[PENDING] [FILLED] [REJECTED]
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
[CANCELLED] [STOP PLACED]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[STOP TRIGGERED]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Order Types**:
|
||||||
|
| Purpose | Order Type | Rationale |
|
||||||
|
|---------|------------|-----------|
|
||||||
|
| Entry | Limit | Better fills, avoid slippage |
|
||||||
|
| Stop-Loss | Stop-Market | Guaranteed execution |
|
||||||
|
| Take-Profit | Limit | Optional, better exit price |
|
||||||
|
|
||||||
|
### 8. Regime Classifier (`src/tradefinder/core/regime.py`)
|
||||||
|
|
||||||
|
Market regime detection.
|
||||||
|
|
||||||
|
**Indicators Used**:
|
||||||
|
| Indicator | Purpose | Threshold |
|
||||||
|
|-----------|---------|-----------|
|
||||||
|
| ADX | Trend strength | >25 = trending |
|
||||||
|
| ATR% | Volatility | Relative to historical |
|
||||||
|
| BB Width | Squeeze detection | Narrow = breakout pending |
|
||||||
|
|
||||||
|
**Regime States**:
|
||||||
|
```python
|
||||||
|
class Regime(Enum):
|
||||||
|
TRENDING_UP = "trending_up"
|
||||||
|
TRENDING_DOWN = "trending_down"
|
||||||
|
RANGING = "ranging"
|
||||||
|
HIGH_VOLATILITY = "high_volatility"
|
||||||
|
UNCERTAIN = "uncertain"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. UI Layer (`src/tradefinder/ui/`)
|
||||||
|
|
||||||
|
Streamlit dashboard for monitoring.
|
||||||
|
|
||||||
|
```
|
||||||
|
ui/
|
||||||
|
├── __init__.py
|
||||||
|
├── app.py # Main Streamlit app
|
||||||
|
├── pages/
|
||||||
|
│ ├── dashboard.py
|
||||||
|
│ ├── trades.py
|
||||||
|
│ ├── strategies.py
|
||||||
|
│ └── optimizer.py
|
||||||
|
└── components/
|
||||||
|
├── charts.py
|
||||||
|
└── tables.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dashboard Sections**:
|
||||||
|
1. **Overview**: Equity, PnL (USDT/CHF/EUR), positions
|
||||||
|
2. **Regime**: Current market state, indicator values
|
||||||
|
3. **Strategies**: Active strategies, parameters, allocation
|
||||||
|
4. **Trades**: Recent trades, performance metrics
|
||||||
|
5. **Optimizer**: Last run results, parameter history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Trading Loop (Every Signal Interval)
|
||||||
|
```
|
||||||
|
1. Fetch latest candles (4h)
|
||||||
|
2. Update regime classification
|
||||||
|
3. Select active strategy based on regime
|
||||||
|
4. Generate signal from strategy
|
||||||
|
5. If signal:
|
||||||
|
a. Calculate position size (risk engine)
|
||||||
|
b. Check allocation limits
|
||||||
|
c. Submit order (limit entry + stop-market)
|
||||||
|
6. Log state to DuckDB
|
||||||
|
7. Update Redis with current positions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Order Execution (1m/5m Monitoring)
|
||||||
|
```
|
||||||
|
1. Monitor open orders
|
||||||
|
2. If limit not filled within timeout:
|
||||||
|
a. Cancel and retry at new price, OR
|
||||||
|
b. Abandon if price moved too far
|
||||||
|
3. Track fills and update position
|
||||||
|
4. Ensure stop-loss is active
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Architecture
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose services
|
||||||
|
services:
|
||||||
|
engine: # Main trading loop
|
||||||
|
ui: # Streamlit dashboard
|
||||||
|
redis: # Real-time state
|
||||||
|
optimizer: # Weekly Optuna runs
|
||||||
|
backtester: # On-demand backtesting
|
||||||
|
```
|
||||||
|
|
||||||
|
**Volume Mounts**:
|
||||||
|
```
|
||||||
|
/opt/trading/crypto/
|
||||||
|
├── engine/ # Engine logs, state
|
||||||
|
├── optimizer/ # Optimization results
|
||||||
|
├── redis/ # Redis persistence
|
||||||
|
└── shared/ # DuckDB, shared data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Error Type | Response |
|
||||||
|
|------------|----------|
|
||||||
|
| Exchange API error | Retry with backoff, log, alert if persistent |
|
||||||
|
| Order rejected | Log reason, notify, do not retry blindly |
|
||||||
|
| WebSocket disconnect | Auto-reconnect with backoff |
|
||||||
|
| Invalid signal | Log and skip, continue loop |
|
||||||
|
| Risk limit exceeded | Block order, log warning |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
See [SECURITY.md](./SECURITY.md) for detailed security guidelines.
|
||||||
|
|
||||||
|
**Key Points**:
|
||||||
|
- API keys in environment variables only
|
||||||
|
- No withdrawal permissions on API keys
|
||||||
|
- Testnet keys separate from production
|
||||||
|
- Secrets never logged
|
||||||
195
docs/PLAN.md
Normal file
195
docs/PLAN.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# TradeFinder Development Plan
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
|
||||||
|
Automated crypto trading system for BTC/ETH perpetual futures with regime-adaptive strategy selection, risk management, and full transparency via web UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Foundation (Current)
|
||||||
|
|
||||||
|
**Goal**: Core infrastructure, exchange connectivity, paper trading capability
|
||||||
|
|
||||||
|
### Milestone 1.1: Project Setup ✅
|
||||||
|
- [x] Repository structure
|
||||||
|
- [x] pyproject.toml with dependencies
|
||||||
|
- [x] Docker configuration
|
||||||
|
- [x] Environment template
|
||||||
|
|
||||||
|
### Milestone 1.2: Configuration & Core
|
||||||
|
- [ ] Pydantic settings with validation
|
||||||
|
- [ ] Structured logging (structlog)
|
||||||
|
- [ ] Error handling patterns
|
||||||
|
|
||||||
|
### Milestone 1.3: Exchange Adapter
|
||||||
|
- [ ] Abstract exchange interface
|
||||||
|
- [ ] Binance USDⓈ-M Futures adapter
|
||||||
|
- [ ] Hedge mode (dual position side)
|
||||||
|
- [ ] Isolated margin per symbol
|
||||||
|
- [ ] Leverage configuration
|
||||||
|
- [ ] Order types: limit, stop-market
|
||||||
|
- [ ] Testnet connectivity verification
|
||||||
|
|
||||||
|
### Milestone 1.4: Data Ingestion
|
||||||
|
- [ ] REST OHLCV fetcher (historical backfill)
|
||||||
|
- [ ] WebSocket streams (real-time)
|
||||||
|
- [ ] Kline/candlestick
|
||||||
|
- [ ] Mark price
|
||||||
|
- [ ] Funding rate
|
||||||
|
- [ ] DuckDB storage schema
|
||||||
|
- [ ] Data validation & gap detection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Trading Logic
|
||||||
|
|
||||||
|
**Goal**: Regime detection, strategies, risk management
|
||||||
|
|
||||||
|
### Milestone 2.1: Regime Classifier
|
||||||
|
- [ ] ADX-based trend detection
|
||||||
|
- [ ] ATR% volatility measurement
|
||||||
|
- [ ] Bollinger Band width for squeeze detection
|
||||||
|
- [ ] Regime states: trending, ranging, high_volatility
|
||||||
|
|
||||||
|
### Milestone 2.2: Strategy Suite
|
||||||
|
- [ ] Base strategy interface
|
||||||
|
- [ ] Supertrend (trend-following)
|
||||||
|
- [ ] Squeeze Breakout (volatility expansion)
|
||||||
|
- [ ] Mean Reversion (range-bound)
|
||||||
|
- [ ] Grid logic (optional, later)
|
||||||
|
|
||||||
|
### Milestone 2.3: Risk Engine
|
||||||
|
- [ ] Position sizing from stop distance + risk%
|
||||||
|
- [ ] Per-trade risk limits (1-3%)
|
||||||
|
- [ ] Strategy allocation caps (25% max)
|
||||||
|
- [ ] Portfolio heat tracking
|
||||||
|
|
||||||
|
### Milestone 2.4: Order Manager
|
||||||
|
- [ ] Limit order entries with retry logic
|
||||||
|
- [ ] Stop-market circuit breakers
|
||||||
|
- [ ] Order state machine
|
||||||
|
- [ ] Fill tracking & slippage logging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Optimization & Backtesting
|
||||||
|
|
||||||
|
**Goal**: Walk-forward optimization, performance measurement
|
||||||
|
|
||||||
|
### Milestone 3.1: Backtester
|
||||||
|
- [ ] Event-driven backtesting engine
|
||||||
|
- [ ] Realistic fill simulation (slippage, fees)
|
||||||
|
- [ ] Funding rate impact modeling
|
||||||
|
- [ ] Multi-symbol support
|
||||||
|
|
||||||
|
### Milestone 3.2: Optimizer
|
||||||
|
- [ ] Optuna integration
|
||||||
|
- [ ] Walk-forward validation
|
||||||
|
- [ ] Objective functions: Calmar, Sortino
|
||||||
|
- [ ] Weekly scheduled optimization runs
|
||||||
|
|
||||||
|
### Milestone 3.3: Scorecard
|
||||||
|
- [ ] Performance metrics calculation
|
||||||
|
- [ ] Alpha, Beta
|
||||||
|
- [ ] Sharpe, Sortino, Calmar ratios
|
||||||
|
- [ ] Max drawdown, CAGR
|
||||||
|
- [ ] Win rate, profit factor
|
||||||
|
- [ ] CHF/EUR currency conversion
|
||||||
|
- [ ] Historical comparison
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Interface
|
||||||
|
|
||||||
|
**Goal**: Full transparency via Streamlit dashboard
|
||||||
|
|
||||||
|
### Milestone 4.1: Core Dashboard
|
||||||
|
- [ ] Account overview (equity, PnL)
|
||||||
|
- [ ] Current positions display
|
||||||
|
- [ ] Recent trades table
|
||||||
|
|
||||||
|
### Milestone 4.2: Regime & Strategy View
|
||||||
|
- [ ] Real-time regime indicator
|
||||||
|
- [ ] Active strategy display
|
||||||
|
- [ ] Parameter visibility
|
||||||
|
- [ ] Allocation breakdown
|
||||||
|
|
||||||
|
### Milestone 4.3: Analytics
|
||||||
|
- [ ] Performance charts (equity curve)
|
||||||
|
- [ ] Drawdown visualization
|
||||||
|
- [ ] Trade distribution analysis
|
||||||
|
- [ ] Optimizer results viewer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Production Readiness
|
||||||
|
|
||||||
|
**Goal**: Reliable deployment, monitoring, alerting
|
||||||
|
|
||||||
|
### Milestone 5.1: CI/CD
|
||||||
|
- [ ] Gitea Actions pipeline
|
||||||
|
- [ ] Container builds & registry push
|
||||||
|
- [ ] Automated testing on PR
|
||||||
|
- [ ] Version tagging
|
||||||
|
|
||||||
|
### Milestone 5.2: Monitoring
|
||||||
|
- [ ] Health check endpoints
|
||||||
|
- [ ] Error alerting (email/webhook)
|
||||||
|
- [ ] Performance logging
|
||||||
|
- [ ] Resource monitoring
|
||||||
|
|
||||||
|
### Milestone 5.3: Documentation
|
||||||
|
- [x] PLAN.md (this file)
|
||||||
|
- [ ] ARCHITECTURE.md
|
||||||
|
- [ ] SECURITY.md
|
||||||
|
- [ ] RUNBOOK.md (operations)
|
||||||
|
- [ ] API reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Enhancements (Future)
|
||||||
|
|
||||||
|
### Email Reports
|
||||||
|
- [ ] Daily/weekly PDF reports
|
||||||
|
- [ ] Embedded charts
|
||||||
|
- [ ] Performance summary
|
||||||
|
|
||||||
|
### Additional Exchanges
|
||||||
|
- [ ] Bybit USDT perpetuals
|
||||||
|
- [ ] Alpaca crypto
|
||||||
|
- [ ] Abstract multi-exchange support
|
||||||
|
|
||||||
|
### Advanced Strategies
|
||||||
|
- [ ] Dynamic leverage based on regime
|
||||||
|
- [ ] Cross-margin mode (optional)
|
||||||
|
- [ ] Funding rate arbitrage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constraints & Decisions
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|----------|-----------|
|
||||||
|
| Custom engine (not Freqtrade) | Freqtrade futures requires one-way mode; we need hedge mode |
|
||||||
|
| Binance USDⓈ-M only (initial) | Best testnet support, most liquidity |
|
||||||
|
| Hedge mode ON | Long+short simultaneously for strategy isolation |
|
||||||
|
| Isolated margin | Contain risk per position |
|
||||||
|
| 4h signal timeframe | Longer swings, less noise, lower fees |
|
||||||
|
| Limit orders for entry | Better fills, avoids slippage |
|
||||||
|
| Stop-market for exits | Guaranteed execution on stop-loss |
|
||||||
|
| DuckDB for analytics | Fast OLAP queries, single file |
|
||||||
|
| Redis for state | Real-time position/order tracking |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timeline (Estimated)
|
||||||
|
|
||||||
|
| Phase | Duration | Status |
|
||||||
|
|-------|----------|--------|
|
||||||
|
| Phase 1: Foundation | 2 weeks | In Progress |
|
||||||
|
| Phase 2: Trading Logic | 3 weeks | Not Started |
|
||||||
|
| Phase 3: Optimization | 2 weeks | Not Started |
|
||||||
|
| Phase 4: UI | 1 week | Not Started |
|
||||||
|
| Phase 5: Production | 1 week | Not Started |
|
||||||
|
|
||||||
|
**Target**: Paper trading operational in ~8 weeks
|
||||||
270
docs/SECURITY.md
Normal file
270
docs/SECURITY.md
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
# TradeFinder Security Guidelines
|
||||||
|
|
||||||
|
## API Key Management
|
||||||
|
|
||||||
|
### Key Permissions
|
||||||
|
|
||||||
|
**Binance Futures API Keys**:
|
||||||
|
| Permission | Required | Notes |
|
||||||
|
|------------|----------|-------|
|
||||||
|
| Enable Futures | Yes | Required for USDⓈ-M trading |
|
||||||
|
| Enable Reading | Yes | Account info, positions, orders |
|
||||||
|
| Enable Spot & Margin Trading | No | Not needed for futures |
|
||||||
|
| Enable Withdrawals | **NEVER** | Do not enable under any circumstances |
|
||||||
|
| Enable Internal Transfer | No | Not needed |
|
||||||
|
|
||||||
|
### Key Separation
|
||||||
|
|
||||||
|
Maintain separate API keys for:
|
||||||
|
1. **Testnet** (paper trading): Lower security concern, can be more permissive
|
||||||
|
2. **Production** (live trading): Maximum security, IP whitelist required
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env structure
|
||||||
|
BINANCE_TESTNET_API_KEY=xxx # Testnet keys
|
||||||
|
BINANCE_TESTNET_SECRET=xxx
|
||||||
|
|
||||||
|
BINANCE_API_KEY=xxx # Production keys (empty until ready)
|
||||||
|
BINANCE_SECRET=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
### IP Whitelisting
|
||||||
|
|
||||||
|
For production keys:
|
||||||
|
1. Enable IP restriction in Binance API settings
|
||||||
|
2. Whitelist only the server IP running the bot
|
||||||
|
3. If using Docker, whitelist the host machine's public IP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets Storage
|
||||||
|
|
||||||
|
### Environment Variables (Required)
|
||||||
|
|
||||||
|
All secrets must be stored in environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local development
|
||||||
|
cp .env.example .env
|
||||||
|
chmod 600 .env # Restrict file permissions
|
||||||
|
|
||||||
|
# Docker deployment
|
||||||
|
docker run --env-file .env tradefinder
|
||||||
|
```
|
||||||
|
|
||||||
|
### What NOT to Do
|
||||||
|
|
||||||
|
| Violation | Risk |
|
||||||
|
|-----------|------|
|
||||||
|
| Commit `.env` to git | Keys exposed in repo history forever |
|
||||||
|
| Hardcode keys in source | Keys exposed to anyone with code access |
|
||||||
|
| Log API keys | Keys visible in log files |
|
||||||
|
| Share testnet keys | Still bad practice, builds bad habits |
|
||||||
|
|
||||||
|
### .gitignore
|
||||||
|
|
||||||
|
Ensure these patterns are in `.gitignore`:
|
||||||
|
```gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
secrets/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logging Security
|
||||||
|
|
||||||
|
### Sensitive Data Handling
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD - Never log secrets
|
||||||
|
logger.info(f"API Key: {api_key}")
|
||||||
|
|
||||||
|
# GOOD - Mask sensitive data
|
||||||
|
logger.info(f"API Key: {api_key[:4]}...{api_key[-4:]}")
|
||||||
|
|
||||||
|
# BEST - Don't log at all
|
||||||
|
logger.info("API credentials loaded successfully")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Structlog Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Filter sensitive fields from logs
|
||||||
|
def mask_sensitive(_, __, event_dict):
|
||||||
|
sensitive_keys = ['api_key', 'secret', 'password', 'token']
|
||||||
|
for key in sensitive_keys:
|
||||||
|
if key in event_dict:
|
||||||
|
event_dict[key] = "***MASKED***"
|
||||||
|
return event_dict
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Network Security
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Environment | Base URL | Notes |
|
||||||
|
|-------------|----------|-------|
|
||||||
|
| Testnet | `https://testnet.binancefuture.com` | Safe for testing |
|
||||||
|
| Production | `https://fapi.binance.com` | Real money |
|
||||||
|
|
||||||
|
### Request Signing
|
||||||
|
|
||||||
|
All authenticated requests must be signed:
|
||||||
|
```python
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
def sign_request(params: dict, secret: str) -> str:
|
||||||
|
query_string = urlencode(params)
|
||||||
|
signature = hmac.new(
|
||||||
|
secret.encode('utf-8'),
|
||||||
|
query_string.encode('utf-8'),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
return signature
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Respect Binance rate limits to avoid IP bans:
|
||||||
|
| Limit Type | Value |
|
||||||
|
|------------|-------|
|
||||||
|
| Request weight | 2400/minute |
|
||||||
|
| Order rate | 300/minute |
|
||||||
|
| WebSocket connections | 5/second |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Security
|
||||||
|
|
||||||
|
### Container Isolation
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml security settings
|
||||||
|
services:
|
||||||
|
engine:
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
user: "1000:1000" # Non-root user
|
||||||
|
```
|
||||||
|
|
||||||
|
### Secrets in Docker
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Use Docker secrets (production)
|
||||||
|
secrets:
|
||||||
|
binance_api_key:
|
||||||
|
file: ./secrets/binance_api_key.txt
|
||||||
|
binance_secret:
|
||||||
|
file: ./secrets/binance_secret.txt
|
||||||
|
|
||||||
|
services:
|
||||||
|
engine:
|
||||||
|
secrets:
|
||||||
|
- binance_api_key
|
||||||
|
- binance_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational Security
|
||||||
|
|
||||||
|
### Pre-Deployment Checklist
|
||||||
|
|
||||||
|
- [ ] API keys have NO withdrawal permission
|
||||||
|
- [ ] Production keys are IP-whitelisted
|
||||||
|
- [ ] `.env` is not in git history
|
||||||
|
- [ ] Logs do not contain secrets
|
||||||
|
- [ ] Container runs as non-root
|
||||||
|
- [ ] Redis is not exposed to public network
|
||||||
|
- [ ] DuckDB file has restricted permissions
|
||||||
|
|
||||||
|
### Monitoring for Compromise
|
||||||
|
|
||||||
|
Watch for:
|
||||||
|
- Unexpected positions or orders
|
||||||
|
- API calls from unknown IPs (check Binance API logs)
|
||||||
|
- Unusual account balance changes
|
||||||
|
- Failed authentication attempts
|
||||||
|
|
||||||
|
### Incident Response
|
||||||
|
|
||||||
|
If keys are compromised:
|
||||||
|
1. **Immediately** delete API key in Binance console
|
||||||
|
2. Check for unauthorized trades/withdrawals
|
||||||
|
3. Generate new API key with fresh permissions
|
||||||
|
4. Review how compromise occurred
|
||||||
|
5. Update security practices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Security
|
||||||
|
|
||||||
|
### Testnet Isolation
|
||||||
|
|
||||||
|
- Always start with testnet credentials
|
||||||
|
- Verify `TRADING_MODE=testnet` before any trade logic
|
||||||
|
- Production code should fail-safe if mode is unclear
|
||||||
|
|
||||||
|
```python
|
||||||
|
def validate_mode(config: Config) -> None:
|
||||||
|
if config.trading_mode == TradingMode.LIVE:
|
||||||
|
if not config.binance_api_key:
|
||||||
|
raise ValueError("Production API key required for live trading")
|
||||||
|
if config.binance_testnet_api_key:
|
||||||
|
logger.warning("Testnet keys present in live mode - ignoring")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paper Trading
|
||||||
|
|
||||||
|
For in-house simulation (no API needed):
|
||||||
|
- Use simulated order fills
|
||||||
|
- No actual API calls
|
||||||
|
- Safe for strategy development
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Security
|
||||||
|
|
||||||
|
### Regular Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for security vulnerabilities
|
||||||
|
pip audit
|
||||||
|
|
||||||
|
# Update dependencies
|
||||||
|
pip install --upgrade -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pinned Versions
|
||||||
|
|
||||||
|
Use exact versions in production:
|
||||||
|
```toml
|
||||||
|
# pyproject.toml
|
||||||
|
dependencies = [
|
||||||
|
"ccxt>=4.2.0,<5.0.0", # Pin major version
|
||||||
|
"pydantic>=2.5.0,<3.0.0",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Category | Requirement |
|
||||||
|
|----------|-------------|
|
||||||
|
| API Keys | No withdrawal permission, IP whitelisted |
|
||||||
|
| Secrets | Environment variables only, never committed |
|
||||||
|
| Logging | Mask all sensitive data |
|
||||||
|
| Network | HTTPS only, signed requests |
|
||||||
|
| Container | Non-root, read-only where possible |
|
||||||
|
| Monitoring | Watch for unauthorized activity |
|
||||||
123
pyproject.toml
Normal file
123
pyproject.toml
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "tradefinder"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Automated crypto trading system with regime detection, multi-strategy allocation, and risk management"
|
||||||
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
authors = [
|
||||||
|
{ name = "TradeFinder Team" }
|
||||||
|
]
|
||||||
|
keywords = ["trading", "crypto", "bitcoin", "algorithmic-trading", "binance"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
# Exchange connectivity
|
||||||
|
"ccxt>=4.2.0",
|
||||||
|
"websockets>=12.0",
|
||||||
|
"aiohttp>=3.9.0",
|
||||||
|
|
||||||
|
# Data processing
|
||||||
|
"pandas>=2.1.0",
|
||||||
|
"numpy>=1.26.0",
|
||||||
|
"polars>=0.20.0",
|
||||||
|
"duckdb>=0.10.0",
|
||||||
|
|
||||||
|
# Technical analysis
|
||||||
|
"pandas-ta>=0.3.14b",
|
||||||
|
"ta-lib>=0.4.28",
|
||||||
|
|
||||||
|
# Optimization
|
||||||
|
"optuna>=3.5.0",
|
||||||
|
|
||||||
|
# Configuration & validation
|
||||||
|
"pydantic>=2.5.0",
|
||||||
|
"pydantic-settings>=2.1.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
"pyyaml>=6.0.1",
|
||||||
|
|
||||||
|
# Web UI
|
||||||
|
"streamlit>=1.30.0",
|
||||||
|
"plotly>=5.18.0",
|
||||||
|
|
||||||
|
# Async & scheduling
|
||||||
|
"asyncio-throttle>=1.0.2",
|
||||||
|
"apscheduler>=3.10.4",
|
||||||
|
|
||||||
|
# Logging & monitoring
|
||||||
|
"structlog>=24.1.0",
|
||||||
|
"rich>=13.7.0",
|
||||||
|
|
||||||
|
# HTTP client
|
||||||
|
"httpx>=0.26.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.4.0",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"mypy>=1.8.0",
|
||||||
|
"pre-commit>=3.6.0",
|
||||||
|
]
|
||||||
|
email = [
|
||||||
|
"jinja2>=3.1.0",
|
||||||
|
"weasyprint>=60.0",
|
||||||
|
"aiosmtplib>=3.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
tradefinder = "tradefinder.core.main:main"
|
||||||
|
tf-backtest = "tradefinder.core.backtest:main"
|
||||||
|
tf-optimize = "tradefinder.core.optimize:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Documentation = "https://github.com/owner/tradefinder#readme"
|
||||||
|
Issues = "https://github.com/owner/tradefinder/issues"
|
||||||
|
Source = "https://github.com/owner/tradefinder"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/tradefinder"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 100
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"UP", # pyupgrade
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"E501", # line too long (handled by formatter)
|
||||||
|
"B008", # do not perform function calls in argument defaults
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.isort]
|
||||||
|
known-first-party = ["tradefinder"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
strict = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-v --cov=tradefinder --cov-report=term-missing"
|
||||||
150
setup.sh
Executable file
150
setup.sh
Executable file
@@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# TradeFinder Development Setup Script
|
||||||
|
# Works with both uv (preferred) and traditional venv+pip
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||||
|
|
||||||
|
# Check Python version
|
||||||
|
check_python() {
|
||||||
|
if command -v python3.11 &> /dev/null; then
|
||||||
|
PYTHON_CMD="python3.11"
|
||||||
|
elif command -v python3.12 &> /dev/null; then
|
||||||
|
PYTHON_CMD="python3.12"
|
||||||
|
elif command -v python3 &> /dev/null; then
|
||||||
|
PYTHON_CMD="python3"
|
||||||
|
else
|
||||||
|
error "Python 3.11+ not found. Please install Python 3.11 or later."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify version
|
||||||
|
PY_VERSION=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||||
|
PY_MAJOR=$($PYTHON_CMD -c 'import sys; print(sys.version_info.major)')
|
||||||
|
PY_MINOR=$($PYTHON_CMD -c 'import sys; print(sys.version_info.minor)')
|
||||||
|
|
||||||
|
if [[ $PY_MAJOR -lt 3 ]] || [[ $PY_MAJOR -eq 3 && $PY_MINOR -lt 11 ]]; then
|
||||||
|
error "Python 3.11+ required, found $PY_VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Using Python $PY_VERSION ($PYTHON_CMD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup with uv (preferred - much faster)
|
||||||
|
setup_with_uv() {
|
||||||
|
info "Setting up with uv (fast mode)..."
|
||||||
|
|
||||||
|
# Create venv and install dependencies in one go
|
||||||
|
uv venv .venv --python "$PYTHON_CMD"
|
||||||
|
|
||||||
|
# Install the package with dev dependencies
|
||||||
|
uv pip install -e ".[dev]" --python .venv/bin/python
|
||||||
|
|
||||||
|
info "uv setup complete!"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup with traditional venv + pip
|
||||||
|
setup_with_pip() {
|
||||||
|
info "Setting up with venv + pip..."
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
$PYTHON_CMD -m venv .venv
|
||||||
|
|
||||||
|
# Activate and upgrade pip
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install --upgrade pip wheel setuptools
|
||||||
|
|
||||||
|
# Install the package with dev dependencies
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
info "pip setup complete!"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install uv if not present (optional, user choice)
|
||||||
|
install_uv() {
|
||||||
|
info "Installing uv package manager..."
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
|
# Add to current shell
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
|
||||||
|
if command -v uv &> /dev/null; then
|
||||||
|
info "uv installed successfully!"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
warn "uv installation may require shell restart"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main setup logic
|
||||||
|
main() {
|
||||||
|
echo ""
|
||||||
|
echo "╔═══════════════════════════════════════════╗"
|
||||||
|
echo "║ TradeFinder Development Setup ║"
|
||||||
|
echo "╚═══════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
check_python
|
||||||
|
|
||||||
|
# Check if uv is available
|
||||||
|
if command -v uv &> /dev/null; then
|
||||||
|
UV_VERSION=$(uv --version 2>/dev/null || echo "unknown")
|
||||||
|
info "Found uv: $UV_VERSION"
|
||||||
|
setup_with_uv
|
||||||
|
else
|
||||||
|
warn "uv not found. It's 10-100x faster than pip."
|
||||||
|
echo ""
|
||||||
|
read -p "Install uv? (recommended) [Y/n]: " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]] || [[ -z $REPLY ]]; then
|
||||||
|
if install_uv; then
|
||||||
|
setup_with_uv
|
||||||
|
else
|
||||||
|
warn "Falling back to pip..."
|
||||||
|
setup_with_pip
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "Using traditional pip..."
|
||||||
|
setup_with_pip
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create .env from example if it doesn't exist
|
||||||
|
if [[ ! -f .env ]] && [[ -f .env.example ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
info "Created .env from .env.example"
|
||||||
|
warn "Edit .env and add your Binance testnet API keys!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
info "Setup complete! Next steps:"
|
||||||
|
echo ""
|
||||||
|
echo " 1. Activate the virtual environment:"
|
||||||
|
echo " source .venv/bin/activate"
|
||||||
|
echo ""
|
||||||
|
echo " 2. Edit .env with your Binance testnet keys:"
|
||||||
|
echo " Get keys from: https://testnet.binancefuture.com"
|
||||||
|
echo ""
|
||||||
|
echo " 3. Run tests:"
|
||||||
|
echo " pytest"
|
||||||
|
echo ""
|
||||||
|
echo " 4. Start developing!"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
0
src/tradefinder/__init__.py
Normal file
0
src/tradefinder/__init__.py
Normal file
69
src/tradefinder/adapters/__init__.py
Normal file
69
src/tradefinder/adapters/__init__.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Exchange adapters for TradeFinder.
|
||||||
|
|
||||||
|
This module provides abstract and concrete exchange adapters for
|
||||||
|
connecting to cryptocurrency exchanges.
|
||||||
|
|
||||||
|
Supported Exchanges:
|
||||||
|
- Binance USDⓈ-M Perpetual Futures (primary)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from tradefinder.adapters import BinanceUSDMAdapter
|
||||||
|
from tradefinder.adapters.types import OrderRequest, Side, PositionSide, OrderType
|
||||||
|
|
||||||
|
adapter = BinanceUSDMAdapter(settings)
|
||||||
|
await adapter.connect()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from tradefinder.adapters.base import (
|
||||||
|
AuthenticationError,
|
||||||
|
ExchangeAdapter,
|
||||||
|
ExchangeError,
|
||||||
|
InsufficientMarginError,
|
||||||
|
OrderNotFoundError,
|
||||||
|
OrderValidationError,
|
||||||
|
RateLimitError,
|
||||||
|
)
|
||||||
|
from tradefinder.adapters.binance_usdm import BinanceUSDMAdapter
|
||||||
|
from tradefinder.adapters.types import (
|
||||||
|
AccountBalance,
|
||||||
|
Candle,
|
||||||
|
FundingRate,
|
||||||
|
MarginType,
|
||||||
|
Order,
|
||||||
|
OrderRequest,
|
||||||
|
OrderStatus,
|
||||||
|
OrderType,
|
||||||
|
Position,
|
||||||
|
PositionSide,
|
||||||
|
Side,
|
||||||
|
SymbolInfo,
|
||||||
|
TimeInForce,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Base classes
|
||||||
|
"ExchangeAdapter",
|
||||||
|
# Implementations
|
||||||
|
"BinanceUSDMAdapter",
|
||||||
|
# Types
|
||||||
|
"AccountBalance",
|
||||||
|
"Candle",
|
||||||
|
"FundingRate",
|
||||||
|
"MarginType",
|
||||||
|
"Order",
|
||||||
|
"OrderRequest",
|
||||||
|
"OrderStatus",
|
||||||
|
"OrderType",
|
||||||
|
"Position",
|
||||||
|
"PositionSide",
|
||||||
|
"Side",
|
||||||
|
"SymbolInfo",
|
||||||
|
"TimeInForce",
|
||||||
|
# Exceptions
|
||||||
|
"ExchangeError",
|
||||||
|
"AuthenticationError",
|
||||||
|
"OrderValidationError",
|
||||||
|
"InsufficientMarginError",
|
||||||
|
"OrderNotFoundError",
|
||||||
|
"RateLimitError",
|
||||||
|
]
|
||||||
374
src/tradefinder/adapters/base.py
Normal file
374
src/tradefinder/adapters/base.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
"""Abstract base class for exchange adapters.
|
||||||
|
|
||||||
|
All exchange implementations must inherit from ExchangeAdapter
|
||||||
|
and implement the abstract methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from tradefinder.adapters.types import (
|
||||||
|
AccountBalance,
|
||||||
|
Candle,
|
||||||
|
FundingRate,
|
||||||
|
MarginType,
|
||||||
|
Order,
|
||||||
|
OrderRequest,
|
||||||
|
Position,
|
||||||
|
PositionSide,
|
||||||
|
SymbolInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeAdapter(ABC):
|
||||||
|
"""Abstract base class for exchange connectivity.
|
||||||
|
|
||||||
|
Provides a unified interface for:
|
||||||
|
- Account management
|
||||||
|
- Order management
|
||||||
|
- Position management
|
||||||
|
- Market data retrieval
|
||||||
|
- Exchange-specific configuration (leverage, margin type, etc.)
|
||||||
|
|
||||||
|
All implementations must support hedge mode for position management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Initialization & Configuration
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""Establish connection to the exchange.
|
||||||
|
|
||||||
|
This should:
|
||||||
|
- Validate API credentials
|
||||||
|
- Set up WebSocket connections if needed
|
||||||
|
- Verify account access
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConnectionError: If unable to connect
|
||||||
|
AuthenticationError: If credentials are invalid
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Clean up connections and resources."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def configure_hedge_mode(self, enable: bool = True) -> None:
|
||||||
|
"""Enable or disable hedge mode (dual position side).
|
||||||
|
|
||||||
|
In hedge mode, long and short positions are tracked separately.
|
||||||
|
This is required for running multiple strategies that may have
|
||||||
|
opposing positions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enable: True to enable hedge mode, False for one-way mode
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ExchangeError: If configuration fails
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def configure_margin_type(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
margin_type: MarginType,
|
||||||
|
) -> None:
|
||||||
|
"""Set margin type for a symbol.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading pair (e.g., "BTCUSDT")
|
||||||
|
margin_type: ISOLATED or CROSS
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ExchangeError: If configuration fails
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def configure_leverage(self, symbol: str, leverage: int) -> None:
|
||||||
|
"""Set leverage for a symbol.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading pair
|
||||||
|
leverage: Leverage multiplier (1-125 depending on exchange)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ExchangeError: If configuration fails
|
||||||
|
ValueError: If leverage is out of allowed range
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Account Information
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_balance(self, asset: str = "USDT") -> AccountBalance:
|
||||||
|
"""Get account balance for an asset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
asset: Asset symbol (default: USDT)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccountBalance with wallet, margin, and available balances
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_positions(self, symbol: str | None = None) -> list[Position]:
|
||||||
|
"""Get current positions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Optional symbol filter. If None, returns all positions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Position objects (may include zero-quantity positions)
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_open_orders(self, symbol: str | None = None) -> list[Order]:
|
||||||
|
"""Get all open orders.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Optional symbol filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of open Order objects
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Order Management
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create_order(self, request: OrderRequest) -> Order:
|
||||||
|
"""Create a new order.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: OrderRequest with all order parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Order object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OrderValidationError: If order parameters are invalid
|
||||||
|
InsufficientMarginError: If not enough margin
|
||||||
|
ExchangeError: For other exchange-level errors
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def cancel_order(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
order_id: str | None = None,
|
||||||
|
client_order_id: str | None = None,
|
||||||
|
) -> Order:
|
||||||
|
"""Cancel an open order.
|
||||||
|
|
||||||
|
Must provide either order_id or client_order_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading pair
|
||||||
|
order_id: Exchange order ID
|
||||||
|
client_order_id: Client-assigned order ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cancelled Order object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OrderNotFoundError: If order doesn't exist
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def cancel_all_orders(self, symbol: str) -> list[Order]:
|
||||||
|
"""Cancel all open orders for a symbol.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading pair
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of cancelled Order objects
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_order(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
order_id: str | None = None,
|
||||||
|
client_order_id: str | None = None,
|
||||||
|
) -> Order:
|
||||||
|
"""Get order by ID.
|
||||||
|
|
||||||
|
Must provide either order_id or client_order_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading pair
|
||||||
|
order_id: Exchange order ID
|
||||||
|
client_order_id: Client-assigned order ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Order object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OrderNotFoundError: If order doesn't exist
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Market Data
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_candles(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
timeframe: str,
|
||||||
|
limit: int = 500,
|
||||||
|
start_time: int | None = None,
|
||||||
|
end_time: int | None = None,
|
||||||
|
) -> list[Candle]:
|
||||||
|
"""Fetch historical candlestick data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading pair
|
||||||
|
timeframe: Candle interval (e.g., "1m", "4h", "1d")
|
||||||
|
limit: Maximum number of candles to return
|
||||||
|
start_time: Start timestamp in milliseconds
|
||||||
|
end_time: End timestamp in milliseconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Candle objects, oldest first
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_mark_price(self, symbol: str) -> Decimal:
|
||||||
|
"""Get current mark price for a symbol.
|
||||||
|
|
||||||
|
Mark price is used for liquidation and PnL calculations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading pair
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current mark price as Decimal
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_funding_rate(self, symbol: str) -> FundingRate:
|
||||||
|
"""Get current/next funding rate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading pair
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FundingRate with rate, time, and mark price
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_symbol_info(self, symbol: str) -> SymbolInfo:
|
||||||
|
"""Get trading rules and precision for a symbol.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading pair
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SymbolInfo with precision, limits, and step sizes
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Utility Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def round_price(self, symbol: str, price: Decimal) -> Decimal:
|
||||||
|
"""Round price to valid tick size for symbol.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading pair
|
||||||
|
price: Price to round
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Price rounded to valid tick size
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def round_quantity(self, symbol: str, quantity: Decimal) -> Decimal:
|
||||||
|
"""Round quantity to valid step size for symbol.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading pair
|
||||||
|
quantity: Quantity to round
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quantity rounded to valid step size
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def close_position(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
position_side: PositionSide,
|
||||||
|
) -> Order | None:
|
||||||
|
"""Close an existing position with a market order.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: Trading pair
|
||||||
|
position_side: LONG or SHORT
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Order object if position was closed, None if no position existed
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeError(Exception):
|
||||||
|
"""Base exception for exchange errors."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationError(ExchangeError):
|
||||||
|
"""Invalid or expired API credentials."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OrderValidationError(ExchangeError):
|
||||||
|
"""Order parameters are invalid."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientMarginError(ExchangeError):
|
||||||
|
"""Not enough margin for the order."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OrderNotFoundError(ExchangeError):
|
||||||
|
"""Order does not exist."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(ExchangeError):
|
||||||
|
"""Rate limit exceeded."""
|
||||||
|
|
||||||
|
pass
|
||||||
640
src/tradefinder/adapters/binance_usdm.py
Normal file
640
src/tradefinder/adapters/binance_usdm.py
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
"""Binance USDⓈ-M Futures adapter implementation.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Hedge mode (dual position side)
|
||||||
|
- Isolated margin
|
||||||
|
- Configurable leverage
|
||||||
|
- Limit and stop-market orders
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from tradefinder.adapters.base import (
|
||||||
|
AuthenticationError,
|
||||||
|
ExchangeAdapter,
|
||||||
|
ExchangeError,
|
||||||
|
InsufficientMarginError,
|
||||||
|
OrderNotFoundError,
|
||||||
|
OrderValidationError,
|
||||||
|
RateLimitError,
|
||||||
|
)
|
||||||
|
from tradefinder.adapters.types import (
|
||||||
|
AccountBalance,
|
||||||
|
Candle,
|
||||||
|
FundingRate,
|
||||||
|
MarginType,
|
||||||
|
Order,
|
||||||
|
OrderRequest,
|
||||||
|
OrderStatus,
|
||||||
|
OrderType,
|
||||||
|
Position,
|
||||||
|
PositionSide,
|
||||||
|
Side,
|
||||||
|
SymbolInfo,
|
||||||
|
TimeInForce,
|
||||||
|
)
|
||||||
|
from tradefinder.core.config import Settings
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BinanceUSDMAdapter(ExchangeAdapter):
|
||||||
|
"""Binance USDⓈ-M Perpetual Futures adapter.
|
||||||
|
|
||||||
|
This adapter connects to Binance Futures (either testnet or production)
|
||||||
|
and provides all functionality needed for automated trading.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Automatic hedge mode configuration
|
||||||
|
- Isolated margin per symbol
|
||||||
|
- Order management (limit, stop-market)
|
||||||
|
- Position tracking
|
||||||
|
- Market data (candles, mark price, funding)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
settings = get_settings()
|
||||||
|
adapter = BinanceUSDMAdapter(settings)
|
||||||
|
await adapter.connect()
|
||||||
|
await adapter.configure_hedge_mode(True)
|
||||||
|
await adapter.configure_margin_type("BTCUSDT", MarginType.ISOLATED)
|
||||||
|
await adapter.configure_leverage("BTCUSDT", 2)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
"""Initialize adapter with settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings: Application settings containing API credentials
|
||||||
|
"""
|
||||||
|
self.settings = settings
|
||||||
|
self.base_url = settings.binance_base_url
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
self._symbol_info_cache: dict[str, SymbolInfo] = {}
|
||||||
|
self._recv_window = 5000 # Milliseconds
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _api_key(self) -> str:
|
||||||
|
"""Get active API key."""
|
||||||
|
key = self.settings.get_active_api_key()
|
||||||
|
if key is None:
|
||||||
|
raise AuthenticationError("No API key configured for current trading mode")
|
||||||
|
return key.get_secret_value()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _secret(self) -> str:
|
||||||
|
"""Get active secret."""
|
||||||
|
secret = self.settings.get_active_secret()
|
||||||
|
if secret is None:
|
||||||
|
raise AuthenticationError("No secret configured for current trading mode")
|
||||||
|
return secret.get_secret_value()
|
||||||
|
|
||||||
|
def _sign(self, params: dict[str, Any]) -> str:
|
||||||
|
"""Generate HMAC-SHA256 signature for request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
params: Request parameters to sign
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hex-encoded signature
|
||||||
|
"""
|
||||||
|
query_string = urlencode(params)
|
||||||
|
signature = hmac.new(
|
||||||
|
self._secret.encode("utf-8"),
|
||||||
|
query_string.encode("utf-8"),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
return signature
|
||||||
|
|
||||||
|
def _get_timestamp(self) -> int:
|
||||||
|
"""Get current timestamp in milliseconds."""
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
endpoint: str,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
signed: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Make HTTP request to Binance API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method: HTTP method (GET, POST, DELETE)
|
||||||
|
endpoint: API endpoint path
|
||||||
|
params: Query/body parameters
|
||||||
|
signed: Whether request requires signature
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON response
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ExchangeError: For API errors
|
||||||
|
"""
|
||||||
|
if self._client is None:
|
||||||
|
raise ExchangeError("Not connected. Call connect() first.")
|
||||||
|
|
||||||
|
params = params or {}
|
||||||
|
headers = {"X-MBX-APIKEY": self._api_key}
|
||||||
|
|
||||||
|
if signed:
|
||||||
|
params["timestamp"] = self._get_timestamp()
|
||||||
|
params["recvWindow"] = self._recv_window
|
||||||
|
params["signature"] = self._sign(params)
|
||||||
|
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if method == "GET":
|
||||||
|
response = await self._client.get(url, params=params, headers=headers)
|
||||||
|
elif method == "POST":
|
||||||
|
response = await self._client.post(url, params=params, headers=headers)
|
||||||
|
elif method == "DELETE":
|
||||||
|
response = await self._client.delete(url, params=params, headers=headers)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
self._handle_error(response.status_code, data)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error("Request failed", endpoint=endpoint, error=str(e))
|
||||||
|
raise ExchangeError(f"Request failed: {e}") from e
|
||||||
|
|
||||||
|
def _handle_error(self, status_code: int, data: dict[str, Any]) -> None:
|
||||||
|
"""Handle API error response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status_code: HTTP status code
|
||||||
|
data: Error response body
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Appropriate exception based on error code
|
||||||
|
"""
|
||||||
|
code = data.get("code", 0)
|
||||||
|
msg = data.get("msg", "Unknown error")
|
||||||
|
|
||||||
|
logger.error("API error", status_code=status_code, code=code, msg=msg)
|
||||||
|
|
||||||
|
# Map Binance error codes to exceptions
|
||||||
|
if code == -1003:
|
||||||
|
raise RateLimitError(msg)
|
||||||
|
elif code == -2010:
|
||||||
|
raise InsufficientMarginError(msg)
|
||||||
|
elif code == -2011:
|
||||||
|
raise OrderNotFoundError(msg)
|
||||||
|
elif code in (-1021, -1022):
|
||||||
|
raise AuthenticationError(msg)
|
||||||
|
elif code == -1102:
|
||||||
|
raise OrderValidationError(msg)
|
||||||
|
else:
|
||||||
|
raise ExchangeError(f"[{code}] {msg}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Connection Management
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
"""Establish connection and validate credentials."""
|
||||||
|
self._client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|
||||||
|
# Validate credentials by fetching account info
|
||||||
|
try:
|
||||||
|
await self._request("GET", "/fapi/v2/account", signed=True)
|
||||||
|
logger.info(
|
||||||
|
"Connected to Binance Futures",
|
||||||
|
mode=self.settings.trading_mode.value,
|
||||||
|
base_url=self.base_url,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await self.disconnect()
|
||||||
|
raise AuthenticationError(f"Failed to authenticate: {e}") from e
|
||||||
|
|
||||||
|
# Load symbol info cache
|
||||||
|
await self._load_exchange_info()
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""Close HTTP client."""
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
logger.info("Disconnected from Binance Futures")
|
||||||
|
|
||||||
|
async def _load_exchange_info(self) -> None:
|
||||||
|
"""Load and cache exchange symbol information."""
|
||||||
|
data = await self._request("GET", "/fapi/v1/exchangeInfo")
|
||||||
|
|
||||||
|
for symbol_data in data.get("symbols", []):
|
||||||
|
if symbol_data.get("contractType") != "PERPETUAL":
|
||||||
|
continue
|
||||||
|
if symbol_data.get("status") != "TRADING":
|
||||||
|
continue
|
||||||
|
|
||||||
|
symbol = symbol_data["symbol"]
|
||||||
|
filters = {f["filterType"]: f for f in symbol_data.get("filters", [])}
|
||||||
|
|
||||||
|
price_filter = filters.get("PRICE_FILTER", {})
|
||||||
|
lot_size = filters.get("LOT_SIZE", {})
|
||||||
|
min_notional = filters.get("MIN_NOTIONAL", {})
|
||||||
|
|
||||||
|
self._symbol_info_cache[symbol] = SymbolInfo(
|
||||||
|
symbol=symbol,
|
||||||
|
base_asset=symbol_data["baseAsset"],
|
||||||
|
quote_asset=symbol_data["quoteAsset"],
|
||||||
|
price_precision=symbol_data["pricePrecision"],
|
||||||
|
quantity_precision=symbol_data["quantityPrecision"],
|
||||||
|
min_quantity=Decimal(lot_size.get("minQty", "0")),
|
||||||
|
max_quantity=Decimal(lot_size.get("maxQty", "0")),
|
||||||
|
min_notional=Decimal(min_notional.get("notional", "0")),
|
||||||
|
tick_size=Decimal(price_filter.get("tickSize", "0")),
|
||||||
|
step_size=Decimal(lot_size.get("stepSize", "0")),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Loaded exchange info", symbol_count=len(self._symbol_info_cache)
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Configuration
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def configure_hedge_mode(self, enable: bool = True) -> None:
|
||||||
|
"""Enable hedge mode for dual position side."""
|
||||||
|
try:
|
||||||
|
await self._request(
|
||||||
|
"POST",
|
||||||
|
"/fapi/v1/positionSide/dual",
|
||||||
|
params={"dualSidePosition": str(enable).lower()},
|
||||||
|
signed=True,
|
||||||
|
)
|
||||||
|
logger.info("Hedge mode configured", enabled=enable)
|
||||||
|
except ExchangeError as e:
|
||||||
|
# Error -4059 means already in requested mode
|
||||||
|
if "-4059" in str(e):
|
||||||
|
logger.debug("Hedge mode already set", enabled=enable)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def configure_margin_type(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
margin_type: MarginType,
|
||||||
|
) -> None:
|
||||||
|
"""Set margin type for a symbol."""
|
||||||
|
try:
|
||||||
|
await self._request(
|
||||||
|
"POST",
|
||||||
|
"/fapi/v1/marginType",
|
||||||
|
params={"symbol": symbol, "marginType": margin_type.value},
|
||||||
|
signed=True,
|
||||||
|
)
|
||||||
|
logger.info("Margin type configured", symbol=symbol, margin_type=margin_type.value)
|
||||||
|
except ExchangeError as e:
|
||||||
|
# Error -4046 means already in requested mode
|
||||||
|
if "-4046" in str(e):
|
||||||
|
logger.debug("Margin type already set", symbol=symbol, margin_type=margin_type.value)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def configure_leverage(self, symbol: str, leverage: int) -> None:
|
||||||
|
"""Set leverage for a symbol."""
|
||||||
|
await self._request(
|
||||||
|
"POST",
|
||||||
|
"/fapi/v1/leverage",
|
||||||
|
params={"symbol": symbol, "leverage": leverage},
|
||||||
|
signed=True,
|
||||||
|
)
|
||||||
|
logger.info("Leverage configured", symbol=symbol, leverage=leverage)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Account Information
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_balance(self, asset: str = "USDT") -> AccountBalance:
|
||||||
|
"""Get account balance for an asset."""
|
||||||
|
data = await self._request("GET", "/fapi/v2/balance", signed=True)
|
||||||
|
|
||||||
|
for balance in data:
|
||||||
|
if balance["asset"] == asset:
|
||||||
|
return AccountBalance(
|
||||||
|
asset=asset,
|
||||||
|
wallet_balance=Decimal(balance["balance"]),
|
||||||
|
unrealized_pnl=Decimal(balance["crossUnPnl"]),
|
||||||
|
margin_balance=Decimal(balance["balance"]),
|
||||||
|
available_balance=Decimal(balance["availableBalance"]),
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise ExchangeError(f"Asset {asset} not found in balances")
|
||||||
|
|
||||||
|
async def get_positions(self, symbol: str | None = None) -> list[Position]:
|
||||||
|
"""Get current positions."""
|
||||||
|
data = await self._request("GET", "/fapi/v2/positionRisk", signed=True)
|
||||||
|
|
||||||
|
positions = []
|
||||||
|
for pos in data:
|
||||||
|
if symbol and pos["symbol"] != symbol:
|
||||||
|
continue
|
||||||
|
|
||||||
|
positions.append(
|
||||||
|
Position(
|
||||||
|
symbol=pos["symbol"],
|
||||||
|
position_side=PositionSide(pos["positionSide"]),
|
||||||
|
quantity=Decimal(pos["positionAmt"]),
|
||||||
|
entry_price=Decimal(pos["entryPrice"]),
|
||||||
|
mark_price=Decimal(pos["markPrice"]),
|
||||||
|
unrealized_pnl=Decimal(pos["unRealizedProfit"]),
|
||||||
|
leverage=int(pos["leverage"]),
|
||||||
|
margin_type=MarginType(pos["marginType"]),
|
||||||
|
isolated_margin=Decimal(pos["isolatedMargin"])
|
||||||
|
if pos["marginType"] == "isolated"
|
||||||
|
else None,
|
||||||
|
liquidation_price=Decimal(pos["liquidationPrice"])
|
||||||
|
if Decimal(pos["liquidationPrice"]) > 0
|
||||||
|
else None,
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
raw=pos,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return positions
|
||||||
|
|
||||||
|
async def get_open_orders(self, symbol: str | None = None) -> list[Order]:
|
||||||
|
"""Get all open orders."""
|
||||||
|
params = {}
|
||||||
|
if symbol:
|
||||||
|
params["symbol"] = symbol
|
||||||
|
|
||||||
|
data = await self._request("GET", "/fapi/v1/openOrders", params=params, signed=True)
|
||||||
|
return [self._parse_order(o) for o in data]
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Order Management
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def create_order(self, request: OrderRequest) -> Order:
|
||||||
|
"""Create a new order."""
|
||||||
|
request.validate()
|
||||||
|
|
||||||
|
# Round price and quantity to valid precision
|
||||||
|
quantity = self.round_quantity(request.symbol, request.quantity)
|
||||||
|
price = self.round_price(request.symbol, request.price) if request.price else None
|
||||||
|
stop_price = (
|
||||||
|
self.round_price(request.symbol, request.stop_price)
|
||||||
|
if request.stop_price
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"symbol": request.symbol,
|
||||||
|
"side": request.side.value,
|
||||||
|
"positionSide": request.position_side.value,
|
||||||
|
"type": request.order_type.value,
|
||||||
|
"quantity": str(quantity),
|
||||||
|
}
|
||||||
|
|
||||||
|
if price:
|
||||||
|
params["price"] = str(price)
|
||||||
|
|
||||||
|
if stop_price:
|
||||||
|
params["stopPrice"] = str(stop_price)
|
||||||
|
|
||||||
|
if request.order_type == OrderType.LIMIT:
|
||||||
|
params["timeInForce"] = request.time_in_force.value
|
||||||
|
|
||||||
|
if request.reduce_only:
|
||||||
|
params["reduceOnly"] = "true"
|
||||||
|
|
||||||
|
if request.client_order_id:
|
||||||
|
params["newClientOrderId"] = request.client_order_id
|
||||||
|
|
||||||
|
data = await self._request("POST", "/fapi/v1/order", params=params, signed=True)
|
||||||
|
|
||||||
|
order = self._parse_order(data)
|
||||||
|
logger.info(
|
||||||
|
"Order created",
|
||||||
|
order_id=order.id,
|
||||||
|
symbol=order.symbol,
|
||||||
|
side=order.side.value,
|
||||||
|
type=order.order_type.value,
|
||||||
|
quantity=str(order.quantity),
|
||||||
|
price=str(order.price) if order.price else None,
|
||||||
|
)
|
||||||
|
return order
|
||||||
|
|
||||||
|
async def cancel_order(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
order_id: str | None = None,
|
||||||
|
client_order_id: str | None = None,
|
||||||
|
) -> Order:
|
||||||
|
"""Cancel an open order."""
|
||||||
|
if not order_id and not client_order_id:
|
||||||
|
raise ValueError("Must provide order_id or client_order_id")
|
||||||
|
|
||||||
|
params: dict[str, Any] = {"symbol": symbol}
|
||||||
|
if order_id:
|
||||||
|
params["orderId"] = order_id
|
||||||
|
if client_order_id:
|
||||||
|
params["origClientOrderId"] = client_order_id
|
||||||
|
|
||||||
|
data = await self._request("DELETE", "/fapi/v1/order", params=params, signed=True)
|
||||||
|
|
||||||
|
order = self._parse_order(data)
|
||||||
|
logger.info("Order cancelled", order_id=order.id, symbol=symbol)
|
||||||
|
return order
|
||||||
|
|
||||||
|
async def cancel_all_orders(self, symbol: str) -> list[Order]:
|
||||||
|
"""Cancel all open orders for a symbol."""
|
||||||
|
await self._request(
|
||||||
|
"DELETE",
|
||||||
|
"/fapi/v1/allOpenOrders",
|
||||||
|
params={"symbol": symbol},
|
||||||
|
signed=True,
|
||||||
|
)
|
||||||
|
logger.info("All orders cancelled", symbol=symbol)
|
||||||
|
return [] # Binance doesn't return cancelled orders
|
||||||
|
|
||||||
|
async def get_order(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
order_id: str | None = None,
|
||||||
|
client_order_id: str | None = None,
|
||||||
|
) -> Order:
|
||||||
|
"""Get order by ID."""
|
||||||
|
if not order_id and not client_order_id:
|
||||||
|
raise ValueError("Must provide order_id or client_order_id")
|
||||||
|
|
||||||
|
params: dict[str, Any] = {"symbol": symbol}
|
||||||
|
if order_id:
|
||||||
|
params["orderId"] = order_id
|
||||||
|
if client_order_id:
|
||||||
|
params["origClientOrderId"] = client_order_id
|
||||||
|
|
||||||
|
data = await self._request("GET", "/fapi/v1/order", params=params, signed=True)
|
||||||
|
return self._parse_order(data)
|
||||||
|
|
||||||
|
def _parse_order(self, data: dict[str, Any]) -> Order:
|
||||||
|
"""Parse order data from API response."""
|
||||||
|
return Order(
|
||||||
|
id=str(data["orderId"]),
|
||||||
|
client_order_id=data.get("clientOrderId", ""),
|
||||||
|
symbol=data["symbol"],
|
||||||
|
side=Side(data["side"]),
|
||||||
|
position_side=PositionSide(data["positionSide"]),
|
||||||
|
order_type=OrderType(data["type"]),
|
||||||
|
quantity=Decimal(data["origQty"]),
|
||||||
|
price=Decimal(data["price"]) if Decimal(data["price"]) > 0 else None,
|
||||||
|
stop_price=Decimal(data["stopPrice"])
|
||||||
|
if data.get("stopPrice") and Decimal(data["stopPrice"]) > 0
|
||||||
|
else None,
|
||||||
|
status=OrderStatus(data["status"]),
|
||||||
|
time_in_force=TimeInForce(data.get("timeInForce", "GTC")),
|
||||||
|
filled_quantity=Decimal(data.get("executedQty", "0")),
|
||||||
|
avg_fill_price=Decimal(data["avgPrice"])
|
||||||
|
if data.get("avgPrice") and Decimal(data["avgPrice"]) > 0
|
||||||
|
else None,
|
||||||
|
commission=Decimal("0"), # Not provided in order response
|
||||||
|
created_at=datetime.fromtimestamp(
|
||||||
|
data["time"] / 1000, tz=timezone.utc
|
||||||
|
)
|
||||||
|
if "time" in data
|
||||||
|
else datetime.now(timezone.utc),
|
||||||
|
updated_at=datetime.fromtimestamp(
|
||||||
|
data["updateTime"] / 1000, tz=timezone.utc
|
||||||
|
)
|
||||||
|
if "updateTime" in data
|
||||||
|
else datetime.now(timezone.utc),
|
||||||
|
raw=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Market Data
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_candles(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
timeframe: str,
|
||||||
|
limit: int = 500,
|
||||||
|
start_time: int | None = None,
|
||||||
|
end_time: int | None = None,
|
||||||
|
) -> list[Candle]:
|
||||||
|
"""Fetch historical candlestick data."""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"symbol": symbol,
|
||||||
|
"interval": timeframe,
|
||||||
|
"limit": min(limit, 1500),
|
||||||
|
}
|
||||||
|
if start_time:
|
||||||
|
params["startTime"] = start_time
|
||||||
|
if end_time:
|
||||||
|
params["endTime"] = end_time
|
||||||
|
|
||||||
|
data = await self._request("GET", "/fapi/v1/klines", params=params)
|
||||||
|
|
||||||
|
candles = []
|
||||||
|
for k in data:
|
||||||
|
candles.append(
|
||||||
|
Candle(
|
||||||
|
timestamp=datetime.fromtimestamp(k[0] / 1000, tz=timezone.utc),
|
||||||
|
open=Decimal(str(k[1])),
|
||||||
|
high=Decimal(str(k[2])),
|
||||||
|
low=Decimal(str(k[3])),
|
||||||
|
close=Decimal(str(k[4])),
|
||||||
|
volume=Decimal(str(k[5])),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return candles
|
||||||
|
|
||||||
|
async def get_mark_price(self, symbol: str) -> Decimal:
|
||||||
|
"""Get current mark price for a symbol."""
|
||||||
|
data = await self._request(
|
||||||
|
"GET",
|
||||||
|
"/fapi/v1/premiumIndex",
|
||||||
|
params={"symbol": symbol},
|
||||||
|
)
|
||||||
|
return Decimal(data["markPrice"])
|
||||||
|
|
||||||
|
async def get_funding_rate(self, symbol: str) -> FundingRate:
|
||||||
|
"""Get current/next funding rate."""
|
||||||
|
data = await self._request(
|
||||||
|
"GET",
|
||||||
|
"/fapi/v1/premiumIndex",
|
||||||
|
params={"symbol": symbol},
|
||||||
|
)
|
||||||
|
return FundingRate(
|
||||||
|
symbol=symbol,
|
||||||
|
funding_rate=Decimal(data["lastFundingRate"]),
|
||||||
|
funding_time=datetime.fromtimestamp(
|
||||||
|
data["nextFundingTime"] / 1000, tz=timezone.utc
|
||||||
|
),
|
||||||
|
mark_price=Decimal(data["markPrice"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_symbol_info(self, symbol: str) -> SymbolInfo:
|
||||||
|
"""Get trading rules and precision for a symbol."""
|
||||||
|
if symbol not in self._symbol_info_cache:
|
||||||
|
await self._load_exchange_info()
|
||||||
|
if symbol not in self._symbol_info_cache:
|
||||||
|
raise ExchangeError(f"Symbol {symbol} not found")
|
||||||
|
return self._symbol_info_cache[symbol]
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Utility Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def round_price(self, symbol: str, price: Decimal) -> Decimal:
|
||||||
|
"""Round price to valid tick size for symbol."""
|
||||||
|
info = self._symbol_info_cache.get(symbol)
|
||||||
|
if not info:
|
||||||
|
return price
|
||||||
|
if info.tick_size == 0:
|
||||||
|
return price
|
||||||
|
return (price // info.tick_size) * info.tick_size
|
||||||
|
|
||||||
|
def round_quantity(self, symbol: str, quantity: Decimal) -> Decimal:
|
||||||
|
"""Round quantity to valid step size for symbol."""
|
||||||
|
info = self._symbol_info_cache.get(symbol)
|
||||||
|
if not info:
|
||||||
|
return quantity
|
||||||
|
if info.step_size == 0:
|
||||||
|
return quantity
|
||||||
|
return (quantity // info.step_size) * info.step_size
|
||||||
|
|
||||||
|
async def close_position(
|
||||||
|
self,
|
||||||
|
symbol: str,
|
||||||
|
position_side: PositionSide,
|
||||||
|
) -> Order | None:
|
||||||
|
"""Close an existing position with a market order."""
|
||||||
|
positions = await self.get_positions(symbol)
|
||||||
|
|
||||||
|
for pos in positions:
|
||||||
|
if pos.position_side == position_side and pos.is_open:
|
||||||
|
# Determine side to close (opposite of position)
|
||||||
|
close_side = Side.SELL if position_side == PositionSide.LONG else Side.BUY
|
||||||
|
|
||||||
|
request = OrderRequest(
|
||||||
|
symbol=symbol,
|
||||||
|
side=close_side,
|
||||||
|
position_side=position_side,
|
||||||
|
order_type=OrderType.MARKET,
|
||||||
|
quantity=abs(pos.quantity),
|
||||||
|
reduce_only=True,
|
||||||
|
)
|
||||||
|
return await self.create_order(request)
|
||||||
|
|
||||||
|
return None
|
||||||
231
src/tradefinder/adapters/types.py
Normal file
231
src/tradefinder/adapters/types.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"""Type definitions for exchange adapters."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Side(str, Enum):
|
||||||
|
"""Order/Position side."""
|
||||||
|
|
||||||
|
BUY = "BUY"
|
||||||
|
SELL = "SELL"
|
||||||
|
|
||||||
|
|
||||||
|
class PositionSide(str, Enum):
|
||||||
|
"""Position side for hedge mode."""
|
||||||
|
|
||||||
|
LONG = "LONG"
|
||||||
|
SHORT = "SHORT"
|
||||||
|
BOTH = "BOTH" # For one-way mode (not used in this project)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderType(str, Enum):
|
||||||
|
"""Order type."""
|
||||||
|
|
||||||
|
MARKET = "MARKET"
|
||||||
|
LIMIT = "LIMIT"
|
||||||
|
STOP_MARKET = "STOP_MARKET"
|
||||||
|
STOP_LIMIT = "STOP_LIMIT"
|
||||||
|
TAKE_PROFIT_MARKET = "TAKE_PROFIT_MARKET"
|
||||||
|
TAKE_PROFIT_LIMIT = "TAKE_PROFIT_LIMIT"
|
||||||
|
TRAILING_STOP_MARKET = "TRAILING_STOP_MARKET"
|
||||||
|
|
||||||
|
|
||||||
|
class OrderStatus(str, Enum):
|
||||||
|
"""Order status."""
|
||||||
|
|
||||||
|
NEW = "NEW"
|
||||||
|
PARTIALLY_FILLED = "PARTIALLY_FILLED"
|
||||||
|
FILLED = "FILLED"
|
||||||
|
CANCELED = "CANCELED"
|
||||||
|
REJECTED = "REJECTED"
|
||||||
|
EXPIRED = "EXPIRED"
|
||||||
|
|
||||||
|
|
||||||
|
class TimeInForce(str, Enum):
|
||||||
|
"""Time in force for orders."""
|
||||||
|
|
||||||
|
GTC = "GTC" # Good Till Cancel
|
||||||
|
IOC = "IOC" # Immediate or Cancel
|
||||||
|
FOK = "FOK" # Fill or Kill
|
||||||
|
GTX = "GTX" # Good Till Crossing (Post Only)
|
||||||
|
|
||||||
|
|
||||||
|
class MarginType(str, Enum):
|
||||||
|
"""Margin type."""
|
||||||
|
|
||||||
|
ISOLATED = "ISOLATED"
|
||||||
|
CROSS = "CROSSED"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Candle:
|
||||||
|
"""OHLCV candlestick data."""
|
||||||
|
|
||||||
|
timestamp: datetime
|
||||||
|
open: Decimal
|
||||||
|
high: Decimal
|
||||||
|
low: Decimal
|
||||||
|
close: Decimal
|
||||||
|
volume: Decimal
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert to dictionary."""
|
||||||
|
return {
|
||||||
|
"timestamp": self.timestamp,
|
||||||
|
"open": float(self.open),
|
||||||
|
"high": float(self.high),
|
||||||
|
"low": float(self.low),
|
||||||
|
"close": float(self.close),
|
||||||
|
"volume": float(self.volume),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Order:
|
||||||
|
"""Order representation."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
client_order_id: str
|
||||||
|
symbol: str
|
||||||
|
side: Side
|
||||||
|
position_side: PositionSide
|
||||||
|
order_type: OrderType
|
||||||
|
quantity: Decimal
|
||||||
|
price: Decimal | None # None for market orders
|
||||||
|
stop_price: Decimal | None # For stop orders
|
||||||
|
status: OrderStatus
|
||||||
|
time_in_force: TimeInForce
|
||||||
|
filled_quantity: Decimal
|
||||||
|
avg_fill_price: Decimal | None
|
||||||
|
commission: Decimal
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
"""Check if order is still open."""
|
||||||
|
return self.status in (OrderStatus.NEW, OrderStatus.PARTIALLY_FILLED)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_filled(self) -> bool:
|
||||||
|
"""Check if order is fully filled."""
|
||||||
|
return self.status == OrderStatus.FILLED
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_quantity(self) -> Decimal:
|
||||||
|
"""Get remaining unfilled quantity."""
|
||||||
|
return self.quantity - self.filled_quantity
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Position:
|
||||||
|
"""Position representation."""
|
||||||
|
|
||||||
|
symbol: str
|
||||||
|
position_side: PositionSide
|
||||||
|
quantity: Decimal # Positive for long, negative for short in one-way mode
|
||||||
|
entry_price: Decimal
|
||||||
|
mark_price: Decimal
|
||||||
|
unrealized_pnl: Decimal
|
||||||
|
leverage: int
|
||||||
|
margin_type: MarginType
|
||||||
|
isolated_margin: Decimal | None # Only for isolated margin
|
||||||
|
liquidation_price: Decimal | None
|
||||||
|
updated_at: datetime
|
||||||
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
"""Check if position has non-zero quantity."""
|
||||||
|
return self.quantity != Decimal("0")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def notional_value(self) -> Decimal:
|
||||||
|
"""Calculate notional value of position."""
|
||||||
|
return abs(self.quantity) * self.mark_price
|
||||||
|
|
||||||
|
@property
|
||||||
|
def side(self) -> Side:
|
||||||
|
"""Get effective side of position."""
|
||||||
|
if self.position_side == PositionSide.LONG:
|
||||||
|
return Side.BUY
|
||||||
|
return Side.SELL
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AccountBalance:
|
||||||
|
"""Account balance information."""
|
||||||
|
|
||||||
|
asset: str
|
||||||
|
wallet_balance: Decimal
|
||||||
|
unrealized_pnl: Decimal
|
||||||
|
margin_balance: Decimal
|
||||||
|
available_balance: Decimal
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_equity(self) -> Decimal:
|
||||||
|
"""Total equity including unrealized PnL."""
|
||||||
|
return self.wallet_balance + self.unrealized_pnl
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SymbolInfo:
|
||||||
|
"""Trading symbol information."""
|
||||||
|
|
||||||
|
symbol: str
|
||||||
|
base_asset: str # e.g., BTC
|
||||||
|
quote_asset: str # e.g., USDT
|
||||||
|
price_precision: int
|
||||||
|
quantity_precision: int
|
||||||
|
min_quantity: Decimal
|
||||||
|
max_quantity: Decimal
|
||||||
|
min_notional: Decimal
|
||||||
|
tick_size: Decimal # Minimum price movement
|
||||||
|
step_size: Decimal # Minimum quantity movement
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FundingRate:
|
||||||
|
"""Funding rate information."""
|
||||||
|
|
||||||
|
symbol: str
|
||||||
|
funding_rate: Decimal
|
||||||
|
funding_time: datetime
|
||||||
|
mark_price: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrderRequest:
|
||||||
|
"""Request to create a new order."""
|
||||||
|
|
||||||
|
symbol: str
|
||||||
|
side: Side
|
||||||
|
position_side: PositionSide
|
||||||
|
order_type: OrderType
|
||||||
|
quantity: Decimal
|
||||||
|
price: Decimal | None = None
|
||||||
|
stop_price: Decimal | None = None
|
||||||
|
time_in_force: TimeInForce = TimeInForce.GTC
|
||||||
|
reduce_only: bool = False
|
||||||
|
client_order_id: str | None = None
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
"""Validate order request parameters."""
|
||||||
|
if self.order_type == OrderType.LIMIT and self.price is None:
|
||||||
|
raise ValueError("Limit orders require a price")
|
||||||
|
if self.order_type in (
|
||||||
|
OrderType.STOP_MARKET,
|
||||||
|
OrderType.STOP_LIMIT,
|
||||||
|
OrderType.TAKE_PROFIT_MARKET,
|
||||||
|
OrderType.TAKE_PROFIT_LIMIT,
|
||||||
|
):
|
||||||
|
if self.stop_price is None:
|
||||||
|
raise ValueError(f"{self.order_type} orders require a stop_price")
|
||||||
|
if self.quantity <= Decimal("0"):
|
||||||
|
raise ValueError("Quantity must be positive")
|
||||||
24
src/tradefinder/core/__init__.py
Normal file
24
src/tradefinder/core/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""TradeFinder core module.
|
||||||
|
|
||||||
|
This module contains the core functionality:
|
||||||
|
- Configuration management
|
||||||
|
- Trading engine orchestration
|
||||||
|
- Risk management
|
||||||
|
- Order management
|
||||||
|
"""
|
||||||
|
|
||||||
|
from tradefinder.core.config import (
|
||||||
|
LogFormat,
|
||||||
|
Settings,
|
||||||
|
TradingMode,
|
||||||
|
get_settings,
|
||||||
|
reset_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Settings",
|
||||||
|
"TradingMode",
|
||||||
|
"LogFormat",
|
||||||
|
"get_settings",
|
||||||
|
"reset_settings",
|
||||||
|
]
|
||||||
300
src/tradefinder/core/config.py
Normal file
300
src/tradefinder/core/config.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""TradeFinder configuration module using Pydantic settings.
|
||||||
|
|
||||||
|
Loads configuration from environment variables with validation and type coercion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import Field, SecretStr, field_validator, model_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class TradingMode(str, Enum):
|
||||||
|
"""Trading mode determines which API endpoints and keys to use."""
|
||||||
|
|
||||||
|
PAPER = "paper" # In-house simulation, no API calls
|
||||||
|
TESTNET = "testnet" # Binance Futures testnet
|
||||||
|
LIVE = "live" # Production trading
|
||||||
|
|
||||||
|
|
||||||
|
class LogFormat(str, Enum):
|
||||||
|
"""Log output format."""
|
||||||
|
|
||||||
|
JSON = "json"
|
||||||
|
CONSOLE = "console"
|
||||||
|
|
||||||
|
|
||||||
|
class BinanceSettings(BaseSettings):
|
||||||
|
"""Binance API configuration."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="BINANCE_")
|
||||||
|
|
||||||
|
# Testnet credentials
|
||||||
|
testnet_api_key: SecretStr | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Binance Futures testnet API key",
|
||||||
|
)
|
||||||
|
testnet_secret: SecretStr | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Binance Futures testnet secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Production credentials (empty until ready for live trading)
|
||||||
|
api_key: SecretStr | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Binance Futures production API key",
|
||||||
|
)
|
||||||
|
secret: SecretStr | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Binance Futures production secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSettings(BaseSettings):
|
||||||
|
"""Risk management configuration."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="RISK_")
|
||||||
|
|
||||||
|
per_trade_pct: Annotated[
|
||||||
|
float,
|
||||||
|
Field(
|
||||||
|
default=2.0,
|
||||||
|
ge=0.1,
|
||||||
|
le=10.0,
|
||||||
|
description="Risk percentage per trade (1-3% recommended)",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
max_equity_per_strategy_pct: Annotated[
|
||||||
|
float,
|
||||||
|
Field(
|
||||||
|
default=25.0,
|
||||||
|
ge=5.0,
|
||||||
|
le=100.0,
|
||||||
|
alias="MAX_EQUITY_PER_STRATEGY_PCT",
|
||||||
|
description="Maximum equity allocation per strategy",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
max_leverage: Annotated[
|
||||||
|
int,
|
||||||
|
Field(
|
||||||
|
default=2,
|
||||||
|
ge=1,
|
||||||
|
le=20,
|
||||||
|
alias="MAX_LEVERAGE",
|
||||||
|
description="Maximum leverage (start conservative)",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Main application settings.
|
||||||
|
|
||||||
|
Configuration is loaded from environment variables with the following precedence:
|
||||||
|
1. Environment variables
|
||||||
|
2. .env file
|
||||||
|
3. Default values
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from tradefinder.core.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trading mode
|
||||||
|
trading_mode: TradingMode = Field(
|
||||||
|
default=TradingMode.TESTNET,
|
||||||
|
description="Trading mode: paper, testnet, or live",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Paper trading equity
|
||||||
|
paper_equity_usdt: Annotated[
|
||||||
|
float,
|
||||||
|
Field(
|
||||||
|
default=3000.0,
|
||||||
|
ge=100.0,
|
||||||
|
description="Starting equity for paper trading (USDT)",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Symbols
|
||||||
|
symbols: list[str] = Field(
|
||||||
|
default=["BTCUSDT", "ETHUSDT"],
|
||||||
|
description="Trading symbols (comma-separated in env)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timeframes
|
||||||
|
signal_timeframe: str = Field(
|
||||||
|
default="4h",
|
||||||
|
description="Timeframe for signal generation",
|
||||||
|
)
|
||||||
|
execution_timeframe: str = Field(
|
||||||
|
default="1m",
|
||||||
|
description="Timeframe for order execution monitoring",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Database
|
||||||
|
duckdb_path: Path = Field(
|
||||||
|
default=Path("/data/tradefinder.duckdb"),
|
||||||
|
description="Path to DuckDB database file",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis_url: str = Field(
|
||||||
|
default="redis://localhost:6379/0",
|
||||||
|
description="Redis connection URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# UI
|
||||||
|
streamlit_port: int = Field(
|
||||||
|
default=8501,
|
||||||
|
ge=1024,
|
||||||
|
le=65535,
|
||||||
|
description="Streamlit UI port",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_level: str = Field(
|
||||||
|
default="INFO",
|
||||||
|
description="Logging level",
|
||||||
|
)
|
||||||
|
log_format: LogFormat = Field(
|
||||||
|
default=LogFormat.JSON,
|
||||||
|
description="Log output format",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nested settings
|
||||||
|
binance: BinanceSettings = Field(default_factory=BinanceSettings)
|
||||||
|
risk: RiskSettings = Field(default_factory=RiskSettings)
|
||||||
|
|
||||||
|
@field_validator("symbols", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_symbols(cls, v: str | list[str]) -> list[str]:
|
||||||
|
"""Parse comma-separated symbols from environment."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
return [s.strip().upper() for s in v.split(",") if s.strip()]
|
||||||
|
return [s.upper() for s in v]
|
||||||
|
|
||||||
|
@field_validator("log_level", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_log_level(cls, v: str) -> str:
|
||||||
|
"""Validate log level is valid."""
|
||||||
|
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||||
|
level = v.upper()
|
||||||
|
if level not in valid_levels:
|
||||||
|
raise ValueError(f"Invalid log level: {v}. Must be one of {valid_levels}")
|
||||||
|
return level
|
||||||
|
|
||||||
|
@field_validator("signal_timeframe", "execution_timeframe", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_timeframe(cls, v: str) -> str:
|
||||||
|
"""Validate timeframe format."""
|
||||||
|
valid_timeframes = {
|
||||||
|
"1m",
|
||||||
|
"3m",
|
||||||
|
"5m",
|
||||||
|
"15m",
|
||||||
|
"30m",
|
||||||
|
"1h",
|
||||||
|
"2h",
|
||||||
|
"4h",
|
||||||
|
"6h",
|
||||||
|
"8h",
|
||||||
|
"12h",
|
||||||
|
"1d",
|
||||||
|
"3d",
|
||||||
|
"1w",
|
||||||
|
"1M",
|
||||||
|
}
|
||||||
|
if v not in valid_timeframes:
|
||||||
|
raise ValueError(f"Invalid timeframe: {v}. Must be one of {valid_timeframes}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_credentials_for_mode(self) -> "Settings":
|
||||||
|
"""Validate that required credentials are present for the trading mode."""
|
||||||
|
if self.trading_mode == TradingMode.TESTNET:
|
||||||
|
if not self.binance.testnet_api_key or not self.binance.testnet_secret:
|
||||||
|
raise ValueError(
|
||||||
|
"Testnet mode requires BINANCE_TESTNET_API_KEY and BINANCE_TESTNET_SECRET"
|
||||||
|
)
|
||||||
|
elif self.trading_mode == TradingMode.LIVE:
|
||||||
|
if not self.binance.api_key or not self.binance.secret:
|
||||||
|
raise ValueError(
|
||||||
|
"Live mode requires BINANCE_API_KEY and BINANCE_SECRET"
|
||||||
|
)
|
||||||
|
# PAPER mode doesn't require any credentials
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_paper_trading(self) -> bool:
|
||||||
|
"""Check if running in paper trading mode (no real API calls)."""
|
||||||
|
return self.trading_mode == TradingMode.PAPER
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_testnet(self) -> bool:
|
||||||
|
"""Check if running against testnet."""
|
||||||
|
return self.trading_mode == TradingMode.TESTNET
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_live(self) -> bool:
|
||||||
|
"""Check if running in live production mode."""
|
||||||
|
return self.trading_mode == TradingMode.LIVE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def binance_base_url(self) -> str:
|
||||||
|
"""Get the appropriate Binance API base URL for current mode."""
|
||||||
|
if self.trading_mode == TradingMode.TESTNET:
|
||||||
|
return "https://testnet.binancefuture.com"
|
||||||
|
return "https://fapi.binance.com"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def binance_ws_url(self) -> str:
|
||||||
|
"""Get the appropriate Binance WebSocket URL for current mode."""
|
||||||
|
if self.trading_mode == TradingMode.TESTNET:
|
||||||
|
return "wss://stream.binancefuture.com"
|
||||||
|
return "wss://fstream.binance.com"
|
||||||
|
|
||||||
|
def get_active_api_key(self) -> SecretStr | None:
|
||||||
|
"""Get the API key for the current trading mode."""
|
||||||
|
if self.trading_mode == TradingMode.TESTNET:
|
||||||
|
return self.binance.testnet_api_key
|
||||||
|
elif self.trading_mode == TradingMode.LIVE:
|
||||||
|
return self.binance.api_key
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_active_secret(self) -> SecretStr | None:
|
||||||
|
"""Get the secret for the current trading mode."""
|
||||||
|
if self.trading_mode == TradingMode.TESTNET:
|
||||||
|
return self.binance.testnet_secret
|
||||||
|
elif self.trading_mode == TradingMode.LIVE:
|
||||||
|
return self.binance.secret
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Get cached application settings.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Settings: Application configuration loaded from environment.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If required settings are missing or invalid.
|
||||||
|
"""
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_settings() -> None:
|
||||||
|
"""Clear the settings cache. Useful for testing."""
|
||||||
|
get_settings.cache_clear()
|
||||||
0
src/tradefinder/data/__init__.py
Normal file
0
src/tradefinder/data/__init__.py
Normal file
0
src/tradefinder/strategies/__init__.py
Normal file
0
src/tradefinder/strategies/__init__.py
Normal file
0
src/tradefinder/ui/__init__.py
Normal file
0
src/tradefinder/ui/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
154
tests/test_config.py
Normal file
154
tests/test_config.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Tests for configuration module."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradefinder.adapters.types import (
|
||||||
|
OrderRequest,
|
||||||
|
OrderType,
|
||||||
|
PositionSide,
|
||||||
|
Side,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrderRequest:
|
||||||
|
"""Tests for OrderRequest validation."""
|
||||||
|
|
||||||
|
def test_valid_limit_order(self) -> None:
|
||||||
|
"""Test valid limit order creation."""
|
||||||
|
order = OrderRequest(
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
side=Side.BUY,
|
||||||
|
position_side=PositionSide.LONG,
|
||||||
|
order_type=OrderType.LIMIT,
|
||||||
|
quantity=Decimal("0.001"),
|
||||||
|
price=Decimal("50000"),
|
||||||
|
)
|
||||||
|
order.validate() # Should not raise
|
||||||
|
|
||||||
|
def test_limit_order_without_price_fails(self) -> None:
|
||||||
|
"""Test limit order without price raises error."""
|
||||||
|
order = OrderRequest(
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
side=Side.BUY,
|
||||||
|
position_side=PositionSide.LONG,
|
||||||
|
order_type=OrderType.LIMIT,
|
||||||
|
quantity=Decimal("0.001"),
|
||||||
|
price=None,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="Limit orders require a price"):
|
||||||
|
order.validate()
|
||||||
|
|
||||||
|
def test_stop_order_without_stop_price_fails(self) -> None:
|
||||||
|
"""Test stop order without stop price raises error."""
|
||||||
|
order = OrderRequest(
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
side=Side.SELL,
|
||||||
|
position_side=PositionSide.LONG,
|
||||||
|
order_type=OrderType.STOP_MARKET,
|
||||||
|
quantity=Decimal("0.001"),
|
||||||
|
stop_price=None,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="STOP_MARKET orders require a stop_price"):
|
||||||
|
order.validate()
|
||||||
|
|
||||||
|
def test_valid_stop_market_order(self) -> None:
|
||||||
|
"""Test valid stop market order creation."""
|
||||||
|
order = OrderRequest(
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
side=Side.SELL,
|
||||||
|
position_side=PositionSide.LONG,
|
||||||
|
order_type=OrderType.STOP_MARKET,
|
||||||
|
quantity=Decimal("0.001"),
|
||||||
|
stop_price=Decimal("48000"),
|
||||||
|
)
|
||||||
|
order.validate() # Should not raise
|
||||||
|
|
||||||
|
def test_zero_quantity_fails(self) -> None:
|
||||||
|
"""Test zero quantity raises error."""
|
||||||
|
order = OrderRequest(
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
side=Side.BUY,
|
||||||
|
position_side=PositionSide.LONG,
|
||||||
|
order_type=OrderType.MARKET,
|
||||||
|
quantity=Decimal("0"),
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="Quantity must be positive"):
|
||||||
|
order.validate()
|
||||||
|
|
||||||
|
def test_negative_quantity_fails(self) -> None:
|
||||||
|
"""Test negative quantity raises error."""
|
||||||
|
order = OrderRequest(
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
side=Side.BUY,
|
||||||
|
position_side=PositionSide.LONG,
|
||||||
|
order_type=OrderType.MARKET,
|
||||||
|
quantity=Decimal("-0.001"),
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="Quantity must be positive"):
|
||||||
|
order.validate()
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigValidation:
|
||||||
|
"""Tests for Settings configuration validation.
|
||||||
|
|
||||||
|
These tests require environment variables or .env file setup.
|
||||||
|
They verify that configuration validation works correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_symbols_parsing_from_string(self) -> None:
|
||||||
|
"""Test comma-separated symbol parsing."""
|
||||||
|
# This tests the validator logic directly
|
||||||
|
from tradefinder.core.config import Settings
|
||||||
|
|
||||||
|
# Simulate what the validator does
|
||||||
|
input_str = "BTCUSDT, ETHUSDT, SOLUSDT"
|
||||||
|
result = [s.strip().upper() for s in input_str.split(",") if s.strip()]
|
||||||
|
assert result == ["BTCUSDT", "ETHUSDT", "SOLUSDT"]
|
||||||
|
|
||||||
|
def test_valid_timeframes(self) -> None:
|
||||||
|
"""Test valid timeframe values."""
|
||||||
|
valid = {"1m", "5m", "15m", "30m", "1h", "4h", "1d"}
|
||||||
|
for tf in valid:
|
||||||
|
# These should not raise
|
||||||
|
assert tf in {
|
||||||
|
"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h",
|
||||||
|
"6h", "8h", "12h", "1d", "3d", "1w", "1M"
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_invalid_timeframe(self) -> None:
|
||||||
|
"""Test invalid timeframe is rejected."""
|
||||||
|
from tradefinder.core.config import Settings
|
||||||
|
|
||||||
|
invalid_timeframes = ["2m", "10m", "2d", "1y", "invalid"]
|
||||||
|
valid_set = {
|
||||||
|
"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h",
|
||||||
|
"6h", "8h", "12h", "1d", "3d", "1w", "1M"
|
||||||
|
}
|
||||||
|
for tf in invalid_timeframes:
|
||||||
|
assert tf not in valid_set
|
||||||
|
|
||||||
|
|
||||||
|
class TestTradingModes:
|
||||||
|
"""Tests for trading mode behavior."""
|
||||||
|
|
||||||
|
def test_paper_mode_no_credentials_needed(self) -> None:
|
||||||
|
"""Paper mode should not require API credentials."""
|
||||||
|
from tradefinder.core.config import TradingMode
|
||||||
|
|
||||||
|
assert TradingMode.PAPER.value == "paper"
|
||||||
|
# Paper mode is for internal simulation - no API calls
|
||||||
|
|
||||||
|
def test_testnet_mode_requires_testnet_keys(self) -> None:
|
||||||
|
"""Testnet mode requires testnet API keys."""
|
||||||
|
from tradefinder.core.config import TradingMode
|
||||||
|
|
||||||
|
assert TradingMode.TESTNET.value == "testnet"
|
||||||
|
|
||||||
|
def test_live_mode_requires_production_keys(self) -> None:
|
||||||
|
"""Live mode requires production API keys."""
|
||||||
|
from tradefinder.core.config import TradingMode
|
||||||
|
|
||||||
|
assert TradingMode.LIVE.value == "live"
|
||||||
Reference in New Issue
Block a user