From 6f602c0d1941b5395eb950c2a009b918d498dcbe Mon Sep 17 00:00:00 2001 From: bnair123 Date: Sat, 27 Dec 2025 13:28:08 +0400 Subject: [PATCH] Add core infrastructure: config, Binance adapter, docs, and auto-setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 73 +++ .gitignore | 77 +++ Dockerfile | 69 +++ README.md | 131 +++++ docker-compose.yml | 146 ++++++ docs/ARCHITECTURE.md | 370 +++++++++++++ docs/PLAN.md | 195 +++++++ docs/SECURITY.md | 270 ++++++++++ pyproject.toml | 123 +++++ setup.sh | 150 ++++++ src/tradefinder/__init__.py | 0 src/tradefinder/adapters/__init__.py | 69 +++ src/tradefinder/adapters/base.py | 374 +++++++++++++ src/tradefinder/adapters/binance_usdm.py | 640 +++++++++++++++++++++++ src/tradefinder/adapters/types.py | 231 ++++++++ src/tradefinder/core/__init__.py | 24 + src/tradefinder/core/config.py | 300 +++++++++++ src/tradefinder/data/__init__.py | 0 src/tradefinder/strategies/__init__.py | 0 src/tradefinder/ui/__init__.py | 0 tests/__init__.py | 0 tests/test_config.py | 154 ++++++ 22 files changed, 3396 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/PLAN.md create mode 100644 docs/SECURITY.md create mode 100644 pyproject.toml create mode 100755 setup.sh create mode 100644 src/tradefinder/__init__.py create mode 100644 src/tradefinder/adapters/__init__.py create mode 100644 src/tradefinder/adapters/base.py create mode 100644 src/tradefinder/adapters/binance_usdm.py create mode 100644 src/tradefinder/adapters/types.py create mode 100644 src/tradefinder/core/__init__.py create mode 100644 src/tradefinder/core/config.py create mode 100644 src/tradefinder/data/__init__.py create mode 100644 src/tradefinder/strategies/__init__.py create mode 100644 src/tradefinder/ui/__init__.py create mode 100644 tests/__init__.py create mode 100644 tests/test_config.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4b28fe2 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e85dc53 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6e161d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index e69de29..1540b8b 100644 --- a/README.md +++ b/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 +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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0617c7a --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6df8dbe --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..e910ece --- /dev/null +++ b/docs/PLAN.md @@ -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 diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..649e048 --- /dev/null +++ b/docs/SECURITY.md @@ -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 | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..75fff3d --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..0a727a5 --- /dev/null +++ b/setup.sh @@ -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 "$@" diff --git a/src/tradefinder/__init__.py b/src/tradefinder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tradefinder/adapters/__init__.py b/src/tradefinder/adapters/__init__.py new file mode 100644 index 0000000..08442f0 --- /dev/null +++ b/src/tradefinder/adapters/__init__.py @@ -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", +] diff --git a/src/tradefinder/adapters/base.py b/src/tradefinder/adapters/base.py new file mode 100644 index 0000000..8d09114 --- /dev/null +++ b/src/tradefinder/adapters/base.py @@ -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 diff --git a/src/tradefinder/adapters/binance_usdm.py b/src/tradefinder/adapters/binance_usdm.py new file mode 100644 index 0000000..b46c29e --- /dev/null +++ b/src/tradefinder/adapters/binance_usdm.py @@ -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 diff --git a/src/tradefinder/adapters/types.py b/src/tradefinder/adapters/types.py new file mode 100644 index 0000000..29bbf8c --- /dev/null +++ b/src/tradefinder/adapters/types.py @@ -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") diff --git a/src/tradefinder/core/__init__.py b/src/tradefinder/core/__init__.py new file mode 100644 index 0000000..bee5f43 --- /dev/null +++ b/src/tradefinder/core/__init__.py @@ -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", +] diff --git a/src/tradefinder/core/config.py b/src/tradefinder/core/config.py new file mode 100644 index 0000000..99fa8b8 --- /dev/null +++ b/src/tradefinder/core/config.py @@ -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() diff --git a/src/tradefinder/data/__init__.py b/src/tradefinder/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tradefinder/strategies/__init__.py b/src/tradefinder/strategies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tradefinder/ui/__init__.py b/src/tradefinder/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..c2bc12b --- /dev/null +++ b/tests/test_config.py @@ -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"