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:
bnair123
2025-12-27 13:28:08 +04:00
parent f1f9888f6b
commit 6f602c0d19
22 changed files with 3396 additions and 0 deletions

73
.env.example Normal file
View 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
View 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
View 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
View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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 "$@"

View File

View 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",
]

View 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

View 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

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

View 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",
]

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

View File

View File

View File

0
tests/__init__.py Normal file
View File

154
tests/test_config.py Normal file
View 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"