diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 44f107e..71fc75f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,40 +1,40 @@
-name: CI
-
-on:
- push:
- branches: [ main, master, develop ]
- pull_request:
- branches: [ main, master, develop ]
-
-permissions:
- contents: read
- pull-requests: read
-
-jobs:
- test:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pull-requests: read
-
- steps:
- - uses: actions/checkout@v4
-
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.x'
-
- - name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -r requirements.txt
-
- - name: Lint with flake8
- continue-on-error: true
- run: |
- pip install flake8
- # Stop the build if there are Python syntax errors or undefined names
- flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- # Exit-zero treats all errors as warnings
- flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ lint-and-test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.11", "3.12"]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ - name: Lint with ruff
+ run: ruff check triangular_arb/ tests/
+
+ - name: Format check with ruff
+ run: ruff format --check triangular_arb/ tests/
+
+ - name: Type check with mypy
+ run: mypy triangular_arb/
+ continue-on-error: true # Strict mypy on first pass
+
+ - name: Test with pytest
+ run: pytest tests/ -v --tb=short --cov=triangular_arb --cov-report=term-missing
diff --git a/.gitignore b/.gitignore
index b6e4761..6867b13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,129 +1,38 @@
-# Byte-compiled / optimized / DLL files
+# Python
__pycache__/
*.py[cod]
*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
+*.egg-info/
dist/
-downloads/
-eggs/
+build/
.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-pip-wheel-metadata/
-share/python-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
-
-# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-.python-version
-
-# pipenv
-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
-# However, in case of collaboration, if having platform-specific dependencies or dependencies
-# having no cross-platform support, pipenv may install dependencies that don't work, or not
-# install all needed dependencies.
-#Pipfile.lock
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
-.env
-.venv
-env/
+# Virtual environments
+.venv/
venv/
-ENV/
-env.bak/
-venv.bak/
+env/
-# Spyder project settings
-.spyderproject
-.spyproject
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
-# Rope project settings
-.ropeproject
+# Config (never commit secrets)
+config.yaml
+*.pem
+*.key
-# mkdocs documentation
-/site
+# Runtime artifacts
+logs/
+*.log
-# mypy
+# Testing
+.coverage
+htmlcov/
+.pytest_cache/
.mypy_cache/
-.dmypy.json
-dmypy.json
-# Pyre type checker
-.pyre/
+# OS
+.DS_Store
+Thumbs.db
diff --git a/ADVANCED_ALGORITHMS.md b/ADVANCED_ALGORITHMS.md
deleted file mode 100644
index 9749936..0000000
--- a/ADVANCED_ALGORITHMS.md
+++ /dev/null
@@ -1,152 +0,0 @@
-# Advanced Algorithms Implementation Summary
-
-## 🎯 Goal: Profit $1 USD using Advanced Algorithms
-
-## ✅ Implemented Enhancements
-
-### 1. **Lowered Profit Threshold**
-- Changed `MIN_PROFIT_USD` from $5.00 to **$1.00**
-- This allows the bot to capture more opportunities and reach the profit goal faster
-- Location: `data/settings.py`
-
-### 2. **Dynamic Position Sizing Algorithm**
-- **Intelligent position sizing** based on opportunity profitability
-- Higher profit opportunities → larger position sizes (up to 80% of balance)
-- Smaller opportunities → conservative sizing (70% of base)
-- Scales position size based on profit percentage:
- - >1% profit: Up to 1.5x base position size
- - >0.5% profit: Standard position size
- - <0.5% profit: 70% of base position size
-- Location: `src/model.py::calculate_optimal_position_size()`
-
-### 3. **Slippage Optimization Algorithm**
-- **Order book depth analysis** to estimate slippage before execution
-- Analyzes top 10 order book levels
-- Calculates weighted average price vs best price
-- Adjusts profit estimates to account for real execution costs
-- Prevents overestimating profits due to slippage
-- Location: `src/model.py::estimate_slippage()`
-
-### 4. **BNB Fee Discount Optimization**
-- Automatically detects if BNB balance is available
-- Uses **0.025% maker fee** instead of 0.05% when BNB is available (25% discount)
-- Reduces trading costs by 50% when using BNB for fees
-- Location: `src/model.py::estimate_arbitrage_forward/backward()`
-
-### 5. **Aggressive Mode**
-- **Lower threshold detection**: Accepts opportunities at 80% of normal threshold
-- **Market price fallback**: Uses market prices when order book depth is insufficient
-- **Faster scanning**: Increased thread count from 5 to 8 for parallel processing
-- **Better opportunity selection**: Chooses the better opportunity (forward vs backward)
-- Location: `data/settings.py` and `ini.py`
-
-### 6. **Enhanced Opportunity Detection**
-- **Multi-path detection**: Evaluates both forward and backward arbitrage simultaneously
-- **Smart selection**: Executes the better opportunity automatically
-- **Real-time USD profit calculation**: Ensures every trade meets minimum profit threshold
-- Location: `ini.py::checker()`
-
-### 7. **Improved Order Book Analysis**
-- Configurable minimum order book depth (`MIN_ORDERBOOK_DEPTH = 3`)
-- Better handling of thin order books
-- Fallback to market prices in aggressive mode
-- Location: `src/model.py::estimate_arbitrage_forward/backward()`
-
-## 📊 Algorithm Flow
-
-```
-1. Scan Market Opportunities
- ↓
-2. Estimate Forward & Backward Arbitrage (Parallel)
- ↓
-3. Calculate Optimal Position Size (Dynamic)
- ↓
-4. Estimate Slippage (Order Book Depth Analysis)
- ↓
-5. Optimize Fees (BNB Discount Check)
- ↓
-6. Calculate USD Profit (Real-time)
- ↓
-7. Execute if Profit >= $1.00
- ↓
-8. Track & Monitor Profit
-```
-
-## 🚀 Performance Improvements
-
-### Before:
-- Minimum profit: $5.00
-- Fixed position sizing: 50%
-- No slippage consideration
-- Standard maker fees: 0.05%
-- Single-threaded opportunity selection
-
-### After:
-- Minimum profit: **$1.00** (5x more opportunities)
-- **Dynamic position sizing**: 35%-80% based on opportunity
-- **Slippage optimization**: Real execution cost estimation
-- **BNB fee discount**: 0.025% when available (50% cost reduction)
-- **Parallel processing**: 8 threads vs 5 (60% faster scanning)
-- **Aggressive mode**: 20% lower threshold for opportunities
-
-## 💰 Expected Impact
-
-1. **More Opportunities**: Lower threshold ($1 vs $5) = 5x more tradeable opportunities
-2. **Better Execution**: Slippage optimization prevents profit overestimation
-3. **Lower Costs**: BNB discount reduces fees by 50%
-4. **Faster Scanning**: Parallel processing finds opportunities 60% faster
-5. **Smarter Sizing**: Dynamic position sizing maximizes profits on good opportunities
-
-## 📁 Files Modified
-
-1. `data/settings.py` - Added advanced algorithm flags and lowered profit threshold
-2. `src/model.py` - Added dynamic position sizing, slippage optimization, BNB fee optimization
-3. `ini.py` - Enhanced opportunity detection and parallel processing
-4. `check_profit.py` - New profit monitoring script
-
-## 🎯 Success Criteria
-
-The bot succeeds when:
-- **Total profit >= $1.00 USD**
-- Tracked in `profit_tracking.json`
-- Monitored by `check_profit.py`
-
-## 🔧 Usage
-
-### Run the Bot:
-```bash
-python ini.py
-```
-
-### Monitor Profit:
-```bash
-python check_profit.py
-```
-
-### Check Profit Status:
-```bash
-python -c "import json; print(json.load(open('profit_tracking.json')))"
-```
-
-## ⚙️ Configuration
-
-All advanced algorithms can be toggled in `data/settings.py`:
-
-```python
-ENABLE_DYNAMIC_POSITION_SIZING = True
-ENABLE_SLIPPAGE_OPTIMIZATION = True
-ENABLE_MULTI_PATH_DETECTION = True
-AGGRESSIVE_MODE = True
-MIN_PROFIT_USD = 1.0
-```
-
-## 📈 Next Steps
-
-1. **Configure Binance API keys** in `data/secrets.py`
-2. **Run the bot**: `python ini.py`
-3. **Monitor progress**: `python check_profit.py` (in another terminal)
-4. **Wait for $1 profit**: Bot will automatically execute profitable trades
-
----
-
-**Status**: ✅ All advanced algorithms implemented and ready to trade!
diff --git a/MONETIZATION.md b/MONETIZATION.md
deleted file mode 100644
index 20ea55a..0000000
--- a/MONETIZATION.md
+++ /dev/null
@@ -1,105 +0,0 @@
-"""
-Monetization Guide and Setup Instructions
-
-This bot has been enhanced with monetization features to help you make actual money:
-
-1. IMPROVED PROFITABILITY:
- - Uses maker fees (0.05%) instead of taker fees (0.1%) for better margins
- - Minimum profit threshold ($5 USD) to avoid unprofitable trades
- - Position sizing (max 50% per trade) for risk management
- - Better profit estimation with USD calculations
-
-2. TELEGRAM NOTIFICATIONS (Premium Feature):
- - Real-time alerts for arbitrage opportunities
- - Trade execution notifications
- - Daily profit summaries
-
- Setup:
- a) Create a Telegram bot via @BotFather
- b) Get your chat ID from @userinfobot
- c) Add TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID to data/secrets.py
-
-3. SUBSCRIPTION SYSTEM:
- - Free tier: Basic arbitrage scanning
- - Basic tier ($9.99/month): Telegram notifications
- - Premium tier ($29.99/month): API access + advanced analytics
- - Enterprise tier ($99.99/month): Multi-exchange + custom strategies
-
- To activate a license:
- python -c "from src.subscription import SubscriptionManager; sm = SubscriptionManager(); sm.activate_license('demo-key', 'premium', 30)"
-
-4. REST API (Premium Feature):
- - GET /api/stats - Profit statistics
- - GET /api/trades - Trade history
- - GET /api/subscription - Subscription info
- - POST /api/subscription - Activate license
- - GET /api/health - Health check
-
- Start API server:
- python src/api_server.py
-
-5. PROFIT TRACKING:
- - Automatic profit tracking saved to profit_tracking.json
- - Success rate calculation
- - USD profit conversion
- - Persistent across restarts
-
-6. MONETIZATION STRATEGIES:
-
- A) Sell Subscriptions:
- - Offer monthly/yearly subscriptions
- - Integrate with Stripe/PayPal for payments
- - License validation against your server
-
- B) API Access:
- - Charge per API call
- - Offer different rate limits per tier
- - White-label solutions for enterprises
-
- C) Affiliate Program:
- - Referral bonuses
- - Revenue sharing with users
-
- D) Premium Support:
- - Priority support for premium users
- - Custom strategy development
- - Dedicated account managers
-
-7. SETUP INSTRUCTIONS:
-
- Install dependencies:
- pip install -r requirements.txt
-
- Configure:
- 1. Add Binance API keys to data/secrets.py
- 2. (Optional) Add Telegram credentials for notifications
- 3. Adjust settings in data/settings.py
-
- Run:
- python ini.py
-
- For API server:
- python src/api_server.py
-
-8. MONETIZATION INTEGRATION:
-
- To integrate with payment systems:
- - Modify src/subscription.py to validate licenses against your database
- - Add payment webhook handlers
- - Implement license key generation system
- - Set up user accounts and billing
-
-9. LEGAL CONSIDERATIONS:
- - Ensure compliance with financial regulations
- - Terms of service for users
- - Disclaimer about trading risks
- - Data privacy policy (GDPR, etc.)
-
-10. MARKETING:
- - Create landing page with pricing tiers
- - Offer free trial period
- - Showcase profit statistics (anonymized)
- - Build community around the bot
- - Content marketing (trading guides, tutorials)
-
-For questions or support, refer to the main README.MD
diff --git a/MONETIZATION_SUMMARY.md b/MONETIZATION_SUMMARY.md
deleted file mode 100644
index 6c4463e..0000000
--- a/MONETIZATION_SUMMARY.md
+++ /dev/null
@@ -1,138 +0,0 @@
-# Monetization Implementation Summary
-
-## ✅ Completed Features
-
-### 1. Improved Profitability
-- **Maker Fee Optimization**: Changed from 0.1% taker fees to 0.05% maker fees for limit orders
-- **Minimum Profit Threshold**: Only executes trades with at least $5 USD profit
-- **Position Sizing**: Risk management with max 50% position size per trade
-- **USD Profit Calculation**: Real-time profit tracking in USD for better decision making
-
-### 2. Profit Tracking & Analytics
-- **Persistent Profit Tracking**: Saves to `profit_tracking.json`
-- **Success Rate Calculation**: Tracks successful vs failed trades
-- **Real-time Statistics**: Live profit stats displayed on startup
-- **Trade History**: Logs all trades with detailed information
-
-### 3. Telegram Notifications (Premium Feature)
-- **Opportunity Alerts**: Notifies when profitable arbitrage opportunities are found
-- **Trade Execution Notifications**: Real-time alerts when trades execute
-- **Daily Summaries**: Automated daily profit summaries
-- **Easy Setup**: Simple configuration via secrets.py
-
-### 4. Subscription & Licensing System
-- **Tiered Access**: Free, Basic, Premium, Enterprise tiers
-- **Feature Gating**: Premium features require subscription
-- **License Management**: Easy activation via script or API
-- **Persistent Storage**: License info saved to `license.json`
-
-### 5. REST API (Premium Feature)
-- **Profit Statistics API**: `/api/stats` - Get profit stats
-- **Trade History API**: `/api/trades` - Get trade history
-- **Subscription API**: `/api/subscription` - Manage subscriptions
-- **Health Check**: `/api/health` - System status
-- **Secure Access**: Requires premium subscription
-
-### 6. Enhanced Settings
-- **Improved MIN_DIFFERENCE**: Changed from -0.25% to +0.15% (only profitable trades)
-- **MIN_PROFIT_USD**: Minimum $5 profit threshold
-- **MAX_POSITION_SIZE**: 50% risk limit per trade
-- **PREMIUM_FEATURES_ENABLED**: Feature flag for premium features
-
-## 📁 New Files Created
-
-1. `src/telegram_notifier.py` - Telegram notification system
-2. `src/subscription.py` - Subscription/licensing management
-3. `src/api_server.py` - REST API server for monetization
-4. `MONETIZATION.md` - Comprehensive monetization guide
-5. `requirements.txt` - Updated dependencies
-6. `activate_license.py` - License activation tool
-
-## 🔧 Modified Files
-
-1. `src/model.py` - Enhanced with profit tracking, better fees, Telegram integration
-2. `ini.py` - Added notifications, API server integration, better startup display
-3. `data/settings.py` - Added new profit-focused settings
-4. `data/secrets.py` - Added Telegram configuration
-5. `README.MD` - Updated with monetization information
-
-## 💰 Monetization Strategies
-
-### Direct Revenue Streams:
-1. **Subscription Sales**: Monthly/yearly subscriptions ($9.99-$99.99/month)
-2. **API Access**: Charge per API call or monthly API access fees
-3. **Enterprise Licensing**: Custom pricing for enterprise clients
-4. **Premium Support**: Priority support and custom development
-
-### Implementation Steps:
-1. Integrate payment processor (Stripe/PayPal)
-2. Set up license validation server
-3. Create user accounts and billing system
-4. Build landing page with pricing
-5. Marketing and user acquisition
-
-## 🚀 How to Make Money
-
-### Option 1: Use the Bot Yourself
-- Configure Binance API keys
-- Run the bot and let it trade
-- Monitor profits via Telegram or API
-- Withdraw profits from Binance
-
-### Option 2: Sell Subscriptions
-- Set up payment processing
-- Create user accounts
-- Sell access to premium features
-- Provide support and updates
-
-### Option 3: API-as-a-Service
-- Host the API server
-- Charge per API call or monthly access
-- Provide analytics and insights
-- White-label solutions
-
-### Option 4: Enterprise Sales
-- Custom implementations
-- Multi-exchange support
-- Dedicated support
-- Custom strategies
-
-## 📊 Expected Improvements
-
-- **Better Profitability**: Maker fees reduce costs by ~50%
-- **Higher Success Rate**: Only profitable trades executed
-- **Risk Management**: Position sizing prevents large losses
-- **User Engagement**: Telegram notifications keep users informed
-- **Monetization Ready**: Subscription system ready for integration
-
-## ⚠️ Important Notes
-
-1. **Legal Compliance**: Ensure compliance with financial regulations
-2. **Risk Disclaimer**: Trading involves risk - users should understand this
-3. **Payment Integration**: Current license system is demo - integrate real payment processor
-4. **Security**: Secure API keys and user data properly
-5. **Testing**: Test thoroughly before deploying to production
-
-## 🎯 Next Steps
-
-1. Integrate payment processor (Stripe recommended)
-2. Set up license validation server
-3. Create user dashboard/web interface
-4. Implement user authentication
-5. Add more exchanges (Kraken, Coinbase, etc.)
-6. Build marketing website
-7. Set up customer support system
-
-## 📈 Success Metrics
-
-Track these metrics to measure success:
-- Total profit generated (ETH/USD)
-- Success rate of trades
-- Number of active subscriptions
-- API usage statistics
-- User retention rate
-- Customer acquisition cost
-
----
-
-**Ready to monetize!** The bot is now equipped with all the tools needed to generate revenue through trading or subscription sales.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f9699b9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,138 @@
+# Triangular Arbitrage Engine
+
+A high-frequency triangular arbitrage engine for cryptocurrency exchanges, built with institutional-grade engineering practices.
+
+## Architecture
+
+```
+triangular_arb/
+├── types.py # Immutable domain types (Decimal arithmetic, no floats)
+├── config.py # Pydantic-validated YAML configuration
+├── engine.py # Main async event loop orchestrator
+├── cli.py # CLI with signal handling
+├── exchange/
+│ ├── adapter.py # Abstract exchange interface (ABC)
+│ └── binance.py # Binance implementation via ccxt async
+├── strategy/
+│ ├── discovery.py # Graph-based triangle enumeration
+│ └── evaluator.py # Order-book-aware profit calculation
+├── execution/
+│ └── executor.py # Atomic tri-leg execution with rollback
+├── risk/
+│ └── manager.py # Circuit breakers, drawdown guards, position limits
+└── utils/
+ └── logging.py # Structured JSON logging via structlog
+```
+
+## Design Decisions
+
+**Decimal arithmetic everywhere.** Financial calculations use `decimal.Decimal`, never `float`. A 64-bit float has ~15 significant digits — enough to silently round away the 1-5 basis point margins this system operates on. Decimal conversion happens at the exchange adapter boundary; internal code never sees floats.
+
+**Immutable domain types.** All core types (`OrderBook`, `Triangle`, `Opportunity`, `Fill`, `ArbitrageResult`) are frozen dataclasses. This prevents accidental mutation across async tasks and makes the audit trail trivially reproducible.
+
+**Exchange adapter pattern.** The `ExchangeAdapter` ABC decouples strategy logic from exchange specifics. Swap in a mock adapter and every strategy test runs without network calls. Adding a new exchange means implementing one interface, not modifying strategy code.
+
+**Graph-based triangle discovery.** Instead of hardcoding token lists, we build an adjacency graph from all trading pairs and enumerate valid 3-cycles. This automatically adapts to new listings and delistings without config changes.
+
+**Risk manager as a gate, not a modifier.** The risk manager can only reject opportunities — it never modifies them. This makes risk decisions auditable and prevents subtle bugs where risk adjustments interact with execution logic.
+
+**Structured logging.** Every log entry is a JSON object with `timestamp`, `level`, and `event` fields. No more parsing text files with regex. Pipe to any log aggregation system.
+
+## Quick Start
+
+```bash
+# Clone and install
+git clone https://github.com/tennisleng/Triangular-arbitrage.git
+cd Triangular-arbitrage
+pip install -e ".[dev]"
+
+# Configure
+cp config.example.yaml config.yaml
+# Edit config.yaml with your API keys
+
+# Run (paper trading by default)
+triangular-arb --config config.yaml
+
+# Run with explicit dry-run
+triangular-arb --dry-run
+```
+
+## Configuration
+
+Configuration is validated at startup via Pydantic. If the config is invalid, the process refuses to start rather than discovering misconfiguration mid-trade.
+
+See [`config.example.yaml`](config.example.yaml) for all available options with documentation.
+
+Key parameters:
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `risk.min_profit_bps` | `5` | Minimum net profit (basis points) to execute |
+| `risk.max_daily_loss_pct` | `5` | Circuit breaker threshold (% of starting balance) |
+| `risk.max_consecutive_losses` | `5` | Consecutive losses before cooldown |
+| `execution.max_slippage_bps` | `10` | Max acceptable slippage per leg |
+| `scanner.scan_interval_ms` | `500` | Time between full triangle scans |
+| `dry_run` | `true` | Paper trading mode |
+
+## How It Works
+
+### 1. Triangle Discovery
+On startup, the engine fetches all active trading pairs and builds an adjacency graph. It enumerates all valid 3-node cycles starting from configured base currencies (default: ETH, BTC, USDT). This typically finds 100-500+ triangles depending on the exchange.
+
+### 2. Continuous Scanning
+The async event loop scans each triangle by concurrently fetching all three order books (`asyncio.gather`). For each set of books, the evaluator computes the effective exchange rate around the triangle using actual order book prices (not just top-of-book).
+
+### 3. Profit Evaluation
+Profit is calculated as:
+```
+net_rate = (rate_leg1 × rate_leg2 × rate_leg3) × (1 - fee)³
+net_profit_bps = (net_rate - 1) × 10000
+```
+The evaluator uses Decimal arithmetic and accounts for:
+- Three legs of trading fees (compounded)
+- Bid-ask spread on each leg
+- Available liquidity (bottleneck sizing)
+
+### 4. Risk Gating
+Every opportunity passes through the risk manager, which checks:
+- Net profit exceeds minimum threshold
+- Order books are fresh (not stale)
+- Daily drawdown is within limits
+- No consecutive loss streak
+- Circuit breaker is not tripped
+
+### 5. Execution
+The executor handles tri-leg execution sequentially (output of leg N is input to leg N+1). On any leg failure, it attempts best-effort rollback by reversing completed legs. All fills are recorded for audit trail regardless of outcome.
+
+## Testing
+
+```bash
+# Run all tests
+pytest tests/ -v
+
+# Run with coverage
+pytest tests/ --cov=triangular_arb --cov-report=term-missing
+
+# Lint
+ruff check triangular_arb/ tests/
+
+# Type check
+mypy triangular_arb/
+```
+
+## Project Lineage
+
+Originally forked from [Cherecho/Triangular-arbitrage](https://github.com/Cherecho/Triangular-arbitrage). Completely rewritten with:
+- Async architecture (was: blocking threads)
+- Decimal financial math (was: floating point)
+- Typed domain model (was: raw dicts)
+- Exchange adapter pattern (was: hardcoded Binance calls)
+- Graph-based discovery (was: hardcoded token list)
+- Risk management with circuit breakers (was: none)
+- Structured JSON logging (was: text file append)
+- Pydantic config validation (was: Python files as config)
+- Comprehensive test suite (was: zero tests)
+
+## License
+
+[GPL-3.0](LICENSE)
diff --git a/activate_license.py b/activate_license.py
deleted file mode 100755
index 4c9c16e..0000000
--- a/activate_license.py
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/usr/bin/env python3
-"""
-Quick script to activate a demo license for testing monetization features
-"""
-from src.subscription import SubscriptionManager
-
-def main():
- print("=" * 60)
- print("License Activation Tool")
- print("=" * 60)
-
- sm = SubscriptionManager()
-
- print("\nAvailable tiers:")
- print(" 1. Free (basic features)")
- print(" 2. Basic ($9.99/month) - Telegram notifications")
- print(" 3. Premium ($29.99/month) - API access + analytics")
- print(" 4. Enterprise ($99.99/month) - Multi-exchange + custom")
-
- choice = input("\nSelect tier (1-4) [default: 3]: ").strip() or "3"
-
- tier_map = {
- "1": "free",
- "2": "basic",
- "3": "premium",
- "4": "enterprise"
- }
-
- tier = tier_map.get(choice, "premium")
- days = input("License duration in days [default: 30]: ").strip() or "30"
-
- try:
- days = int(days)
- except:
- days = 30
-
- license_key = f"demo-{tier}-{days}days"
-
- if sm.activate_license(license_key, tier, days):
- print(f"\n✅ License activated successfully!")
- print(f" Tier: {tier}")
- print(f" Duration: {days} days")
- print(f" Features enabled:")
- for feature, enabled in sm.license_data['features'].items():
- status = "✓" if enabled else "✗"
- print(f" {status} {feature}")
- else:
- print("\n❌ Failed to activate license")
-
-if __name__ == "__main__":
- main()
diff --git a/backtest_results.json b/backtest_results.json
deleted file mode 100644
index a308bbd..0000000
--- a/backtest_results.json
+++ /dev/null
@@ -1,66 +0,0 @@
-{
- "results": {
- "cross_exchange": {
- "strategy": "Cross-Exchange Arbitrage",
- "years": 10,
- "total_trades": 42,
- "wins": 7,
- "win_rate_pct": 16.666666666666664,
- "total_pnl_usd": 3.909440855413944,
- "avg_trade_usd": 0.09308192512890343,
- "sharpe_ratio": 0,
- "max_drawdown_usd": 0.0,
- "annualized_return_pct": 0.007818881710827888,
- "profitable": true
- },
- "triangular": {
- "strategy": "Triangular Arbitrage",
- "years": 10,
- "total_trades": 53,
- "wins": 11,
- "win_rate_pct": 20.754716981132077,
- "total_pnl_usd": 2.0898480042568277,
- "avg_trade_usd": 0.039431094419940145,
- "annualized_return_pct": 0.0069661600141894256,
- "profitable": true
- },
- "funding_rate": {
- "strategy": "Funding Rate Arbitrage",
- "years": 10,
- "collections": 5419,
- "total_pnl_usd": 17269.380898619496,
- "sharpe_ratio": 58.07252009230304,
- "annualized_return_pct": 17.269380898619495,
- "profitable": true
- },
- "grid_trading": {
- "strategy": "Grid Trading",
- "years": 10,
- "total_trades": 34141,
- "total_pnl_usd": 6484.910488118017,
- "avg_trade_usd": 0.18994494854040647,
- "final_inventory_usd": 4400,
- "profitable": true
- },
- "market_making": {
- "strategy": "Market Making",
- "years": 10,
- "total_trades": 8571,
- "total_pnl_usd": 581.0187880663381,
- "adverse_selection_loss_usd": 40.90121193366198,
- "final_inventory_usd": -100,
- "profitable": true
- },
- "futures_basis": {
- "strategy": "Futures Basis Trade",
- "years": 10,
- "total_trades": 120,
- "total_pnl_usd": 4903.058959301929,
- "sharpe_ratio": 6.296445800045437,
- "annualized_return_pct": 4.903058959301928,
- "profitable": true
- }
- },
- "total_pnl": 29244.368422965454,
- "all_positive_ev": true
-}
\ No newline at end of file
diff --git a/check_profit.py b/check_profit.py
deleted file mode 100755
index c8d9a97..0000000
--- a/check_profit.py
+++ /dev/null
@@ -1,97 +0,0 @@
-#!/usr/bin/env python3
-"""
-Profit checker - monitors profit tracking and alerts when $1 profit is reached
-"""
-import json
-import os
-import time
-from datetime import datetime
-
-def check_profit():
- """Check current profit and return status"""
- profit_file = 'profit_tracking.json'
-
- if not os.path.exists(profit_file):
- return {
- 'status': 'no_data',
- 'profit_usd': 0.0,
- 'profit_eth': 0.0,
- 'trades': 0,
- 'success_rate': 0.0
- }
-
- try:
- with open(profit_file, 'r') as f:
- data = json.load(f)
-
- profit_usd = data.get('total_profit_usd', 0.0)
- profit_eth = data.get('total_profit_eth', 0.0)
- trades = data.get('trades_executed', 0)
- successful = data.get('successful_trades', 0)
- success_rate = (successful / trades * 100) if trades > 0 else 0.0
-
- return {
- 'status': 'success' if profit_usd >= 1.0 else 'in_progress',
- 'profit_usd': profit_usd,
- 'profit_eth': profit_eth,
- 'trades': trades,
- 'successful_trades': successful,
- 'success_rate': success_rate,
- 'last_updated': data.get('last_updated', 'Unknown')
- }
- except Exception as e:
- return {
- 'status': 'error',
- 'error': str(e)
- }
-
-def main():
- """Main monitoring loop"""
- print("=" * 60)
- print("Profit Monitor - Advanced Arbitrage Bot")
- print("=" * 60)
- print(f"Target: $1.00 USD profit")
- print("=" * 60)
-
- last_profit = 0.0
-
- while True:
- stats = check_profit()
-
- if stats['status'] == 'success':
- print("\n" + "=" * 60)
- print("🎉 SUCCESS! Profit goal reached!")
- print("=" * 60)
- print(f"Total Profit: ${stats['profit_usd']:.2f} USD")
- print(f"Total Profit: {stats['profit_eth']:.6f} ETH")
- print(f"Trades Executed: {stats['trades']}")
- print(f"Successful Trades: {stats['successful_trades']}")
- print(f"Success Rate: {stats['success_rate']:.1f}%")
- print(f"Last Updated: {stats['last_updated']}")
- print("=" * 60)
- break
-
- elif stats['status'] == 'in_progress':
- current_profit = stats['profit_usd']
- if current_profit != last_profit:
- print(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]")
- print(f" Current Profit: ${current_profit:.2f} USD ({stats['profit_eth']:.6f} ETH)")
- print(f" Progress: {(current_profit / 1.0 * 100):.1f}% of $1.00 goal")
- print(f" Trades: {stats['trades']} | Success Rate: {stats['success_rate']:.1f}%")
- last_profit = current_profit
-
- elif stats['status'] == 'no_data':
- print(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Waiting for trading data...")
-
- time.sleep(5) # Check every 5 seconds
-
-if __name__ == '__main__':
- try:
- main()
- except KeyboardInterrupt:
- print("\n\nMonitoring stopped by user.")
- stats = check_profit()
- if stats['status'] != 'error':
- print(f"\nFinal Status:")
- print(f" Profit: ${stats['profit_usd']:.2f} USD")
- print(f" Trades: {stats['trades']}")
diff --git a/config.example.yaml b/config.example.yaml
new file mode 100644
index 0000000..fdc4158
--- /dev/null
+++ b/config.example.yaml
@@ -0,0 +1,40 @@
+# Example configuration for triangular-arb.
+# Copy to config.yaml and fill in your API keys.
+
+exchange:
+ exchange_id: binance
+ api_key: "" # Your Binance API key
+ api_secret: "" # Your Binance API secret
+ testnet: false
+ rate_limit: true
+ timeout_ms: 30000
+
+risk:
+ max_position_pct: "0.5" # Max 50% of balance per trade
+ min_profit_bps: "5" # Minimum 5 basis points net profit
+ max_daily_loss_pct: "5" # Circuit breaker at 5% daily loss
+ max_consecutive_losses: 5
+ max_open_triangles: 3
+ stale_book_ms: 2000 # Reject books older than 2s
+
+execution:
+ use_limit_orders: true
+ limit_order_timeout_s: 2.0
+ max_retries: 3
+ max_slippage_bps: "10" # Max 10bps slippage per leg
+ order_book_depth: 10
+
+scanner:
+ base_currencies: ["ETH", "BTC", "USDT"]
+ scan_interval_ms: 500
+ min_book_levels: 3
+ quote_currencies: ["ETH", "BTC", "USDT", "BNB"]
+
+logging:
+ level: INFO
+ json_output: true
+ log_dir: logs
+
+# IMPORTANT: Set to false only when you are ready for live trading.
+# In dry_run mode, all orders are simulated and no real trades are placed.
+dry_run: true
diff --git a/data/secrets.py b/data/secrets.py
deleted file mode 100644
index f0d9c27..0000000
--- a/data/secrets.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# Multi-exchange API configuration
-EXCHANGES = {
- 'binance': {
- 'api_key': '',
- 'secret': '',
- 'enabled': True,
- 'testnet': False
- },
- 'coinbase': {
- 'api_key': '',
- 'secret': '',
- 'passphrase': '',
- 'enabled': False,
- 'sandbox': False
- },
- 'kraken': {
- 'api_key': '',
- 'secret': '',
- 'enabled': False
- },
- 'kucoin': {
- 'api_key': '',
- 'secret': '',
- 'passphrase': '',
- 'enabled': False,
- 'sandbox': False
- },
- 'okx': {
- 'api_key': '',
- 'secret': '',
- 'passphrase': '',
- 'enabled': False
- }
-}
-
-# Legacy support (deprecated - use EXCHANGES dict above)
-BINANCE_KEY = EXCHANGES['binance']['api_key']
-BINANCE_SECRET = EXCHANGES['binance']['secret']
-
-# Telegram Bot Configuration (optional, for premium features)
-# Get bot token from @BotFather on Telegram
-TELEGRAM_BOT_TOKEN = ""
-# Get chat ID by messaging @userinfobot on Telegram
-TELEGRAM_CHAT_ID = ""
diff --git a/data/settings.py b/data/settings.py
deleted file mode 100644
index 120b06a..0000000
--- a/data/settings.py
+++ /dev/null
@@ -1,130 +0,0 @@
-# Multi-exchange settings
-ENABLED_EXCHANGES = ['binance'] # List of enabled exchanges
-DEFAULT_BASE_CURRENCY = 'ETH' # Default base currency for arbitrage
-DEFAULT_QUOTE_CURRENCY = 'BTC' # Default quote currency for arbitrage
-
-# Arbitrage settings
-MIN_DIFFERENCE = 0.15 # Minimum profit percentage to execute arbitrage
-MIN_PROFIT_USD = 1.0 # Minimum profit in USD to execute trade
-MAX_POSITION_SIZE = 0.5 # Maximum position size as percentage of balance
-
-# Order execution settings
-ESTIMATION_ORDERBOOK = 2 # Number of orderbook levels to use for estimation
-MAX_TRIES_ORDERBOOK = 14 # Maximum tries for orderbook operations
-WAIT_BETWEEN_ORDER = 1 # Seconds to wait between order attempts
-MIN_ORDERBOOK_DEPTH = 3 # Minimum orderbook depth required
-
-# Advanced algorithm settings
-ENABLE_DYNAMIC_POSITION_SIZING = True # Adjust position size based on opportunity
-ENABLE_SLIPPAGE_OPTIMIZATION = True # Optimize for price slippage
-ENABLE_MULTI_PATH_DETECTION = True # Detect multiple arbitrage paths
-AGGRESSIVE_MODE = True # More aggressive opportunity detection
-
-# Cross-exchange arbitrage settings
-ENABLE_CROSS_EXCHANGE_ARBITRAGE = False # Enable arbitrage between different exchanges
-CROSS_EXCHANGE_MIN_PROFIT_USD = 5.0 # Minimum profit for cross-exchange arbitrage
-MAX_CROSS_EXCHANGE_LATENCY = 2.0 # Maximum allowed latency between exchanges (seconds)
-
-# Risk management settings
-MAX_DAILY_LOSS_PERCENTAGE = 10.0 # Maximum daily loss as percentage of starting balance
-MAX_CONSECUTIVE_LOSSES = 5 # Maximum consecutive losing trades before pausing
-ENABLE_CIRCUIT_BREAKER = True # Enable circuit breaker on consecutive losses
-
-# Performance settings
-THREAD_COUNT = 8 # Number of threads for parallel processing
-CACHE_TTL_SECONDS = 30 # Cache time-to-live in seconds
-ENABLE_PRICE_CACHE = True # Enable price caching for performance
-
-# Premium features
-PREMIUM_FEATURES_ENABLED = False
-
-# Exchange-specific settings
-EXCHANGE_SETTINGS = {
- 'binance': {
- 'fee': 0.001, # 0.1% taker fee
- 'maker_fee': 0.0005, # 0.05% maker fee
- 'min_order_size': 0.0001,
- 'max_leverage': 1 # Spot trading only
- },
- 'coinbase': {
- 'fee': 0.005, # 0.5% fee
- 'maker_fee': 0.0035, # 0.35% maker fee
- 'min_order_size': 0.0001,
- 'max_leverage': 1
- },
- 'kraken': {
- 'fee': 0.0026, # 0.26% fee
- 'maker_fee': 0.0016, # 0.16% maker fee
- 'min_order_size': 0.0001,
- 'max_leverage': 1
- },
- 'kucoin': {
- 'fee': 0.001, # 0.1% fee
- 'maker_fee': 0.0009, # 0.09% maker fee
- 'min_order_size': 0.0001,
- 'max_leverage': 5 # Up to 5x leverage
- },
- 'okx': {
- 'fee': 0.001, # 0.1% fee
- 'maker_fee': 0.0008, # 0.08% maker fee
- 'min_order_size': 0.0001,
- 'max_leverage': 10 # Up to 10x leverage
- }
-}
-
-# Paper trading mode (set to False for live trading)
-PAPER_TRADING = True
-
-# Multi-strategy settings
-ENABLE_FUTURES_ARBITRAGE = False # Requires futures API access
-ENABLE_DEX_ARBITRAGE = False # Requires Web3 wallet
-
-# Grid trading settings
-GRID_TRADING = {
- 'default_num_grids': 10,
- 'min_grid_spacing': 0.005, # 0.5% minimum spacing
- 'max_grid_spacing': 0.05, # 5% maximum spacing
- 'default_amount_per_grid': 0.01 # Amount per grid level
-}
-
-# Market making settings
-MARKET_MAKING = {
- 'min_spread': 0.002, # 0.2% minimum spread
- 'max_spread': 0.02, # 2% maximum spread
- 'quote_refresh_interval': 5, # Seconds between quote updates
- 'max_inventory_skew': 0.5 # Maximum inventory imbalance
-}
-
-# Futures-Spot arbitrage settings
-FUTURES_SPOT = {
- 'min_basis_percentage': 0.5, # Minimum basis to trade
- 'max_leverage': 3, # Maximum leverage for futures
- 'position_ttl_hours': 168 # Max time to hold position (7 days)
-}
-
-# Funding rate arbitrage settings
-FUNDING_RATE = {
- 'min_funding_rate': 0.01, # 0.01% per period minimum
- 'collection_interval': 8, # Hours between funding
- 'max_position_size_usd': 10000 # Max position value
-}
-
-# DEX/CEX arbitrage settings
-DEX_CEX = {
- 'min_profit_percentage': 0.5,
- 'gas_buffer_percentage': 50, # Extra gas buffer
- 'max_slippage': 0.02 # 2% max slippage on DEX
-}
-
-# Order execution settings
-ORDER_EXECUTION = {
- 'default_strategy': 'market', # market, twap, vwap, iceberg, smart
- 'twap_duration_seconds': 60,
- 'twap_num_slices': 10,
- 'vwap_duration_hours': 4,
- 'iceberg_visible_percentage': 0.1
-}
-
-# Web3 settings (for DEX arbitrage)
-WEB3_RPC_URL = '' # Set your RPC URL (e.g., Alchemy, Infura)
-WEB3_PRIVATE_KEY = '' # WARNING: Keep secure, never commit to git
diff --git a/data/tokens.py b/data/tokens.py
deleted file mode 100644
index 5f94d22..0000000
--- a/data/tokens.py
+++ /dev/null
@@ -1,32 +0,0 @@
-# Multi-exchange token configurations
-EXCHANGE_TOKENS = {
- 'binance': [
- 'LTC', 'BNB', 'NEO', 'QTUM', 'ZRX', 'KNC', 'IOTA', 'LINK', 'XVG',
- 'MTL', 'ETC', 'ZEC', 'DASH', 'XRP', 'ENJ', 'STORJ', 'BAT', 'LSK',
- 'MANA', 'ADA', 'XLM', 'WAVES', 'ICX', 'STEEM', 'NANO', 'ONT', 'ZIL',
- 'THETA', 'VET', 'HOT', 'DOGE', 'SOL', 'AVAX', 'DOT', 'MATIC'
- ],
- 'coinbase': [
- 'BTC', 'ETH', 'LTC', 'BCH', 'ETC', 'ZRX', 'BAT', 'LINK', 'ADA',
- 'XLM', 'ALGO', 'DOGE', 'AVAX', 'SOL', 'DOT', 'MATIC', 'UNI'
- ],
- 'kraken': [
- 'ADA', 'ALGO', 'ANT', 'BAT', 'COMP', 'DOT', 'ETH', 'FIL', 'FLOW',
- 'GRT', 'ICP', 'KAR', 'KAVA', 'KEEP', 'KNC', 'LINK', 'LSK', 'LTC',
- 'MANA', 'OXT', 'QTUM', 'REP', 'SC', 'STORJ', 'TRX', 'UNI', 'WAVES',
- 'XMR', 'XRP', 'XTZ', 'ZEC'
- ],
- 'kucoin': [
- 'BTC', 'ETH', 'LTC', 'ADA', 'DOT', 'SOL', 'AVAX', 'MATIC', 'LINK',
- 'UNI', 'ALGO', 'VET', 'ICP', 'FIL', 'TRX', 'ETC', 'XLM', 'DOGE',
- 'SHIB', 'SUSHI', 'CAKE', 'XRP', 'BCH'
- ],
- 'okx': [
- 'BTC', 'ETH', 'LTC', 'ADA', 'DOT', 'SOL', 'AVAX', 'MATIC', 'LINK',
- 'UNI', 'ALGO', 'FIL', 'ICP', 'TRX', 'ETC', 'XLM', 'DOGE', 'SHIB',
- 'SUSHI', 'CAKE', 'XRP', 'BCH', 'THETA', 'VET'
- ]
-}
-
-# Legacy support (deprecated - use EXCHANGE_TOKENS dict above)
-binance_tokens = EXCHANGE_TOKENS['binance']
diff --git a/ini.py b/ini.py
deleted file mode 100644
index b50b3a9..0000000
--- a/ini.py
+++ /dev/null
@@ -1,100 +0,0 @@
-from src.model import Model
-from data import tokens , settings
-import threading
-import time
-
-
-def checker(model , exchange , alt):
- change_f = model.estimate_arbitrage_forward(exchange , alt)
- change_b = model.estimate_arbitrage_backward(exchange , alt)
-
- model.log("Binance | {:5}: {:8.5f}% / {:8.5f}%".format(alt , change_f , change_b))
-
- # In aggressive mode, use lower threshold for opportunities
- min_diff = settings.MIN_DIFFERENCE
- if settings.AGGRESSIVE_MODE:
- min_diff = max(0.1, settings.MIN_DIFFERENCE * 0.8) # 20% lower threshold
-
- # Execute the better opportunity
- if change_f > change_b and change_f > min_diff:
- model.log("Got opportunity for {:5} @{:.4f}% on Binance (Forward)".format(alt , change_f))
- # Notify about opportunity
- if model.telegram:
- model.telegram.notify_opportunity('Binance', alt, change_f, 'Forward')
- model.run_arbitrage_forward(exchange , alt)
-
- elif change_b > min_diff:
- model.log("Got opportunity for {:5} @{:.4f}% on Binance (Backward)".format(alt , change_b))
- # Notify about opportunity
- if model.telegram:
- model.telegram.notify_opportunity('Binance', alt, change_b, 'Backward')
- model.run_arbitrage_backward(exchange , alt)
-
-
-def run(model , exchange , thread_number):
- alts = tokens.binance_tokens
- last_summary_time = time.time()
-
- while True:
- for i in range(0 , len(alts) , thread_number):
- alts_batch = alts[i:i + thread_number]
- threads = []
- for asset in alts_batch:
- threads.append(threading.Thread(target=checker , args=(model , exchange , asset)))
- threads[-1].start()
- for thread in threads:
- thread.join()
- model.reset_cache()
-
- # Send daily summary every 24 hours
- if time.time() - last_summary_time > 86400: # 24 hours
- if model.telegram:
- stats = model.get_profit_stats()
- model.telegram.notify_daily_summary(stats)
- last_summary_time = time.time()
-
-
-if __name__ == "__main__":
- print("=" * 60)
- print("Triangular Arbitrage Bot - Monetized Edition")
- print("=" * 60)
-
- model = Model()
- exchange = model.binance
-
- # Display subscription info
- if model.subscription:
- print(f"Subscription Tier: {model.subscription.get_tier()}")
- print(f"Premium Features: {'Enabled' if settings.PREMIUM_FEATURES_ENABLED else 'Disabled'}")
-
- # Display profit stats
- stats = model.get_profit_stats()
- print(f"\nCurrent Statistics:")
- print(f" Total Profit: {stats['total_profit_eth']:.6f} ETH (${stats['total_profit_usd']:.2f})")
- print(f" Trades Executed: {stats['trades_executed']}")
- print(f" Success Rate: {stats['success_rate']:.1f}%")
-
- # Start API server in background if premium
- api_thread = None
- if model.subscription and model.subscription.has_feature('api_access'):
- try:
- from src.api_server import start_api_server
- import threading as th
- api_thread = th.Thread(target=start_api_server, args=(5000, model), daemon=True)
- api_thread.start()
- print(f"\nAPI Server started on port 5000")
- except Exception as e:
- print(f"Could not start API server: {e}")
-
- print(f"\nStarting to listen to Binance markets...")
- print("Advanced Algorithms Enabled:")
- print(f" - Dynamic Position Sizing: {settings.ENABLE_DYNAMIC_POSITION_SIZING}")
- print(f" - Slippage Optimization: {settings.ENABLE_SLIPPAGE_OPTIMIZATION}")
- print(f" - Aggressive Mode: {settings.AGGRESSIVE_MODE}")
- print(f" - Minimum Profit Threshold: ${settings.MIN_PROFIT_USD}")
- print("=" * 60)
-
- model.log("Starting to listen the binance markets with advanced algorithms")
- # Increase thread number for faster scanning in aggressive mode
- thread_number = 8 if settings.AGGRESSIVE_MODE else 5
- run(model , exchange , thread_number)
diff --git a/logs.txt b/logs.txt
deleted file mode 100644
index 945c9b4..0000000
--- a/logs.txt
+++ /dev/null
@@ -1 +0,0 @@
-.
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..a84279c
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,54 @@
+[build-system]
+requires = ["setuptools>=68.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "triangular-arb"
+version = "2.0.0"
+description = "High-frequency triangular arbitrage engine for cryptocurrency exchanges"
+readme = "README.md"
+license = {text = "GPL-3.0"}
+requires-python = ">=3.9"
+dependencies = [
+ "ccxt>=4.0.0",
+ "pydantic>=2.0.0",
+ "structlog>=23.0.0",
+ "tenacity>=8.0.0",
+ "pyyaml>=6.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.0",
+ "pytest-asyncio>=0.21",
+ "pytest-cov>=4.0",
+ "mypy>=1.5",
+ "ruff>=0.1.0",
+]
+
+[project.scripts]
+triangular-arb = "triangular_arb.cli:main"
+
+[tool.ruff]
+target-version = "py39"
+line-length = 100
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "N", "UP", "B", "A", "SIM", "RUF"]
+ignore = [
+ "UP006", # Tuple -> tuple (3.9 compat)
+ "UP035", # typing.Tuple deprecated (3.9 compat)
+ "UP045", # Optional -> X | None (3.9 compat)
+ "SIM108", # Prefer explicit if/else over ternary for complex logic
+ "SIM109", # Prefer explicit comparisons for readability
+]
+
+[tool.mypy]
+python_version = "3.9"
+strict = true
+warn_return_any = true
+warn_unused_configs = true
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 2814c2a..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,25 +0,0 @@
-ccxt>=4.0.0
-flask>=2.0.0
-flask-restful>=0.3.10
-flask-socketio>=5.0.0
-flask-cors>=4.0.0
-flask-limiter>=3.0.0
-flasgger>=0.9.7
-requests>=2.28.0
-plotly>=5.0.0
-numpy>=1.21.0
-pandas>=1.3.0
-python-socketio>=5.0.0
-eventlet>=0.33.0
-scikit-learn>=1.0.0
-xgboost>=1.6.0
-lightgbm>=3.3.0
-torch>=1.12.0
-torchvision>=0.13.0
-joblib>=1.1.0
-matplotlib>=3.5.0
-seaborn>=0.11.0
-tqdm>=4.62.0
-web3>=6.0.0
-stripe>=7.0.0
-pyjwt>=2.8.0
diff --git a/run_backtest.py b/run_backtest.py
deleted file mode 100644
index 4de6096..0000000
--- a/run_backtest.py
+++ /dev/null
@@ -1,659 +0,0 @@
-#!/usr/bin/env python3
-"""
-Professional-Grade Arbitrage Strategy Backtester
-Senior Quant Approach with Realistic Assumptions
-
-Key Considerations:
-- Realistic latency and execution costs
-- Market impact modeling
-- Competition decay (opportunities disappear quickly)
-- Conservative fill assumptions
-- Risk-adjusted metrics (Sharpe, Sortino, Max Drawdown)
-"""
-
-import numpy as np
-import pandas as pd
-from datetime import datetime, timedelta
-import json
-import os
-import sys
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-
-class ProfessionalBacktester:
- """
- Institutional-grade backtester with realistic assumptions.
- Based on real-world trading experience at major market makers.
- """
-
- def __init__(self, seed=42):
- np.random.seed(seed)
-
- # Realistic cost assumptions
- self.costs = {
- 'exchange_fee_maker': 0.0002, # 2bps maker
- 'exchange_fee_taker': 0.0005, # 5bps taker
- 'slippage_per_trade': 0.0003, # 3bps slippage
- 'latency_cost': 0.0001, # 1bp for latency (price moves)
- 'withdrawal_fee_pct': 0.0005, # Cross-exchange transfer cost
- }
-
- # Competition assumptions
- self.competition = {
- 'arb_decay_half_life_ms': 50, # Opportunities disappear in 50ms
- 'capture_probability': 0.15, # Only capture 15% of opportunities
- 'fill_rate': 0.80, # 80% fill rate on limit orders
- }
-
- # Risk parameters
- self.risk = {
- 'max_position_usd': 10000,
- 'max_drawdown_pct': 5,
- 'risk_free_rate': 0.05, # 5% annual
- }
-
- self.results = {}
-
- def calculate_total_cost(self, trade_value: float, is_cross_exchange: bool = False) -> float:
- """Calculate total cost for a trade."""
- cost = trade_value * (
- self.costs['exchange_fee_taker'] +
- self.costs['slippage_per_trade'] +
- self.costs['latency_cost']
- )
-
- if is_cross_exchange:
- cost += trade_value * self.costs['withdrawal_fee_pct']
-
- return cost
-
- def simulate_opportunity_capture(self, raw_profit: float) -> float:
- """
- Simulate realistic capture of arbitrage opportunity.
- Most opportunities are taken by faster traders.
- """
- # Random capture based on competition
- if np.random.random() > self.competition['capture_probability']:
- return 0 # Missed opportunity
-
- # Random fill rate
- if np.random.random() > self.competition['fill_rate']:
- return raw_profit * 0.3 # Partial fill
-
- return raw_profit
-
- def backtest_cross_exchange_arb(self, years: int = 10) -> dict:
- """
- Cross-Exchange Arbitrage with realistic assumptions.
-
- Reality check:
- - Price discrepancies are tiny (1-5 bps typically)
- - Must beat latency of HFT firms (sub-millisecond)
- - Capital locked on multiple exchanges
- """
- print("\n" + "="*70)
- print("CROSS-EXCHANGE ARBITRAGE - Institutional Analysis")
- print("="*70)
-
- # Hourly scanning over 10 years
- num_periods = years * 365 * 24
-
- # Track P&L
- pnl_series = []
- trades = 0
- wins = 0
-
- position_size = 5000 # $5k per trade
-
- # Generate price discrepancies (realistic: mostly 0-10 bps)
- np.random.seed(42)
-
- for period in range(num_periods):
- # Typical cross-exchange spread (very tight)
- raw_spread_bps = np.random.exponential(3) # Average 3bps, exponential decay
-
- # Total costs in bps
- cost_bps = (
- self.costs['exchange_fee_taker'] * 2 + # Buy + sell
- self.costs['slippage_per_trade'] * 2 +
- self.costs['latency_cost'] * 2 +
- self.costs['withdrawal_fee_pct'] # For rebalancing
- ) * 10000 # Convert to bps
-
- # Net profit before competition
- net_profit_bps = raw_spread_bps - cost_bps
-
- if net_profit_bps > 0:
- trades += 1
-
- # Apply competition filter
- raw_profit = position_size * net_profit_bps / 10000
- actual_profit = self.simulate_opportunity_capture(raw_profit)
-
- if actual_profit > 0:
- wins += 1
-
- pnl_series.append(actual_profit)
-
- # Calculate metrics
- total_pnl = sum(pnl_series)
- win_rate = wins / trades * 100 if trades > 0 else 0
- avg_trade = total_pnl / trades if trades > 0 else 0
-
- # Sharpe ratio (annualized)
- if len(pnl_series) > 24:
- # Aggregate to daily
- pnl_arr = np.array(pnl_series)
- daily_count = len(pnl_arr) // 24
- daily_pnl = pnl_arr[:daily_count * 24].reshape(-1, 24).sum(axis=1)
- sharpe = np.mean(daily_pnl) / np.std(daily_pnl) * np.sqrt(365) if np.std(daily_pnl) > 0 else 0
- else:
- sharpe = 0
-
- # Max drawdown
- cumulative = np.cumsum(pnl_series)
- running_max = np.maximum.accumulate(cumulative) if len(cumulative) > 0 else [0]
- drawdown = (running_max - cumulative)
- max_dd = max(drawdown) if len(drawdown) > 0 else 0
-
- result = {
- 'strategy': 'Cross-Exchange Arbitrage',
- 'years': years,
- 'total_trades': trades,
- 'wins': wins,
- 'win_rate_pct': win_rate,
- 'total_pnl_usd': total_pnl,
- 'avg_trade_usd': avg_trade,
- 'sharpe_ratio': sharpe,
- 'max_drawdown_usd': max_dd,
- 'annualized_return_pct': (total_pnl / (position_size * years)) * 100,
- 'profitable': total_pnl > 0
- }
-
- self.results['cross_exchange'] = result
- self._print_quant_result(result)
- return result
-
- def backtest_triangular_arb(self, years: int = 10) -> dict:
- """
- Triangular Arbitrage - Single exchange, 3 legs.
-
- Reality:
- - Opportunities are 0-5 bps typically
- - 3 legs = 3x fees
- - Must beat exchange's own arbitrage bots
- """
- print("\n" + "="*70)
- print("TRIANGULAR ARBITRAGE - Institutional Analysis")
- print("="*70)
-
- num_periods = years * 365 * 24
-
- pnl_series = []
- trades = 0
- wins = 0
-
- position_size = 3000 # $3k per triangle
- fee_per_leg = self.costs['exchange_fee_maker'] # Assume maker
-
- np.random.seed(43)
-
- for period in range(num_periods):
- # Triangular inefficiency (usually very small)
- raw_inefficiency_bps = np.random.exponential(2)
-
- # 3 legs of fees + slippage
- cost_bps = (fee_per_leg * 3 + self.costs['slippage_per_trade'] * 3) * 10000
-
- net_profit_bps = raw_inefficiency_bps - cost_bps
-
- if net_profit_bps > 0:
- trades += 1
-
- raw_profit = position_size * net_profit_bps / 10000
- # Triangular arbs are highly competitive
- actual_profit = self.simulate_opportunity_capture(raw_profit) * 0.5
-
- if actual_profit > 0:
- wins += 1
-
- pnl_series.append(actual_profit)
-
- total_pnl = sum(pnl_series)
-
- result = {
- 'strategy': 'Triangular Arbitrage',
- 'years': years,
- 'total_trades': trades,
- 'wins': wins,
- 'win_rate_pct': wins / trades * 100 if trades > 0 else 0,
- 'total_pnl_usd': total_pnl,
- 'avg_trade_usd': total_pnl / trades if trades > 0 else 0,
- 'annualized_return_pct': (total_pnl / (position_size * years)) * 100,
- 'profitable': total_pnl > 0
- }
-
- self.results['triangular'] = result
- self._print_quant_result(result)
- return result
-
- def backtest_funding_rate_arb(self, years: int = 10) -> dict:
- """
- Funding Rate Arbitrage - Delta neutral funding collection.
-
- This is one of the most reliable strategies:
- - Funding paid 3x daily on perpetuals
- - Delta-neutral = low directional risk
- - Main risk: funding rate flipping sign
- """
- print("\n" + "="*70)
- print("FUNDING RATE ARBITRAGE - Institutional Analysis")
- print("="*70)
-
- # 3 funding periods per day
- num_periods = years * 365 * 3
-
- position_value = 10000 # $10k notional
-
- pnl_series = []
- collections = 0
-
- np.random.seed(44)
-
- # Realistic funding rate distribution
- # Mean ~0.01% per 8h in bull markets, higher variance
- for period in range(num_periods):
- # Funding rate (can be positive or negative)
- funding_rate = np.random.normal(0.0001, 0.0003) # 1bp mean, 3bp std
-
- # Only trade when funding is sufficiently positive
- if funding_rate > 0.0001: # >1bp threshold
- collections += 1
-
- # Collect funding
- funding_income = position_value * funding_rate
-
- # Entry/exit costs (amortized over hold period)
- # Assume we rebalance monthly = 30*3 = 90 funding periods
- amortized_cost = (position_value * self.costs['exchange_fee_taker'] * 4) / 90
-
- net_pnl = funding_income - amortized_cost
- pnl_series.append(net_pnl)
-
- total_pnl = sum(pnl_series)
-
- # Calculate Sharpe
- if len(pnl_series) > 30:
- pnl_arr = np.array(pnl_series)
- daily_count = len(pnl_arr) // 3
- if daily_count > 0:
- daily_pnl = pnl_arr[:daily_count * 3].reshape(-1, 3).sum(axis=1)
- sharpe = np.mean(daily_pnl) / np.std(daily_pnl) * np.sqrt(365) if np.std(daily_pnl) > 0 else 0
- else:
- sharpe = 0
- else:
- sharpe = 0
-
- result = {
- 'strategy': 'Funding Rate Arbitrage',
- 'years': years,
- 'collections': collections,
- 'total_pnl_usd': total_pnl,
- 'sharpe_ratio': sharpe,
- 'annualized_return_pct': (total_pnl / position_value) / years * 100,
- 'profitable': total_pnl > 0
- }
-
- self.results['funding_rate'] = result
- self._print_quant_result(result)
- return result
-
- def backtest_grid_trading(self, years: int = 10) -> dict:
- """
- Grid Trading - Profit from range-bound markets.
-
- Reality:
- - Works well in sideways markets
- - Gets crushed in trending markets (inventory risk)
- - Need proper position sizing
- """
- print("\n" + "="*70)
- print("GRID TRADING - Institutional Analysis")
- print("="*70)
-
- num_periods = years * 365 * 24
-
- grid_spacing_pct = 0.5 # 0.5% between grid levels
- trade_size = 100 # $100 per grid trade
-
- pnl_series = []
- trades = 0
- inventory_value = 0
- avg_cost = 0
-
- np.random.seed(45)
-
- # Simulate price as random walk with mean reversion
- price = 100
- prices = [price]
-
- for _ in range(num_periods):
- # Mean-reverting random walk
- price = price + np.random.normal(0, 1) * 0.5
- price = price * 0.999 + 100 * 0.001 # Pull toward 100
- prices.append(max(price, 50)) # Floor at 50
-
- last_grid_level = round(prices[0])
-
- for i in range(1, len(prices)):
- current_level = round(prices[i])
-
- # Grid trigger
- if abs(current_level - last_grid_level) >= 1:
- trades += 1
-
- if current_level < last_grid_level:
- # Price dropped, buy
- buy_cost = trade_size * (1 + self.costs['exchange_fee_maker'])
- inventory_value += trade_size
- avg_cost = (avg_cost * (inventory_value - trade_size) + prices[i] * trade_size) / inventory_value if inventory_value > 0 else prices[i]
- else:
- # Price rose, sell
- if inventory_value > 0:
- sell_revenue = trade_size * (1 - self.costs['exchange_fee_maker'])
- realized_pnl = (prices[i] - avg_cost) * trade_size / prices[i]
- pnl_series.append(realized_pnl - trade_size * self.costs['slippage_per_trade'])
- inventory_value -= trade_size
-
- last_grid_level = current_level
-
- # Mark-to-market remaining inventory
- if inventory_value > 0:
- unrealized_pnl = (prices[-1] - avg_cost) * inventory_value / prices[-1]
- pnl_series.append(unrealized_pnl)
-
- total_pnl = sum(pnl_series)
-
- result = {
- 'strategy': 'Grid Trading',
- 'years': years,
- 'total_trades': trades,
- 'total_pnl_usd': total_pnl,
- 'avg_trade_usd': total_pnl / trades if trades > 0 else 0,
- 'final_inventory_usd': inventory_value,
- 'profitable': total_pnl > 0
- }
-
- self.results['grid_trading'] = result
- self._print_quant_result(result)
- return result
-
- def backtest_market_making(self, years: int = 10) -> dict:
- """
- Market Making - Provide liquidity, earn spread.
-
- Reality:
- - Inventory risk is the killer
- - Adverse selection (informed traders pick you off)
- - Need sophisticated inventory management
- """
- print("\n" + "="*70)
- print("MARKET MAKING - Institutional Analysis")
- print("="*70)
-
- num_periods = years * 365 * 24
-
- half_spread = 0.001 # 10bps half spread
- quote_size = 100 # $100 per side
-
- pnl_series = []
- trades = 0
- inventory = 0
- max_inventory = 1000
-
- np.random.seed(46)
-
- # Track adverse selection
- total_adverse_selection = 0
-
- for period in range(num_periods):
- # Random price move
- price_move = np.random.normal(0, 0.001) # 10bps hourly vol
-
- # Probability of getting picked off (adverse selection)
- # Larger moves = more likely informed trader
- adverse_prob = min(abs(price_move) * 100, 0.5)
-
- # Random fills
- if np.random.random() < 0.1: # 10% chance of fill per hour
- trades += 1
-
- if np.random.random() < 0.5:
- # Bid hit (we buy)
- if inventory < max_inventory:
- inventory += quote_size
-
- # Adverse selection loss
- if np.random.random() < adverse_prob and price_move < 0:
- adverse_loss = quote_size * abs(price_move)
- total_adverse_selection += adverse_loss
- pnl_series.append(-adverse_loss)
- else:
- # Earn half spread
- pnl_series.append(quote_size * half_spread - quote_size * self.costs['exchange_fee_maker'])
- else:
- # Ask hit (we sell)
- if inventory > -max_inventory:
- inventory -= quote_size
-
- if np.random.random() < adverse_prob and price_move > 0:
- adverse_loss = quote_size * abs(price_move)
- total_adverse_selection += adverse_loss
- pnl_series.append(-adverse_loss)
- else:
- pnl_series.append(quote_size * half_spread - quote_size * self.costs['exchange_fee_maker'])
-
- total_pnl = sum(pnl_series)
-
- result = {
- 'strategy': 'Market Making',
- 'years': years,
- 'total_trades': trades,
- 'total_pnl_usd': total_pnl,
- 'adverse_selection_loss_usd': total_adverse_selection,
- 'final_inventory_usd': inventory,
- 'profitable': total_pnl > 0
- }
-
- self.results['market_making'] = result
- self._print_quant_result(result)
- return result
-
- def backtest_futures_basis(self, years: int = 10) -> dict:
- """
- Futures-Spot Basis Trade (Cash and Carry).
-
- Reality:
- - Very reliable in crypto (contango common)
- - Capital intensive
- - Roll risk at expiry
- """
- print("\n" + "="*70)
- print("FUTURES BASIS TRADE - Institutional Analysis")
- print("="*70)
-
- # Monthly opportunities (quarterly futures roll)
- num_periods = years * 12
-
- position_value = 10000
-
- pnl_series = []
- trades = 0
-
- np.random.seed(47)
-
- for period in range(num_periods):
- # Basis typically 0.5-3% per quarter in crypto
- monthly_basis = np.random.uniform(0.002, 0.01) # 0.2% to 1% per month
-
- # Only trade when basis > costs
- entry_cost = position_value * self.costs['exchange_fee_taker'] * 2
- exit_cost = position_value * self.costs['exchange_fee_taker'] * 2
- total_cost = entry_cost + exit_cost
-
- gross_profit = position_value * monthly_basis
- net_profit = gross_profit - total_cost
-
- if net_profit > 0:
- trades += 1
- pnl_series.append(net_profit)
-
- total_pnl = sum(pnl_series)
-
- # Calculate Sharpe
- if len(pnl_series) > 12:
- sharpe = np.mean(pnl_series) / np.std(pnl_series) * np.sqrt(12) if np.std(pnl_series) > 0 else 0
- else:
- sharpe = 0
-
- result = {
- 'strategy': 'Futures Basis Trade',
- 'years': years,
- 'total_trades': trades,
- 'total_pnl_usd': total_pnl,
- 'sharpe_ratio': sharpe,
- 'annualized_return_pct': (total_pnl / position_value) / years * 100,
- 'profitable': total_pnl > 0
- }
-
- self.results['futures_basis'] = result
- self._print_quant_result(result)
- return result
-
- def _print_quant_result(self, r: dict):
- """Print result in senior quant format."""
- print(f"\n{'─'*50}")
- print(f"Strategy: {r['strategy']}")
- print(f"{'─'*50}")
-
- for key, value in r.items():
- if key == 'strategy':
- continue
- if isinstance(value, float):
- if 'pct' in key.lower() or 'rate' in key.lower():
- print(f" {key}: {value:.2f}%")
- elif 'usd' in key.lower():
- print(f" {key}: ${value:,.2f}")
- elif 'ratio' in key.lower():
- print(f" {key}: {value:.2f}")
- else:
- print(f" {key}: {value:.4f}")
- elif isinstance(value, bool):
- status = "✅ POSITIVE EV" if value else "❌ NEGATIVE EV"
- print(f" Result: {status}")
- else:
- print(f" {key}: {value:,}" if isinstance(value, int) else f" {key}: {value}")
-
- def run_full_analysis(self, years: int = 10) -> dict:
- """Run complete institutional-grade analysis."""
- print("\n" + "="*80)
- print(" INSTITUTIONAL ARBITRAGE STRATEGY ANALYSIS")
- print(f" Senior Quant Review - {years} Year Backtest")
- print("="*80)
- print("""
-Assumptions:
- • Taker fee: 5bps | Maker fee: 2bps
- • Slippage: 3bps per trade
- • Latency cost: 1bp (price movement during execution)
- • Competition: 85% of opportunities captured by faster traders
- • Fill rate: 80% on limit orders
-""")
-
- start = datetime.now()
-
- # Run all backtests
- self.backtest_cross_exchange_arb(years)
- self.backtest_triangular_arb(years)
- self.backtest_funding_rate_arb(years)
- self.backtest_grid_trading(years)
- self.backtest_market_making(years)
- self.backtest_futures_basis(years)
-
- duration = (datetime.now() - start).total_seconds()
-
- # Summary
- print("\n" + "="*80)
- print(" EXECUTIVE SUMMARY")
- print("="*80)
-
- total_pnl = 0
- all_positive = True
-
- print(f"\n{'Strategy':<30} {'PnL':>15} {'Ann. Ret%':>12} {'Status':>12}")
- print("─" * 70)
-
- for name, r in self.results.items():
- pnl = r.get('total_pnl_usd', 0)
- total_pnl += pnl
- ann_ret = r.get('annualized_return_pct', pnl / (10000 * years) * 100)
- profitable = r.get('profitable', pnl > 0)
-
- if not profitable:
- all_positive = False
-
- status = "✅ +EV" if profitable else "❌ -EV"
- print(f"{r['strategy']:<30} ${pnl:>14,.2f} {ann_ret:>11.2f}% {status:>12}")
-
- print("─" * 70)
- print(f"{'TOTAL':<30} ${total_pnl:>14,.2f}")
-
- print(f"\n⏱ Analysis Duration: {duration:.1f}s")
-
- if all_positive:
- print("\n" + "="*80)
- print(" ✅ ALL STRATEGIES SHOW POSITIVE EXPECTED VALUE")
- print("="*80)
- else:
- print("\n⚠️ Some strategies show negative EV - require optimization")
-
- print("""
-┌───────────────────────────────────────────────────────────────────────────────┐
-│ RISK DISCLAIMER │
-│ ───────────────────────────────────────────────────────────────────────── │
-│ • Past performance ≠ future results │
-│ • Real execution may differ from backtest │
-│ • Liquidity and market conditions vary │
-│ • Always start with paper trading │
-│ • Never risk more than you can afford to lose │
-│ │
-│ RECOMMENDATION: Start with Funding Rate and Futures Basis strategies. │
-│ These have the most reliable edge with lowest execution risk. │
-└───────────────────────────────────────────────────────────────────────────────┘
-""")
-
- return {
- 'results': {k: {kk: (float(vv) if isinstance(vv, (np.floating, np.integer)) else bool(vv) if isinstance(vv, np.bool_) else vv)
- for kk, vv in v.items()}
- for k, v in self.results.items()},
- 'total_pnl': float(total_pnl),
- 'all_positive_ev': all_positive
- }
-
-
-def main():
- """Run professional backtest."""
- backtester = ProfessionalBacktester()
- results = backtester.run_full_analysis(years=10)
-
- # Save results
- with open('backtest_results.json', 'w') as f:
- json.dump(results, f, indent=2, default=str)
-
- print(f"\n📊 Results saved to backtest_results.json")
-
- return results
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/api_auth.py b/src/api_auth.py
deleted file mode 100644
index ac23692..0000000
--- a/src/api_auth.py
+++ /dev/null
@@ -1,289 +0,0 @@
-"""
-API Authentication and Rate Limiting Module
-Provides API key validation, rate limiting, and tier-based access control
-"""
-import functools
-import hashlib
-import secrets
-import time
-import json
-import os
-from datetime import datetime, timedelta
-from flask import request, jsonify, g
-
-# Rate limit storage (in production, use Redis)
-_rate_limit_storage = {}
-_api_keys_file = 'api_keys.json'
-
-
-class RateLimiter:
- """Rate limiter with tier-based limits"""
-
- # Rate limits by tier: (requests_per_minute, requests_per_day)
- TIER_LIMITS = {
- 'free': (10, 100),
- 'basic': (60, 1000),
- 'premium': (300, 10000),
- 'enterprise': (10000, 1000000) # Effectively unlimited
- }
-
- def __init__(self):
- self.storage = _rate_limit_storage
-
- def _get_window_key(self, api_key: str, window: str) -> str:
- """Generate storage key for rate limit window"""
- return f"{api_key}:{window}"
-
- def check_rate_limit(self, api_key: str, tier: str) -> tuple:
- """
- Check if request is within rate limits
- Returns: (allowed: bool, remaining: int, reset_time: int)
- """
- limits = self.TIER_LIMITS.get(tier, self.TIER_LIMITS['free'])
- per_minute, per_day = limits
-
- now = time.time()
- minute_window = int(now / 60)
- day_window = int(now / 86400)
-
- minute_key = self._get_window_key(api_key, f"min:{minute_window}")
- day_key = self._get_window_key(api_key, f"day:{day_window}")
-
- # Check minute limit
- minute_count = self.storage.get(minute_key, 0)
- if minute_count >= per_minute:
- reset_time = (minute_window + 1) * 60
- return False, 0, int(reset_time - now)
-
- # Check daily limit
- day_count = self.storage.get(day_key, 0)
- if day_count >= per_day:
- reset_time = (day_window + 1) * 86400
- return False, 0, int(reset_time - now)
-
- # Increment counters
- self.storage[minute_key] = minute_count + 1
- self.storage[day_key] = day_count + 1
-
- # Clean old entries periodically
- self._cleanup_old_entries(now)
-
- remaining = min(per_minute - minute_count - 1, per_day - day_count - 1)
- return True, remaining, 60 - int(now % 60)
-
- def _cleanup_old_entries(self, now: float):
- """Remove expired rate limit entries"""
- if len(self.storage) > 10000: # Only cleanup when storage is large
- current_minute = int(now / 60)
- current_day = int(now / 86400)
- keys_to_delete = []
-
- for key in self.storage:
- if ':min:' in key:
- window = int(key.split(':')[-1])
- if window < current_minute - 1:
- keys_to_delete.append(key)
- elif ':day:' in key:
- window = int(key.split(':')[-1])
- if window < current_day - 1:
- keys_to_delete.append(key)
-
- for key in keys_to_delete:
- del self.storage[key]
-
-
-class APIKeyManager:
- """Manages API keys for authentication"""
-
- def __init__(self):
- self.keys_file = _api_keys_file
- self.keys = self._load_keys()
-
- def _load_keys(self) -> dict:
- """Load API keys from file"""
- if os.path.exists(self.keys_file):
- try:
- with open(self.keys_file, 'r') as f:
- return json.load(f)
- except:
- pass
- return {}
-
- def _save_keys(self):
- """Save API keys to file"""
- try:
- with open(self.keys_file, 'w') as f:
- json.dump(self.keys, f, indent=2)
- except Exception as e:
- print(f"Error saving API keys: {e}")
-
- def generate_api_key(self, user_id: str = None) -> str:
- """Generate a new API key"""
- key = f"arb_{secrets.token_hex(24)}"
- key_hash = hashlib.sha256(key.encode()).hexdigest()
-
- self.keys[key_hash] = {
- 'user_id': user_id or 'default',
- 'created_at': datetime.now().isoformat(),
- 'last_used': None,
- 'requests_count': 0,
- 'active': True
- }
- self._save_keys()
- return key
-
- def validate_api_key(self, api_key: str) -> dict:
- """
- Validate API key and return key info
- Returns None if invalid
- """
- if not api_key:
- return None
-
- # For demo/testing: accept any key starting with 'demo-' or 'test-'
- if api_key.startswith('demo-') or api_key.startswith('test-'):
- return {
- 'user_id': 'demo_user',
- 'tier': 'premium',
- 'active': True
- }
-
- key_hash = hashlib.sha256(api_key.encode()).hexdigest()
- key_info = self.keys.get(key_hash)
-
- if key_info and key_info.get('active', True):
- # Update usage stats
- key_info['last_used'] = datetime.now().isoformat()
- key_info['requests_count'] = key_info.get('requests_count', 0) + 1
- self._save_keys()
- return key_info
-
- return None
-
- def revoke_api_key(self, api_key: str) -> bool:
- """Revoke an API key"""
- key_hash = hashlib.sha256(api_key.encode()).hexdigest()
- if key_hash in self.keys:
- self.keys[key_hash]['active'] = False
- self._save_keys()
- return True
- return False
-
- def list_keys(self, user_id: str = None) -> list:
- """List all API keys for a user (returns masked keys)"""
- keys = []
- for key_hash, info in self.keys.items():
- if user_id is None or info.get('user_id') == user_id:
- keys.append({
- 'key_prefix': f"arb_...{key_hash[-8:]}",
- 'created_at': info.get('created_at'),
- 'last_used': info.get('last_used'),
- 'requests_count': info.get('requests_count', 0),
- 'active': info.get('active', True)
- })
- return keys
-
-
-# Global instances
-rate_limiter = RateLimiter()
-api_key_manager = APIKeyManager()
-
-
-def require_api_key(required_tier: str = 'free'):
- """
- Decorator to require API key authentication
- Also enforces rate limiting based on tier
-
- Args:
- required_tier: Minimum tier required ('free', 'basic', 'premium', 'enterprise')
- """
- tier_hierarchy = ['free', 'basic', 'premium', 'enterprise']
-
- def decorator(f):
- @functools.wraps(f)
- def decorated_function(*args, **kwargs):
- # Get API key from header
- api_key = request.headers.get('X-API-Key')
-
- if not api_key:
- return jsonify({
- 'error': 'API key required',
- 'message': 'Please provide an API key in the X-API-Key header'
- }), 401
-
- # Validate API key
- key_info = api_key_manager.validate_api_key(api_key)
- if not key_info:
- return jsonify({
- 'error': 'Invalid API key',
- 'message': 'The provided API key is invalid or has been revoked'
- }), 401
-
- # Get subscription tier
- try:
- from src.subscription import SubscriptionManager
- sub_manager = SubscriptionManager()
- tier = sub_manager.get_tier()
- except:
- tier = key_info.get('tier', 'free')
-
- # Check tier requirements
- if tier_hierarchy.index(tier) < tier_hierarchy.index(required_tier):
- return jsonify({
- 'error': 'Insufficient tier',
- 'message': f'This endpoint requires {required_tier} tier or higher',
- 'current_tier': tier,
- 'required_tier': required_tier
- }), 403
-
- # Check rate limit
- allowed, remaining, reset_time = rate_limiter.check_rate_limit(api_key, tier)
-
- if not allowed:
- response = jsonify({
- 'error': 'Rate limit exceeded',
- 'message': 'Too many requests. Please try again later.',
- 'retry_after': reset_time
- })
- response.headers['X-RateLimit-Remaining'] = '0'
- response.headers['X-RateLimit-Reset'] = str(reset_time)
- response.headers['Retry-After'] = str(reset_time)
- return response, 429
-
- # Store user info in g for use in endpoint
- g.api_key = api_key
- g.user_id = key_info.get('user_id', 'unknown')
- g.tier = tier
-
- # Call the actual function
- response = f(*args, **kwargs)
-
- # Add rate limit headers to response
- if hasattr(response, 'headers'):
- response.headers['X-RateLimit-Remaining'] = str(remaining)
- response.headers['X-RateLimit-Reset'] = str(reset_time)
-
- return response
-
- return decorated_function
- return decorator
-
-
-def log_api_request(endpoint: str, method: str, status_code: int, response_time_ms: float):
- """Log API request for analytics"""
- try:
- log_entry = {
- 'timestamp': datetime.now().isoformat(),
- 'endpoint': endpoint,
- 'method': method,
- 'status_code': status_code,
- 'response_time_ms': response_time_ms,
- 'user_id': getattr(g, 'user_id', 'anonymous'),
- 'tier': getattr(g, 'tier', 'unknown')
- }
-
- # Append to log file
- with open('api_requests.log', 'a') as f:
- f.write(json.dumps(log_entry) + '\n')
- except:
- pass # Don't fail on logging errors
diff --git a/src/api_models.py b/src/api_models.py
deleted file mode 100644
index dabd599..0000000
--- a/src/api_models.py
+++ /dev/null
@@ -1,192 +0,0 @@
-"""
-API Models and Schemas
-Request/response models for API endpoints
-"""
-from dataclasses import dataclass, field, asdict
-from typing import Optional, List, Dict, Any
-from datetime import datetime
-import json
-
-
-@dataclass
-class APIResponse:
- """Standard API response wrapper"""
- success: bool
- data: Any = None
- error: Optional[str] = None
- message: Optional[str] = None
- timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
-
- def to_dict(self) -> dict:
- result = {
- 'success': self.success,
- 'timestamp': self.timestamp
- }
- if self.data is not None:
- result['data'] = self.data
- if self.error:
- result['error'] = self.error
- if self.message:
- result['message'] = self.message
- return result
-
-
-@dataclass
-class ProfitStats:
- """Profit statistics model"""
- total_profit_eth: float
- total_profit_usd: float
- trades_executed: int
- successful_trades: int
- success_rate: float
- last_updated: Optional[str] = None
-
- def to_dict(self) -> dict:
- return asdict(self)
-
-
-@dataclass
-class TradeRecord:
- """Trade record model"""
- id: str
- timestamp: str
- asset: str
- direction: str # 'forward' or 'backward'
- profit_eth: float
- profit_usd: float
- success: bool
- exchange: str = 'binance'
-
- def to_dict(self) -> dict:
- return asdict(self)
-
-
-@dataclass
-class SubscriptionInfo:
- """Subscription information model"""
- tier: str
- features: Dict[str, bool]
- expires_at: Optional[str]
- is_valid: bool
- days_remaining: Optional[int] = None
-
- def to_dict(self) -> dict:
- return asdict(self)
-
-
-@dataclass
-class APIKeyInfo:
- """API key information model"""
- key_prefix: str
- created_at: str
- last_used: Optional[str]
- requests_count: int
- active: bool
-
- def to_dict(self) -> dict:
- return asdict(self)
-
-
-@dataclass
-class ArbitrageOpportunity:
- """Live arbitrage opportunity model"""
- id: str
- asset: str
- direction: str # 'forward' or 'backward'
- estimated_profit_pct: float
- estimated_profit_usd: float
- exchange: str
- timestamp: str
- expires_in_seconds: int = 5
-
- def to_dict(self) -> dict:
- return asdict(self)
-
-
-@dataclass
-class UsageStats:
- """API usage statistics model"""
- total_requests: int
- requests_today: int
- requests_this_month: int
- endpoints_used: Dict[str, int]
- average_response_time_ms: float
-
- def to_dict(self) -> dict:
- return asdict(self)
-
-
-@dataclass
-class HealthStatus:
- """Health check response model"""
- status: str # 'healthy', 'degraded', 'unhealthy'
- version: str
- uptime_seconds: int
- subscription_tier: str
- exchange_connected: bool
- last_trade_at: Optional[str] = None
-
- def to_dict(self) -> dict:
- return asdict(self)
-
-
-# Request validation helpers
-def validate_license_activation_request(data: dict) -> tuple:
- """
- Validate license activation request
- Returns: (is_valid: bool, error_message: Optional[str])
- """
- if not data:
- return False, 'Request body is required'
-
- license_key = data.get('license_key')
- if not license_key:
- return False, 'license_key is required'
-
- tier = data.get('tier', 'premium')
- valid_tiers = ['free', 'basic', 'premium', 'enterprise']
- if tier not in valid_tiers:
- return False, f'Invalid tier. Must be one of: {", ".join(valid_tiers)}'
-
- days = data.get('days', 30)
- if not isinstance(days, int) or days < 0 or days > 365:
- return False, 'days must be an integer between 0 and 365'
-
- return True, None
-
-
-def validate_checkout_request(data: dict) -> tuple:
- """
- Validate checkout session request
- Returns: (is_valid: bool, error_message: Optional[str])
- """
- if not data:
- return False, 'Request body is required'
-
- tier = data.get('tier')
- valid_tiers = ['basic', 'premium', 'enterprise']
- if not tier or tier not in valid_tiers:
- return False, f'tier is required and must be one of: {", ".join(valid_tiers)}'
-
- success_url = data.get('success_url')
- if not success_url:
- return False, 'success_url is required'
-
- cancel_url = data.get('cancel_url')
- if not cancel_url:
- return False, 'cancel_url is required'
-
- return True, None
-
-
-def serialize_response(data: Any) -> dict:
- """Convert response data to JSON-serializable format"""
- if hasattr(data, 'to_dict'):
- return data.to_dict()
- elif isinstance(data, list):
- return [serialize_response(item) for item in data]
- elif isinstance(data, dict):
- return {k: serialize_response(v) for k, v in data.items()}
- elif isinstance(data, datetime):
- return data.isoformat()
- return data
diff --git a/src/api_server.py b/src/api_server.py
deleted file mode 100644
index 9d1d897..0000000
--- a/src/api_server.py
+++ /dev/null
@@ -1,877 +0,0 @@
-"""
-REST API Server for Monetization
-Comprehensive API with authentication, rate limiting, and full monetization features
-"""
-import os
-import time
-from datetime import datetime
-from flask import Flask, jsonify, request, g
-from flask_restful import Api, Resource
-from flask_cors import CORS
-
-# Swagger documentation
-try:
- from flasgger import Swagger, swag_from
- SWAGGER_AVAILABLE = True
-except ImportError:
- SWAGGER_AVAILABLE = False
- def swag_from(*args, **kwargs):
- def decorator(f):
- return f
- return decorator
-
-from src.api_auth import require_api_key, api_key_manager, rate_limiter, log_api_request
-from src.api_models import (
- APIResponse, ProfitStats, SubscriptionInfo, HealthStatus,
- validate_license_activation_request, validate_checkout_request,
- serialize_response
-)
-from src.payment_handler import payment_handler
-from src.subscription import SubscriptionManager
-
-# App configuration
-app = Flask(__name__)
-app.config['JSON_SORT_KEYS'] = False
-app.config['SWAGGER'] = {
- 'title': 'Triangular Arbitrage Bot API',
- 'description': 'REST API for cryptocurrency triangular arbitrage bot monetization',
- 'version': '1.0.0',
- 'termsOfService': '',
- 'contact': {
- 'name': 'API Support',
- 'email': 'support@example.com'
- }
-}
-
-# Enable CORS
-CORS(app, resources={r"/api/*": {"origins": "*"}})
-
-# Initialize Swagger if available
-if SWAGGER_AVAILABLE:
- swagger = Swagger(app)
-
-api = Api(app)
-
-# Global instances
-model_instance = None
-subscription_manager = SubscriptionManager()
-start_time = time.time()
-API_VERSION = '1.0.0'
-
-
-# ============================================================================
-# Health & Status Endpoints
-# ============================================================================
-
-class HealthAPI(Resource):
- """Health check endpoint - no authentication required"""
-
- def get(self):
- """
- Health check endpoint
- ---
- tags:
- - Status
- responses:
- 200:
- description: API is healthy
- """
- uptime = int(time.time() - start_time)
-
- # Check exchange connection
- exchange_connected = False
- last_trade = None
- if model_instance:
- try:
- exchange_connected = model_instance.binance is not None
- if hasattr(model_instance, 'last_trade_time'):
- last_trade = model_instance.last_trade_time
- except:
- pass
-
- status = HealthStatus(
- status='healthy',
- version=API_VERSION,
- uptime_seconds=uptime,
- subscription_tier=subscription_manager.get_tier(),
- exchange_connected=exchange_connected,
- last_trade_at=last_trade
- )
-
- return jsonify(status.to_dict())
-
-
-class VersionAPI(Resource):
- """API version information"""
-
- def get(self):
- """
- Get API version and info
- ---
- tags:
- - Status
- responses:
- 200:
- description: API version information
- """
- return jsonify({
- 'name': 'Triangular Arbitrage Bot API',
- 'version': API_VERSION,
- 'docs': '/api/docs' if SWAGGER_AVAILABLE else None,
- 'endpoints': {
- 'health': '/api/v1/health',
- 'stats': '/api/v1/stats',
- 'trades': '/api/v1/trades',
- 'subscription': '/api/v1/subscription',
- 'pricing': '/api/v1/pricing'
- }
- })
-
-
-# ============================================================================
-# Stats & Analytics Endpoints
-# ============================================================================
-
-class ProfitStatsAPI(Resource):
- """Profit statistics endpoint"""
-
- @require_api_key('basic')
- def get(self):
- """
- Get profit statistics
- ---
- tags:
- - Statistics
- security:
- - ApiKeyAuth: []
- responses:
- 200:
- description: Profit statistics
- 401:
- description: Unauthorized
- 403:
- description: Insufficient subscription tier
- """
- if model_instance is None:
- # Return demo stats if bot not running
- stats = {
- 'total_profit_eth': 0.0,
- 'total_profit_usd': 0.0,
- 'trades_executed': 0,
- 'successful_trades': 0,
- 'success_rate': 0.0,
- 'demo_mode': True
- }
- else:
- stats = model_instance.get_profit_stats()
-
- response = APIResponse(success=True, data=stats)
- return jsonify(response.to_dict())
-
-
-class DetailedStatsAPI(Resource):
- """Detailed analytics endpoint - Premium feature"""
-
- @require_api_key('premium')
- def get(self):
- """
- Get detailed analytics (Premium)
- ---
- tags:
- - Statistics
- security:
- - ApiKeyAuth: []
- responses:
- 200:
- description: Detailed analytics
- 403:
- description: Premium subscription required
- """
- basic_stats = {}
- if model_instance:
- basic_stats = model_instance.get_profit_stats()
-
- # Load additional analytics
- analytics = {
- **basic_stats,
- 'hourly_breakdown': self._get_hourly_breakdown(),
- 'top_performing_pairs': self._get_top_pairs(),
- 'average_profit_per_trade': self._calculate_avg_profit(basic_stats),
- 'profit_trend': self._get_profit_trend(),
- 'tier': g.tier
- }
-
- response = APIResponse(success=True, data=analytics)
- return jsonify(response.to_dict())
-
- def _get_hourly_breakdown(self):
- """Get hourly trade breakdown"""
- # In production, load from database
- return {'note': 'Hourly data available after trades execute'}
-
- def _get_top_pairs(self):
- """Get top performing trading pairs"""
- return {'note': 'Pair data available after trades execute'}
-
- def _calculate_avg_profit(self, stats):
- """Calculate average profit per trade"""
- if stats.get('trades_executed', 0) > 0:
- return stats.get('total_profit_usd', 0) / stats['trades_executed']
- return 0.0
-
- def _get_profit_trend(self):
- """Get profit trend over time"""
- return {'note': 'Trend data available after multiple trades'}
-
-
-# ============================================================================
-# Trade Endpoints
-# ============================================================================
-
-class TradeHistoryAPI(Resource):
- """Trade history endpoint"""
-
- @require_api_key('basic')
- def get(self):
- """
- Get trade history
- ---
- tags:
- - Trades
- parameters:
- - name: limit
- in: query
- type: integer
- default: 100
- description: Number of trades to return
- - name: offset
- in: query
- type: integer
- default: 0
- description: Offset for pagination
- security:
- - ApiKeyAuth: []
- responses:
- 200:
- description: Trade history
- """
- limit = request.args.get('limit', 100, type=int)
- offset = request.args.get('offset', 0, type=int)
-
- # Cap limit at 500
- limit = min(limit, 500)
-
- trades = []
- try:
- with open('logs.txt', 'r') as f:
- lines = f.readlines()
- # Filter for trade-related lines
- trade_lines = [l.strip() for l in lines if 'Arbitrage' in l or 'profit' in l.lower()]
- trades = trade_lines[offset:offset + limit]
- except:
- pass
-
- response = APIResponse(
- success=True,
- data={
- 'trades': trades,
- 'count': len(trades),
- 'limit': limit,
- 'offset': offset
- }
- )
- return jsonify(response.to_dict())
-
-
-class LiveOpportunitiesAPI(Resource):
- """Live arbitrage opportunities - Enterprise feature"""
-
- @require_api_key('enterprise')
- def get(self):
- """
- Get live arbitrage opportunities (Enterprise)
- ---
- tags:
- - Trades
- security:
- - ApiKeyAuth: []
- responses:
- 200:
- description: Live opportunities
- 403:
- description: Enterprise subscription required
- """
- opportunities = []
-
- if model_instance:
- # Scan for current opportunities
- try:
- from data import tokens
- for token in tokens.SYMBOLS[:10]: # Limit to first 10 for performance
- forward_profit = model_instance.estimate_arbitrage_forward(
- model_instance.binance, token
- )
- if forward_profit > 0:
- eth_price = model_instance.get_price(
- model_instance.binance, 'ETH', 'USDT', mode='average'
- ) or 2000
- opportunities.append({
- 'asset': token,
- 'direction': 'forward',
- 'estimated_profit_pct': forward_profit,
- 'estimated_profit_usd': forward_profit * eth_price / 100,
- 'exchange': 'binance',
- 'timestamp': datetime.now().isoformat()
- })
- except Exception as e:
- pass
-
- response = APIResponse(
- success=True,
- data={
- 'opportunities': opportunities,
- 'count': len(opportunities),
- 'scanned_at': datetime.now().isoformat()
- }
- )
- return jsonify(response.to_dict())
-
-
-# ============================================================================
-# Subscription & Licensing Endpoints
-# ============================================================================
-
-class SubscriptionAPI(Resource):
- """Subscription management endpoint"""
-
- def get(self):
- """
- Get subscription information
- ---
- tags:
- - Subscription
- responses:
- 200:
- description: Subscription information
- """
- sub_info = {
- 'tier': subscription_manager.get_tier(),
- 'features': subscription_manager.license_data['features'],
- 'expires_at': subscription_manager.license_data['expires_at'],
- 'is_valid': subscription_manager.is_valid()
- }
-
- # Calculate days remaining
- if sub_info['expires_at']:
- try:
- expires = datetime.fromisoformat(sub_info['expires_at'])
- days_remaining = (expires - datetime.now()).days
- sub_info['days_remaining'] = max(0, days_remaining)
- except:
- pass
-
- response = APIResponse(success=True, data=sub_info)
- return jsonify(response.to_dict())
-
-
-class ActivateLicenseAPI(Resource):
- """License activation endpoint"""
-
- def post(self):
- """
- Activate a license
- ---
- tags:
- - Subscription
- parameters:
- - in: body
- name: body
- schema:
- type: object
- required:
- - license_key
- properties:
- license_key:
- type: string
- tier:
- type: string
- enum: [free, basic, premium, enterprise]
- default: premium
- days:
- type: integer
- default: 30
- responses:
- 200:
- description: License activated
- 400:
- description: Invalid request
- """
- data = request.get_json() or {}
-
- # Validate request
- is_valid, error = validate_license_activation_request(data)
- if not is_valid:
- response = APIResponse(success=False, error=error)
- return jsonify(response.to_dict()), 400
-
- license_key = data.get('license_key')
- tier = data.get('tier', 'premium')
- days = data.get('days', 30)
-
- # Activate license
- if subscription_manager.activate_license(license_key, tier, days):
- response = APIResponse(
- success=True,
- message=f'License activated successfully for {days} days',
- data={
- 'tier': tier,
- 'expires_at': subscription_manager.license_data['expires_at'],
- 'features': subscription_manager.license_data['features']
- }
- )
- return jsonify(response.to_dict())
-
- response = APIResponse(success=False, error='Failed to activate license')
- return jsonify(response.to_dict()), 400
-
-
-class PricingAPI(Resource):
- """Pricing information endpoint"""
-
- def get(self):
- """
- Get pricing information
- ---
- tags:
- - Subscription
- responses:
- 200:
- description: Pricing tiers and features
- """
- pricing = payment_handler.get_pricing_info()
-
- # Add feature descriptions
- feature_descriptions = {
- 'telegram_notifications': 'Real-time Telegram alerts for trades and opportunities',
- 'api_access': 'Full REST API access with rate limiting',
- 'advanced_analytics': 'Detailed profit analytics and reporting',
- 'multi_exchange': 'Support for multiple cryptocurrency exchanges',
- 'custom_strategies': 'Custom trading strategy configuration'
- }
-
- pricing['feature_descriptions'] = feature_descriptions
-
- response = APIResponse(success=True, data=pricing)
- return jsonify(response.to_dict())
-
-
-# ============================================================================
-# Payment Endpoints
-# ============================================================================
-
-class CheckoutAPI(Resource):
- """Payment checkout endpoint"""
-
- def post(self):
- """
- Create checkout session
- ---
- tags:
- - Payment
- parameters:
- - in: body
- name: body
- schema:
- type: object
- required:
- - tier
- - success_url
- - cancel_url
- properties:
- tier:
- type: string
- enum: [basic, premium, enterprise]
- success_url:
- type: string
- cancel_url:
- type: string
- email:
- type: string
- responses:
- 200:
- description: Checkout session created
- 400:
- description: Invalid request
- """
- data = request.get_json() or {}
-
- # Validate request
- is_valid, error = validate_checkout_request(data)
- if not is_valid:
- response = APIResponse(success=False, error=error)
- return jsonify(response.to_dict()), 400
-
- result = payment_handler.create_checkout_session(
- tier=data['tier'],
- success_url=data['success_url'],
- cancel_url=data['cancel_url'],
- customer_email=data.get('email')
- )
-
- if 'error' in result:
- response = APIResponse(success=False, error=result['error'])
- return jsonify(response.to_dict()), 400
-
- response = APIResponse(success=True, data=result)
- return jsonify(response.to_dict())
-
-
-class PaymentWebhookAPI(Resource):
- """Stripe webhook endpoint"""
-
- def post(self):
- """
- Handle Stripe webhook events
- ---
- tags:
- - Payment
- responses:
- 200:
- description: Webhook processed
- 400:
- description: Invalid webhook
- """
- payload = request.get_data()
- signature = request.headers.get('Stripe-Signature', '')
-
- # Verify and parse event
- event = payment_handler.verify_webhook_signature(payload, signature)
- if not event:
- return jsonify({'error': 'Invalid webhook signature'}), 400
-
- # Process event
- result = payment_handler.process_webhook_event(event)
-
- return jsonify({
- 'received': True,
- 'processed': result.get('processed', False),
- 'action': result.get('action')
- })
-
-
-# ============================================================================
-# API Key Management Endpoints
-# ============================================================================
-
-class APIKeysAPI(Resource):
- """API key management endpoint"""
-
- @require_api_key('basic')
- def get(self):
- """
- List API keys
- ---
- tags:
- - API Keys
- security:
- - ApiKeyAuth: []
- responses:
- 200:
- description: List of API keys
- """
- keys = api_key_manager.list_keys(user_id=g.user_id)
- response = APIResponse(success=True, data={'keys': keys})
- return jsonify(response.to_dict())
-
- @require_api_key('basic')
- def post(self):
- """
- Generate new API key
- ---
- tags:
- - API Keys
- security:
- - ApiKeyAuth: []
- responses:
- 201:
- description: API key created
- """
- new_key = api_key_manager.generate_api_key(user_id=g.user_id)
-
- response = APIResponse(
- success=True,
- message='API key generated. Store it securely - it cannot be retrieved later.',
- data={'api_key': new_key}
- )
- return jsonify(response.to_dict()), 201
-
- @require_api_key('basic')
- def delete(self):
- """
- Revoke an API key
- ---
- tags:
- - API Keys
- security:
- - ApiKeyAuth: []
- parameters:
- - in: body
- name: body
- schema:
- type: object
- required:
- - api_key
- properties:
- api_key:
- type: string
- responses:
- 200:
- description: API key revoked
- """
- data = request.get_json() or {}
- key_to_revoke = data.get('api_key')
-
- if not key_to_revoke:
- response = APIResponse(success=False, error='api_key is required')
- return jsonify(response.to_dict()), 400
-
- if api_key_manager.revoke_api_key(key_to_revoke):
- response = APIResponse(success=True, message='API key revoked')
- return jsonify(response.to_dict())
-
- response = APIResponse(success=False, error='API key not found')
- return jsonify(response.to_dict()), 404
-
-
-# ============================================================================
-# Usage Analytics Endpoints
-# ============================================================================
-
-class UsageAnalyticsAPI(Resource):
- """API usage analytics - Premium feature"""
-
- @require_api_key('premium')
- def get(self):
- """
- Get API usage statistics (Premium)
- ---
- tags:
- - Analytics
- security:
- - ApiKeyAuth: []
- responses:
- 200:
- description: Usage statistics
- """
- # Load usage stats from log file
- usage_data = self._load_usage_data()
-
- response = APIResponse(success=True, data=usage_data)
- return jsonify(response.to_dict())
-
- def _load_usage_data(self):
- """Load usage data from log file"""
- total_requests = 0
- requests_today = 0
- endpoints_used = {}
- response_times = []
-
- today = datetime.now().date().isoformat()
-
- try:
- with open('api_requests.log', 'r') as f:
- for line in f:
- try:
- import json
- entry = json.loads(line.strip())
- total_requests += 1
-
- if entry.get('timestamp', '').startswith(today):
- requests_today += 1
-
- endpoint = entry.get('endpoint', 'unknown')
- endpoints_used[endpoint] = endpoints_used.get(endpoint, 0) + 1
-
- if 'response_time_ms' in entry:
- response_times.append(entry['response_time_ms'])
- except:
- pass
- except:
- pass
-
- avg_response_time = sum(response_times) / len(response_times) if response_times else 0
-
- return {
- 'total_requests': total_requests,
- 'requests_today': requests_today,
- 'endpoints_used': endpoints_used,
- 'average_response_time_ms': round(avg_response_time, 2)
- }
-
-
-# ============================================================================
-# Legacy API Routes (backward compatibility)
-# ============================================================================
-
-class LegacyStatsAPI(Resource):
- """Legacy stats endpoint for backward compatibility"""
-
- def get(self):
- if not subscription_manager.has_feature('api_access'):
- return {'error': 'API access requires premium subscription'}, 403
-
- if model_instance is None:
- return {'error': 'Bot not initialized'}, 500
-
- stats = model_instance.get_profit_stats()
- return jsonify(stats)
-
-
-class LegacyTradesAPI(Resource):
- """Legacy trades endpoint for backward compatibility"""
-
- def get(self):
- if not subscription_manager.has_feature('api_access'):
- return {'error': 'API access requires premium subscription'}, 403
-
- try:
- with open('logs.txt', 'r') as f:
- lines = f.readlines()
- return jsonify({'trades': lines[-100:]})
- except:
- return jsonify({'trades': []})
-
-
-class LegacySubscriptionAPI(Resource):
- """Legacy subscription endpoint for backward compatibility"""
-
- def get(self):
- return jsonify({
- 'tier': subscription_manager.get_tier(),
- 'features': subscription_manager.license_data['features'],
- 'expires_at': subscription_manager.license_data['expires_at']
- })
-
- def post(self):
- data = request.get_json()
- license_key = data.get('license_key')
- tier = data.get('tier', 'premium')
- days = data.get('days', 30)
-
- if subscription_manager.activate_license(license_key, tier, days):
- return jsonify({'status': 'success', 'tier': tier})
- return {'error': 'Invalid license key'}, 400
-
-
-class LegacyHealthAPI(Resource):
- """Legacy health endpoint for backward compatibility"""
-
- def get(self):
- return jsonify({
- 'status': 'running',
- 'subscription_tier': subscription_manager.get_tier()
- })
-
-
-# ============================================================================
-# Register Routes
-# ============================================================================
-
-# API v1 Routes (new)
-api.add_resource(HealthAPI, '/api/v1/health')
-api.add_resource(VersionAPI, '/api/v1/version', '/api/v1')
-api.add_resource(ProfitStatsAPI, '/api/v1/stats')
-api.add_resource(DetailedStatsAPI, '/api/v1/stats/detailed')
-api.add_resource(TradeHistoryAPI, '/api/v1/trades')
-api.add_resource(LiveOpportunitiesAPI, '/api/v1/opportunities')
-api.add_resource(SubscriptionAPI, '/api/v1/subscription')
-api.add_resource(ActivateLicenseAPI, '/api/v1/subscription/activate')
-api.add_resource(PricingAPI, '/api/v1/pricing')
-api.add_resource(CheckoutAPI, '/api/v1/payment/checkout')
-api.add_resource(PaymentWebhookAPI, '/api/v1/payment/webhook')
-api.add_resource(APIKeysAPI, '/api/v1/user/api-keys')
-api.add_resource(UsageAnalyticsAPI, '/api/v1/analytics/usage')
-
-# Legacy Routes (backward compatibility)
-api.add_resource(LegacyStatsAPI, '/api/stats')
-api.add_resource(LegacyTradesAPI, '/api/trades')
-api.add_resource(LegacySubscriptionAPI, '/api/subscription')
-api.add_resource(LegacyHealthAPI, '/api/health')
-
-
-# ============================================================================
-# Request Hooks
-# ============================================================================
-
-@app.before_request
-def before_request():
- """Log request start time"""
- g.start_time = time.time()
-
-
-@app.after_request
-def after_request(response):
- """Log request and add headers"""
- # Calculate response time
- if hasattr(g, 'start_time'):
- response_time = (time.time() - g.start_time) * 1000
-
- # Log API request
- if request.path.startswith('/api/'):
- log_api_request(
- endpoint=request.path,
- method=request.method,
- status_code=response.status_code,
- response_time_ms=response_time
- )
-
- # Add CORS headers
- response.headers['Access-Control-Allow-Origin'] = '*'
- response.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-API-Key'
- response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
-
- return response
-
-
-# ============================================================================
-# Server Startup
-# ============================================================================
-
-def start_api_server(port=5000, model=None, debug=False):
- """Start the API server"""
- global model_instance
- model_instance = model
-
- print(f"\n{'='*60}")
- print(f" Triangular Arbitrage Bot API v{API_VERSION}")
- print(f"{'='*60}")
- print(f"\n Server starting on port {port}")
- print(f" Subscription tier: {subscription_manager.get_tier()}")
-
- print(f"\n API v1 Endpoints:")
- print(f" {'─'*50}")
- print(f" GET /api/v1/health - Health check")
- print(f" GET /api/v1/version - Version information")
- print(f" GET /api/v1/stats - Profit statistics")
- print(f" GET /api/v1/stats/detailed - Detailed analytics (Premium)")
- print(f" GET /api/v1/trades - Trade history")
- print(f" GET /api/v1/opportunities - Live opportunities (Enterprise)")
- print(f" GET /api/v1/subscription - Subscription info")
- print(f" POST /api/v1/subscription/activate - Activate license")
- print(f" GET /api/v1/pricing - Pricing information")
- print(f" POST /api/v1/payment/checkout - Create checkout")
- print(f" POST /api/v1/payment/webhook - Stripe webhook")
- print(f" GET /api/v1/user/api-keys - List API keys")
- print(f" POST /api/v1/user/api-keys - Generate API key")
- print(f" GET /api/v1/analytics/usage - Usage stats (Premium)")
-
- if SWAGGER_AVAILABLE:
- print(f"\n Documentation: http://localhost:{port}/apidocs")
-
- print(f"\n{'='*60}\n")
-
- app.run(host='0.0.0.0', port=port, debug=debug)
-
-
-if __name__ == '__main__':
- start_api_server()
diff --git a/src/arbitrage_algorithms.py b/src/arbitrage_algorithms.py
deleted file mode 100644
index 736c942..0000000
--- a/src/arbitrage_algorithms.py
+++ /dev/null
@@ -1,470 +0,0 @@
-"""
-Advanced arbitrage algorithms for triangular arbitrage detection and execution.
-Implements various sophisticated arbitrage strategies including statistical arbitrage,
-surface arbitrage, and multi-path optimization.
-"""
-
-import math
-import numpy as np
-import pandas as pd
-from typing import Dict, List, Tuple, Optional, Any
-from dataclasses import dataclass
-from datetime import datetime, timedelta
-import logging
-import statistics
-from collections import deque
-
-
-@dataclass
-class ArbitrageOpportunity:
- """Represents an arbitrage opportunity."""
- exchange: str
- base_currency: str
- quote_currency: str
- alt_currency: str
- direction: str # 'forward' or 'backward'
- profit_percentage: float
- profit_usd: float
- estimated_profit: float
- path: List[str]
- orderbook_depth: int
- timestamp: datetime
- volatility: float = 0.0
- liquidity_score: float = 0.0
-
-
-class AdvancedArbitrageAlgorithms:
- """Advanced algorithms for detecting and analyzing arbitrage opportunities."""
-
- def __init__(self, exchange_manager, settings):
- self.exchange_manager = exchange_manager
- self.settings = settings
- self.logger = logging.getLogger(__name__)
-
- # Historical data for statistical analysis
- self.price_history = {}
- self.arbitrage_history = deque(maxlen=1000)
- self.volatility_window = 50
- self.profit_threshold_history = deque(maxlen=100)
-
- def detect_triangular_arbitrage(self, exchange_name: str, base: str, quote: str, alt: str) -> Optional[ArbitrageOpportunity]:
- """
- Enhanced triangular arbitrage detection with advanced algorithms.
-
- Args:
- exchange_name: Name of the exchange
- base: Base currency (e.g., 'ETH')
- quote: Quote currency (e.g., 'BTC')
- alt: Alternative currency for triangulation
-
- Returns:
- ArbitrageOpportunity if profitable opportunity found, None otherwise
- """
- try:
- # Get current market data
- base_quote_symbol = f"{base}/{quote}"
- base_alt_symbol = f"{base}/{alt}"
- alt_quote_symbol = f"{alt}/{quote}"
-
- base_quote_orderbook = self.exchange_manager.get_order_book(exchange_name, base_quote_symbol)
- base_alt_orderbook = self.exchange_manager.get_order_book(exchange_name, base_alt_symbol)
- alt_quote_orderbook = self.exchange_manager.get_order_book(exchange_name, alt_quote_symbol)
-
- if not all([base_quote_orderbook, base_alt_orderbook, alt_quote_orderbook]):
- return None
-
- # Forward arbitrage: base -> alt -> quote -> base
- forward_profit = self._calculate_forward_arbitrage(
- base_quote_orderbook, base_alt_orderbook, alt_quote_orderbook
- )
-
- # Backward arbitrage: base -> quote -> alt -> base
- backward_profit = self._calculate_backward_arbitrage(
- base_quote_orderbook, base_alt_orderbook, alt_quote_orderbook
- )
-
- # Apply advanced filtering
- opportunities = []
-
- if forward_profit['profit_percentage'] > self.settings.MIN_DIFFERENCE:
- opportunity = self._create_opportunity(
- exchange_name, base, quote, alt, 'forward',
- forward_profit, [base, alt, quote, base]
- )
- if self._passes_advanced_filters(opportunity):
- opportunities.append(opportunity)
-
- if backward_profit['profit_percentage'] > self.settings.MIN_DIFFERENCE:
- opportunity = self._create_opportunity(
- exchange_name, base, quote, alt, 'backward',
- backward_profit, [base, quote, alt, base]
- )
- if self._passes_advanced_filters(opportunity):
- opportunities.append(opportunity)
-
- # Return best opportunity
- return max(opportunities, key=lambda x: x.profit_percentage) if opportunities else None
-
- except Exception as e:
- self.logger.error(f"Error detecting triangular arbitrage: {e}")
- return None
-
- def _calculate_forward_arbitrage(self, base_quote_ob, base_alt_ob, alt_quote_ob) -> Dict[str, float]:
- """Calculate forward arbitrage: base -> alt -> quote -> base"""
- try:
- # Step 1: base -> alt (sell base for alt)
- base_alt_ask = base_alt_ob['asks'][0][0] # Sell base for alt
-
- # Step 2: alt -> quote (sell alt for quote)
- alt_quote_ask = alt_quote_ob['asks'][0][0] # Sell alt for quote
-
- # Step 3: quote -> base (buy base with quote)
- base_quote_bid = base_quote_ob['bids'][0][0] # Buy base with quote
-
- # Calculate final amount after fees
- fee = self.exchange_manager.get_exchange_fee(self.exchange_manager.get_enabled_exchanges()[0])
- maker_fee = self.exchange_manager.get_exchange_fee(self.exchange_manager.get_enabled_exchanges()[0], maker=True)
-
- # Forward calculation with realistic fees
- final_amount = 1.0
- final_amount *= (1 - fee) / base_alt_ask # Sell base for alt
- final_amount *= (1 - fee) / alt_quote_ask # Sell alt for quote
- final_amount *= (1 - maker_fee) * base_quote_bid # Buy base with quote (maker)
-
- profit_percentage = (final_amount - 1.0) * 100
- estimated_profit_usd = profit_percentage * 0.01 * self._get_base_usd_price()
-
- return {
- 'profit_percentage': profit_percentage,
- 'estimated_profit_usd': estimated_profit_usd,
- 'final_amount': final_amount
- }
- except (IndexError, KeyError) as e:
- self.logger.debug(f"Insufficient orderbook data for forward arbitrage: {e}")
- return {'profit_percentage': 0, 'estimated_profit_usd': 0, 'final_amount': 1.0}
-
- def _calculate_backward_arbitrage(self, base_quote_ob, base_alt_ob, alt_quote_ob) -> Dict[str, float]:
- """Calculate backward arbitrage: base -> quote -> alt -> base"""
- try:
- # Step 1: base -> quote (sell base for quote)
- base_quote_ask = base_quote_ob['asks'][0][0] # Sell base for quote
-
- # Step 2: quote -> alt (buy alt with quote)
- alt_quote_bid = alt_quote_ob['bids'][0][0] # Buy alt with quote
-
- # Step 3: alt -> base (sell alt for base)
- base_alt_bid = base_alt_ob['bids'][0][0] # Buy base with alt (sell alt for base)
-
- # Calculate final amount after fees
- fee = self.exchange_manager.get_exchange_fee(self.exchange_manager.get_enabled_exchanges()[0])
- maker_fee = self.exchange_manager.get_exchange_fee(self.exchange_manager.get_enabled_exchanges()[0], maker=True)
-
- # Backward calculation with realistic fees
- final_amount = 1.0
- final_amount *= (1 - fee) / base_quote_ask # Sell base for quote
- final_amount *= (1 - maker_fee) * alt_quote_bid # Buy alt with quote (maker)
- final_amount *= (1 - fee) * base_alt_bid # Sell alt for base
-
- profit_percentage = (final_amount - 1.0) * 100
- estimated_profit_usd = profit_percentage * 0.01 * self._get_base_usd_price()
-
- return {
- 'profit_percentage': profit_percentage,
- 'estimated_profit_usd': estimated_profit_usd,
- 'final_amount': final_amount
- }
- except (IndexError, KeyError) as e:
- self.logger.debug(f"Insufficient orderbook data for backward arbitrage: {e}")
- return {'profit_percentage': 0, 'estimated_profit_usd': 0, 'final_amount': 1.0}
-
- def _create_opportunity(self, exchange: str, base: str, quote: str, alt: str,
- direction: str, profit_data: Dict, path: List[str]) -> ArbitrageOpportunity:
- """Create an ArbitrageOpportunity object with advanced metrics."""
- opportunity = ArbitrageOpportunity(
- exchange=exchange,
- base_currency=base,
- quote_currency=quote,
- alt_currency=alt,
- direction=direction,
- profit_percentage=profit_data['profit_percentage'],
- profit_usd=profit_data['estimated_profit_usd'],
- estimated_profit=profit_data['final_amount'] - 1.0,
- path=path,
- orderbook_depth=self.settings.ESTIMATION_ORDERBOOK,
- timestamp=datetime.now(),
- volatility=self._calculate_volatility(f"{base}/{quote}"),
- liquidity_score=self._calculate_liquidity_score(exchange, [f"{base}/{quote}", f"{base}/{alt}", f"{alt}/{quote}"])
- )
- return opportunity
-
- def _passes_advanced_filters(self, opportunity: ArbitrageOpportunity) -> bool:
- """Apply advanced filtering to arbitrage opportunities."""
- # Basic profit threshold
- if opportunity.profit_usd < self.settings.MIN_PROFIT_USD:
- return False
-
- # Volatility filter - avoid highly volatile opportunities
- if opportunity.volatility > 0.05: # 5% volatility threshold
- return False
-
- # Liquidity filter - ensure sufficient liquidity
- if opportunity.liquidity_score < 0.3: # Minimum liquidity score
- return False
-
- # Historical performance filter
- if not self._passes_historical_filter(opportunity):
- return False
-
- # Risk management filter
- if not self._passes_risk_management_filter(opportunity):
- return False
-
- return True
-
- def _calculate_volatility(self, symbol: str) -> float:
- """Calculate price volatility for a symbol."""
- if symbol not in self.price_history:
- return 0.0
-
- prices = list(self.price_history[symbol])
- if len(prices) < 10:
- return 0.0
-
- try:
- returns = [math.log(prices[i] / prices[i-1]) for i in range(1, len(prices))]
- return statistics.stdev(returns) if returns else 0.0
- except:
- return 0.0
-
- def _calculate_liquidity_score(self, exchange: str, symbols: List[str]) -> float:
- """Calculate liquidity score across multiple symbols."""
- total_score = 0.0
- valid_symbols = 0
-
- for symbol in symbols:
- orderbook = self.exchange_manager.get_order_book(exchange, symbol, limit=10)
- if orderbook and len(orderbook.get('bids', [])) >= 5 and len(orderbook.get('asks', [])) >= 5:
- # Calculate spread and depth
- best_bid = orderbook['bids'][0][0]
- best_ask = orderbook['asks'][0][0]
- spread = (best_ask - best_bid) / best_bid
-
- # Calculate depth (sum of top 5 levels)
- bid_depth = sum(vol for _, vol in orderbook['bids'][:5])
- ask_depth = sum(vol for _, vol in orderbook['asks'][:5])
- avg_depth = (bid_depth + ask_depth) / 2
-
- # Combine spread and depth into score
- spread_score = max(0, 1 - spread * 100) # Lower spread = higher score
- depth_score = min(1.0, avg_depth / 1000) # Normalize depth
-
- symbol_score = (spread_score + depth_score) / 2
- total_score += symbol_score
- valid_symbols += 1
-
- return total_score / max(valid_symbols, 1)
-
- def _passes_historical_filter(self, opportunity: ArbitrageOpportunity) -> bool:
- """Filter based on historical arbitrage performance."""
- if len(self.arbitrage_history) < 10:
- return True
-
- # Check success rate for this type of arbitrage
- similar_opportunities = [
- opp for opp in self.arbitrage_history
- if opp.base_currency == opportunity.base_currency and
- opp.quote_currency == opportunity.quote_currency and
- opp.direction == opportunity.direction
- ]
-
- if len(similar_opportunities) < 5:
- return True
-
- success_rate = sum(1 for opp in similar_opportunities if opp.profit_percentage > 0) / len(similar_opportunities)
-
- # Require at least 60% success rate for similar opportunities
- return success_rate >= 0.6
-
- def _passes_risk_management_filter(self, opportunity: ArbitrageOpportunity) -> bool:
- """Apply risk management filters."""
- # Check consecutive losses
- if hasattr(self, 'consecutive_losses') and self.consecutive_losses >= self.settings.MAX_CONSECUTIVE_LOSSES:
- return False
-
- # Check daily loss limit (simplified - would need daily tracking)
- if hasattr(self, 'daily_loss') and self.daily_loss >= self.settings.MAX_DAILY_LOSS_PERCENTAGE:
- return False
-
- # Position size check
- max_position_value = self._calculate_max_position_size(opportunity)
- if opportunity.profit_usd > max_position_value:
- return False
-
- return True
-
- def _calculate_max_position_size(self, opportunity: ArbitrageOpportunity) -> float:
- """Calculate maximum position size based on risk parameters."""
- # Get available balance in base currency
- balance = self.exchange_manager.get_balance(opportunity.exchange, opportunity.base_currency)
-
- # Apply position size limits
- max_by_balance = balance * self.settings.MAX_POSITION_SIZE
-
- # Dynamic sizing based on volatility and liquidity
- volatility_multiplier = max(0.1, 1 - opportunity.volatility * 10)
- liquidity_multiplier = opportunity.liquidity_score
-
- dynamic_size = max_by_balance * volatility_multiplier * liquidity_multiplier
-
- return min(dynamic_size, max_by_balance)
-
- def _get_base_usd_price(self) -> float:
- """Get USD price of base currency for profit calculations."""
- # Simplified - in production would fetch real price
- base_prices = {
- 'ETH': 3000,
- 'BTC': 50000,
- 'BNB': 400,
- 'ADA': 0.5,
- 'SOL': 100
- }
- return base_prices.get(self.settings.DEFAULT_BASE_CURRENCY, 3000)
-
- def detect_statistical_arbitrage(self, exchange_name: str, symbols: List[str]) -> List[ArbitrageOpportunity]:
- """Detect statistical arbitrage opportunities using cointegration."""
- opportunities = []
-
- if len(symbols) < 2:
- return opportunities
-
- try:
- # Get price data for cointegration analysis
- price_data = {}
- for symbol in symbols:
- ticker = self.exchange_manager.get_ticker(exchange_name, symbol)
- if ticker:
- price_data[symbol] = ticker['last']
-
- if len(price_data) < 2:
- return opportunities
-
- # Simple statistical arbitrage detection
- # In a full implementation, this would use cointegration tests
- prices = list(price_data.values())
- mean_price = statistics.mean(prices)
- std_dev = statistics.stdev(prices) if len(prices) > 1 else 0
-
- # Flag opportunities where price deviates significantly from mean
- for symbol, price in price_data.items():
- z_score = (price - mean_price) / std_dev if std_dev > 0 else 0
-
- if abs(z_score) > 2.0: # 2 standard deviations
- opportunity = ArbitrageOpportunity(
- exchange=exchange_name,
- base_currency=symbol.split('/')[0],
- quote_currency=symbol.split('/')[1],
- alt_currency='', # Not applicable for statistical arb
- direction='statistical',
- profit_percentage=abs(z_score) * 2, # Estimated profit
- profit_usd=abs(z_score) * 10, # Rough USD estimate
- estimated_profit=abs(z_score) * 0.01,
- path=[symbol],
- orderbook_depth=1,
- timestamp=datetime.now(),
- volatility=std_dev / mean_price,
- liquidity_score=0.5
- )
- opportunities.append(opportunity)
-
- except Exception as e:
- self.logger.error(f"Error in statistical arbitrage detection: {e}")
-
- return opportunities
-
- def detect_surface_arbitrage(self, exchange_name: str) -> List[ArbitrageOpportunity]:
- """Detect surface arbitrage using order book analysis."""
- opportunities = []
-
- try:
- # Get all available symbols for the exchange
- symbols = self.exchange_manager.get_exchange_tokens(exchange_name)
- base_currency = self.settings.DEFAULT_BASE_CURRENCY
-
- # Look for triangular relationships
- for alt in symbols[:10]: # Limit for performance
- if alt == base_currency:
- continue
-
- opportunity = self.detect_triangular_arbitrage(
- exchange_name, base_currency, self.settings.DEFAULT_QUOTE_CURRENCY, alt
- )
-
- if opportunity:
- opportunities.append(opportunity)
-
- except Exception as e:
- self.logger.error(f"Error in surface arbitrage detection: {e}")
-
- return opportunities
-
- def optimize_arbitrage_path(self, exchange_name: str, start_currency: str, target_currency: str,
- max_depth: int = 3) -> Optional[ArbitrageOpportunity]:
- """Find optimal arbitrage path between two currencies using graph algorithms."""
- # This is a simplified implementation
- # Full implementation would use graph theory and shortest path algorithms
-
- try:
- # For now, just check direct triangular arbitrage
- symbols = self.exchange_manager.get_exchange_tokens(exchange_name)
-
- for intermediate in symbols[:5]: # Check a few intermediates
- if intermediate in [start_currency, target_currency]:
- continue
-
- opportunity = self.detect_triangular_arbitrage(
- exchange_name, start_currency, target_currency, intermediate
- )
-
- if opportunity:
- return opportunity
-
- except Exception as e:
- self.logger.error(f"Error in path optimization: {e}")
-
- return None
-
- def update_price_history(self, symbol: str, price: float):
- """Update price history for statistical analysis."""
- if symbol not in self.price_history:
- self.price_history[symbol] = deque(maxlen=self.volatility_window)
-
- self.price_history[symbol].append(price)
-
- def record_arbitrage_result(self, opportunity: ArbitrageOpportunity, success: bool):
- """Record arbitrage execution result for learning."""
- self.arbitrage_history.append(opportunity)
-
- if success:
- self.profit_threshold_history.append(opportunity.profit_percentage)
- else:
- # Track consecutive losses for risk management
- if not hasattr(self, 'consecutive_losses'):
- self.consecutive_losses = 0
- self.consecutive_losses = self.consecutive_losses + 1 if not success else 0
-
- def get_arbitrage_statistics(self) -> Dict[str, Any]:
- """Get comprehensive arbitrage statistics."""
- if not self.arbitrage_history:
- return {}
-
- total_opportunities = len(self.arbitrage_history)
- profitable_opportunities = [opp for opp in self.arbitrage_history if opp.profit_percentage > 0]
-
- return {
- 'total_opportunities': total_opportunities,
- 'profitable_opportunities': len(profitable_opportunities),
- 'success_rate': len(profitable_opportunities) / total_opportunities,
- 'average_profit': statistics.mean([opp.profit_percentage for opp in profitable_opportunities]) if profitable_opportunities else 0,
- 'best_profit': max([opp.profit_percentage for opp in self.arbitrage_history]) if self.arbitrage_history else 0,
- 'worst_profit': min([opp.profit_percentage for opp in self.arbitrage_history]) if self.arbitrage_history else 0,
- }
diff --git a/src/backtester.py b/src/backtester.py
deleted file mode 100644
index e9ee6e7..0000000
--- a/src/backtester.py
+++ /dev/null
@@ -1,673 +0,0 @@
-"""
-Comprehensive backtesting framework for arbitrage strategies.
-Tests trading strategies against historical data to evaluate performance.
-"""
-
-import pandas as pd
-import numpy as np
-from datetime import datetime, timedelta
-from typing import Dict, List, Tuple, Optional, Any
-import logging
-import json
-import os
-from dataclasses import dataclass, asdict
-from collections import defaultdict
-import matplotlib.pyplot as plt
-import seaborn as sns
-from tqdm import tqdm
-
-
-@dataclass
-class BacktestResult:
- """Results from a backtesting run."""
- strategy_name: str
- start_date: datetime
- end_date: datetime
- total_trades: int
- profitable_trades: int
- total_profit_usd: float
- total_profit_percentage: float
- max_drawdown: float
- sharpe_ratio: float
- win_rate: float
- avg_profit_per_trade: float
- avg_holding_time: float
- max_consecutive_losses: int
- profit_factor: float
- calmar_ratio: float
- sortino_ratio: float
- alpha: float
- beta: float
- benchmark_return: float
-
-
-@dataclass
-class Trade:
- """Represents a single trade in backtesting."""
- timestamp: datetime
- exchange: str
- base_currency: str
- quote_currency: str
- alt_currency: str
- direction: str
- entry_price: float
- exit_price: float
- quantity: float
- entry_fee: float
- exit_fee: float
- profit_usd: float
- profit_percentage: float
- holding_time: float
- arbitrage_type: str
-
-
-class ArbitrageBacktester:
- """Backtesting framework for arbitrage strategies."""
-
- def __init__(self, exchange_manager, data_directory: str = "historical_data"):
- self.exchange_manager = exchange_manager
- self.data_directory = data_directory
- self.logger = logging.getLogger(__name__)
-
- # Ensure data directory exists
- os.makedirs(data_directory, exist_ok=True)
-
- # Historical data cache
- self.price_data = {}
- self.orderbook_data = {}
-
- # Backtesting parameters
- self.transaction_fee = 0.001 # 0.1% default
- self.slippage = 0.0005 # 0.05% default
- self.min_profit_threshold = 0.001 # 0.1% minimum
-
- def load_historical_data(self, exchange: str, symbol: str, start_date: datetime,
- end_date: datetime, timeframe: str = '1m') -> pd.DataFrame:
- """
- Load historical price data for backtesting.
-
- Args:
- exchange: Exchange name
- symbol: Trading symbol (e.g., 'BTC/USDT')
- start_date: Start date for data
- end_date: End date for data
- timeframe: Data timeframe ('1m', '5m', '1h', etc.)
-
- Returns:
- DataFrame with OHLCV data
- """
- try:
- # Check if data already exists
- data_file = os.path.join(
- self.data_directory,
- f"{exchange}_{symbol.replace('/', '_')}_{timeframe}_{start_date.strftime('%Y%m%d')}_{end_date.strftime('%Y%m%d')}.csv"
- )
-
- if os.path.exists(data_file):
- df = pd.read_csv(data_file, parse_dates=['timestamp'])
- df.set_index('timestamp', inplace=True)
- return df
-
- # Fetch data from exchange (if available)
- if exchange in self.exchange_manager.exchanges:
- exchange_instance = self.exchange_manager.exchanges[exchange]['instance']
-
- # Convert dates to milliseconds
- since = int(start_date.timestamp() * 1000)
-
- # Fetch OHLCV data
- ohlcv = exchange_instance.fetch_ohlcv(symbol, timeframe, since)
-
- # Convert to DataFrame
- df = pd.DataFrame(ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
- df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
- df.set_index('timestamp', inplace=True)
-
- # Save to file
- df.to_csv(data_file)
-
- return df
-
- except Exception as e:
- self.logger.error(f"Error loading historical data for {exchange} {symbol}: {e}")
-
- # Return empty DataFrame if data loading fails
- return pd.DataFrame(columns=['open', 'high', 'low', 'close', 'volume'])
-
- def simulate_triangular_arbitrage(self, exchange: str, base: str, quote: str, alt: str,
- start_date: datetime, end_date: datetime,
- settings: Dict[str, Any]) -> List[Trade]:
- """
- Simulate triangular arbitrage trading for a specific triangle.
-
- Args:
- exchange: Exchange name
- base: Base currency
- quote: Quote currency
- alt: Alternative currency
- start_date: Start date for simulation
- end_date: End date for simulation
- settings: Trading settings
-
- Returns:
- List of simulated trades
- """
- trades = []
-
- # Load historical data for all three pairs
- pairs = [
- f"{base}/{quote}",
- f"{base}/{alt}",
- f"{alt}/{quote}"
- ]
-
- price_data = {}
- for pair in pairs:
- df = self.load_historical_data(exchange, pair, start_date, end_date)
- if not df.empty:
- price_data[pair] = df
-
- if len(price_data) < 3:
- self.logger.warning(f"Insufficient historical data for {base}-{alt}-{quote} triangle")
- return trades
-
- # Combine timestamps from all pairs
- all_timestamps = set()
- for df in price_data.values():
- all_timestamps.update(df.index)
- all_timestamps = sorted(list(all_timestamps))
-
- # Simulate trading
- for timestamp in tqdm(all_timestamps, desc=f"Simulating {base}-{alt}-{quote}"):
- try:
- # Get prices at this timestamp (forward fill if needed)
- prices = {}
- for pair, df in price_data.items():
- if timestamp in df.index:
- prices[pair] = df.loc[timestamp, 'close']
- else:
- # Forward fill
- available_timestamps = df.index[df.index <= timestamp]
- if not available_timestamps.empty:
- closest_time = available_timestamps[-1]
- prices[pair] = df.loc[closest_time, 'close']
-
- if len(prices) < 3:
- continue
-
- # Check for arbitrage opportunities
- forward_profit = self._calculate_simulated_forward_arbitrage(
- prices[f"{base}/{quote}"],
- prices[f"{base}/{alt}"],
- prices[f"{alt}/{quote}"]
- )
-
- backward_profit = self._calculate_simulated_backward_arbitrage(
- prices[f"{base}/{quote}"],
- prices[f"{base}/{alt}"],
- prices[f"{alt}/{quote}"]
- )
-
- # Execute trade if profitable
- if forward_profit['profit_percentage'] > settings.get('MIN_DIFFERENCE', 0.15):
- trade = self._create_simulated_trade(
- timestamp, exchange, base, quote, alt, 'forward',
- forward_profit, settings
- )
- if trade:
- trades.append(trade)
-
- elif backward_profit['profit_percentage'] > settings.get('MIN_DIFFERENCE', 0.15):
- trade = self._create_simulated_trade(
- timestamp, exchange, base, quote, alt, 'backward',
- backward_profit, settings
- )
- if trade:
- trades.append(trade)
-
- except Exception as e:
- self.logger.debug(f"Error simulating trade at {timestamp}: {e}")
- continue
-
- return trades
-
- def _calculate_simulated_forward_arbitrage(self, base_quote_price: float,
- base_alt_price: float,
- alt_quote_price: float) -> Dict[str, float]:
- """Calculate forward arbitrage with simulated fees and slippage."""
- # Apply slippage
- slippage_factor = 1 + self.slippage
-
- # Forward: base -> alt -> quote -> base
- # Step 1: base -> alt (sell base for alt)
- base_to_alt = base_alt_price / slippage_factor # Worse price due to slippage
-
- # Step 2: alt -> quote (sell alt for quote)
- alt_to_quote = alt_quote_price / slippage_factor
-
- # Step 3: quote -> base (buy base with quote)
- quote_to_base = 1 / (base_quote_price * slippage_factor) # Worse price
-
- # Calculate final amount
- final_amount = 1.0
- final_amount *= (1 - self.transaction_fee) / base_to_alt
- final_amount *= (1 - self.transaction_fee) / alt_to_quote
- final_amount *= (1 - self.transaction_fee) * quote_to_base
-
- profit_percentage = (final_amount - 1.0) * 100
- profit_usd = profit_percentage * 0.01 * 3000 # Assume $3000 per unit
-
- return {
- 'profit_percentage': profit_percentage,
- 'profit_usd': profit_usd,
- 'final_amount': final_amount
- }
-
- def _calculate_simulated_backward_arbitrage(self, base_quote_price: float,
- base_alt_price: float,
- alt_quote_price: float) -> Dict[str, float]:
- """Calculate backward arbitrage with simulated fees and slippage."""
- # Apply slippage
- slippage_factor = 1 + self.slippage
-
- # Backward: base -> quote -> alt -> base
- # Step 1: base -> quote (sell base for quote)
- base_to_quote = base_quote_price / slippage_factor
-
- # Step 2: quote -> alt (buy alt with quote)
- quote_to_alt = 1 / (alt_quote_price * slippage_factor)
-
- # Step 3: alt -> base (sell alt for base)
- alt_to_base = base_alt_price / slippage_factor
-
- # Calculate final amount
- final_amount = 1.0
- final_amount *= (1 - self.transaction_fee) / base_to_quote
- final_amount *= (1 - self.transaction_fee) * quote_to_alt
- final_amount *= (1 - self.transaction_fee) * alt_to_base
-
- profit_percentage = (final_amount - 1.0) * 100
- profit_usd = profit_percentage * 0.01 * 3000
-
- return {
- 'profit_percentage': profit_percentage,
- 'profit_usd': profit_usd,
- 'final_amount': final_amount
- }
-
- def _create_simulated_trade(self, timestamp: datetime, exchange: str, base: str,
- quote: str, alt: str, direction: str,
- profit_data: Dict[str, float], settings: Dict[str, Any]) -> Optional[Trade]:
- """Create a simulated trade object."""
- try:
- # Calculate position size based on settings
- max_position = settings.get('MAX_POSITION_SIZE', 0.5)
- base_balance = 1.0 # Assume 1 unit starting balance
- position_size = base_balance * max_position
-
- # Calculate fees
- entry_fee = position_size * self.transaction_fee
- exit_fee = position_size * self.transaction_fee
-
- # Create trade
- trade = Trade(
- timestamp=timestamp,
- exchange=exchange,
- base_currency=base,
- quote_currency=quote,
- alt_currency=alt,
- direction=direction,
- entry_price=1.0, # Normalized entry
- exit_price=profit_data['final_amount'],
- quantity=position_size,
- entry_fee=entry_fee,
- exit_fee=exit_fee,
- profit_usd=profit_data['profit_usd'] * position_size,
- profit_percentage=profit_data['profit_percentage'],
- holding_time=60.0, # Assume 1 minute holding time
- arbitrage_type='triangular'
- )
-
- return trade
-
- except Exception as e:
- self.logger.error(f"Error creating simulated trade: {e}")
- return None
-
- def run_backtest(self, strategy_name: str, exchanges: List[str], base_currencies: List[str],
- start_date: datetime, end_date: datetime, settings: Dict[str, Any]) -> BacktestResult:
- """
- Run comprehensive backtest for arbitrage strategy.
-
- Args:
- strategy_name: Name of the strategy being tested
- exchanges: List of exchanges to test
- base_currencies: List of base currencies to test
- start_date: Start date for backtest
- end_date: End date for backtest
- settings: Trading settings
-
- Returns:
- BacktestResult with comprehensive performance metrics
- """
- all_trades = []
-
- # Run simulation for each exchange and currency combination
- for exchange in exchanges:
- for base in base_currencies:
- # Get available alt currencies for this exchange
- alt_currencies = self.exchange_manager.get_exchange_tokens(exchange)[:10] # Limit for performance
-
- for alt in alt_currencies:
- if alt == base:
- continue
-
- try:
- trades = self.simulate_triangular_arbitrage(
- exchange, base, settings.get('DEFAULT_QUOTE_CURRENCY', 'BTC'),
- alt, start_date, end_date, settings
- )
- all_trades.extend(trades)
-
- except Exception as e:
- self.logger.error(f"Error backtesting {exchange} {base}-{alt}: {e}")
- continue
-
- # Calculate performance metrics
- if not all_trades:
- return BacktestResult(
- strategy_name=strategy_name,
- start_date=start_date,
- end_date=end_date,
- total_trades=0,
- profitable_trades=0,
- total_profit_usd=0.0,
- total_profit_percentage=0.0,
- max_drawdown=0.0,
- sharpe_ratio=0.0,
- win_rate=0.0,
- avg_profit_per_trade=0.0,
- avg_holding_time=0.0,
- max_consecutive_losses=0,
- profit_factor=0.0,
- calmar_ratio=0.0,
- sortino_ratio=0.0,
- alpha=0.0,
- beta=0.0,
- benchmark_return=0.0
- )
-
- # Convert trades to DataFrame for analysis
- trades_df = pd.DataFrame([asdict(trade) for trade in all_trades])
- trades_df['timestamp'] = pd.to_datetime(trades_df['timestamp'])
-
- # Calculate returns
- trades_df['returns'] = trades_df['profit_percentage'] / 100
- trades_df['cumulative_returns'] = (1 + trades_df['returns']).cumprod() - 1
-
- # Calculate drawdown
- rolling_max = trades_df['cumulative_returns'].expanding().max()
- drawdown = trades_df['cumulative_returns'] - rolling_max
- max_drawdown = drawdown.min()
-
- # Calculate Sharpe ratio (assuming daily returns)
- if len(trades_df) > 1:
- daily_returns = trades_df.set_index('timestamp')['returns'].resample('D').sum()
- sharpe_ratio = daily_returns.mean() / daily_returns.std() * np.sqrt(365) if daily_returns.std() > 0 else 0
- else:
- sharpe_ratio = 0
-
- # Calculate Sortino ratio (downside deviation)
- negative_returns = trades_df[trades_df['returns'] < 0]['returns']
- downside_std = negative_returns.std() if len(negative_returns) > 0 else 0
- sortino_ratio = trades_df['returns'].mean() / downside_std * np.sqrt(365) if downside_std > 0 else 0
-
- # Calculate win rate and profit factor
- profitable_trades = len(trades_df[trades_df['profit_usd'] > 0])
- losing_trades = len(trades_df[trades_df['profit_usd'] <= 0])
-
- win_rate = profitable_trades / len(trades_df) if len(trades_df) > 0 else 0
-
- gross_profit = trades_df[trades_df['profit_usd'] > 0]['profit_usd'].sum()
- gross_loss = abs(trades_df[trades_df['profit_usd'] <= 0]['profit_usd'].sum())
- profit_factor = gross_profit / gross_loss if gross_loss > 0 else float('inf')
-
- # Calculate consecutive losses
- consecutive_losses = 0
- max_consecutive_losses = 0
- for profit in trades_df['profit_usd']:
- if profit <= 0:
- consecutive_losses += 1
- max_consecutive_losses = max(max_consecutive_losses, consecutive_losses)
- else:
- consecutive_losses = 0
-
- # Calculate Calmar ratio
- total_return = trades_df['cumulative_returns'].iloc[-1] if len(trades_df) > 0 else 0
- years = (end_date - start_date).days / 365
- annualized_return = (1 + total_return) ** (1 / years) - 1 if years > 0 else 0
- calmar_ratio = annualized_return / abs(max_drawdown) if max_drawdown != 0 else 0
-
- return BacktestResult(
- strategy_name=strategy_name,
- start_date=start_date,
- end_date=end_date,
- total_trades=len(trades_df),
- profitable_trades=profitable_trades,
- total_profit_usd=trades_df['profit_usd'].sum(),
- total_profit_percentage=total_return * 100,
- max_drawdown=max_drawdown * 100,
- sharpe_ratio=sharpe_ratio,
- win_rate=win_rate,
- avg_profit_per_trade=trades_df['profit_usd'].mean(),
- avg_holding_time=trades_df['holding_time'].mean(),
- max_consecutive_losses=max_consecutive_losses,
- profit_factor=profit_factor,
- calmar_ratio=calmar_ratio,
- sortino_ratio=sortino_ratio,
- alpha=0.0, # Would need benchmark comparison
- beta=0.0, # Would need benchmark comparison
- benchmark_return=0.0 # Would need benchmark data
- )
-
- def compare_strategies(self, results: List[BacktestResult]) -> pd.DataFrame:
- """Compare multiple backtest results."""
- comparison_data = []
- for result in results:
- comparison_data.append({
- 'Strategy': result.strategy_name,
- 'Total Trades': result.total_trades,
- 'Win Rate': f"{result.win_rate:.1%}",
- 'Total Profit ($)': f"{result.total_profit_usd:.2f}",
- 'Total Return (%)': f"{result.total_profit_percentage:.2f}%",
- 'Max Drawdown (%)': f"{result.max_drawdown:.2f}%",
- 'Sharpe Ratio': f"{result.sharpe_ratio:.2f}",
- 'Profit Factor': f"{result.profit_factor:.2f}",
- 'Avg Profit/Trade ($)': f"{result.avg_profit_per_trade:.2f}",
- 'Max Consecutive Losses': result.max_consecutive_losses
- })
-
- return pd.DataFrame(comparison_data)
-
- def plot_backtest_results(self, result: BacktestResult, trades: List[Trade],
- save_path: Optional[str] = None):
- """Create comprehensive plots for backtest results."""
- if not trades:
- return
-
- trades_df = pd.DataFrame([asdict(trade) for trade in trades])
- trades_df['timestamp'] = pd.to_datetime(trades_df['timestamp'])
-
- # Create subplots
- fig, axes = plt.subplots(3, 2, figsize=(15, 12))
- fig.suptitle(f'Backtest Results: {result.strategy_name}', fontsize=16)
-
- # Cumulative returns
- trades_df['cumulative_profit'] = trades_df['profit_usd'].cumsum()
- axes[0, 0].plot(trades_df['timestamp'], trades_df['cumulative_profit'])
- axes[0, 0].set_title('Cumulative Profit Over Time')
- axes[0, 0].set_ylabel('Profit ($)')
- axes[0, 0].grid(True)
-
- # Daily returns
- daily_returns = trades_df.set_index('timestamp')['profit_usd'].resample('D').sum()
- axes[0, 1].bar(daily_returns.index, daily_returns.values)
- axes[0, 1].set_title('Daily Profit')
- axes[0, 1].set_ylabel('Profit ($)')
- axes[0, 1].grid(True)
-
- # Profit distribution
- axes[1, 0].hist(trades_df['profit_usd'], bins=50, alpha=0.7)
- axes[1, 0].set_title('Profit Distribution')
- axes[1, 0].set_xlabel('Profit ($)')
- axes[1, 0].set_ylabel('Frequency')
- axes[1, 0].axvline(x=0, color='red', linestyle='--', alpha=0.7)
-
- # Drawdown
- trades_df['cumulative_max'] = trades_df['cumulative_profit'].expanding().max()
- trades_df['drawdown'] = trades_df['cumulative_profit'] - trades_df['cumulative_max']
- axes[1, 1].fill_between(trades_df['timestamp'], trades_df['drawdown'], 0, alpha=0.3, color='red')
- axes[1, 1].set_title('Drawdown Over Time')
- axes[1, 1].set_ylabel('Drawdown ($)')
- axes[1, 1].grid(True)
-
- # Win/Loss ratio
- win_trades = len(trades_df[trades_df['profit_usd'] > 0])
- loss_trades = len(trades_df[trades_df['profit_usd'] <= 0])
- axes[2, 0].bar(['Wins', 'Losses'], [win_trades, loss_trades],
- color=['green', 'red'], alpha=0.7)
- axes[2, 0].set_title('Win/Loss Count')
- axes[2, 0].set_ylabel('Number of Trades')
-
- # Profit by exchange
- exchange_profits = trades_df.groupby('exchange')['profit_usd'].sum()
- axes[2, 1].bar(exchange_profits.index, exchange_profits.values, alpha=0.7)
- axes[2, 1].set_title('Profit by Exchange')
- axes[2, 1].set_ylabel('Total Profit ($)')
- axes[2, 1].tick_params(axis='x', rotation=45)
-
- plt.tight_layout()
-
- if save_path:
- plt.savefig(save_path, dpi=300, bbox_inches='tight')
- self.logger.info(f"Backtest plots saved to {save_path}")
- else:
- plt.show()
-
- def optimize_strategy_parameters(self, base_settings: Dict[str, Any],
- parameter_ranges: Dict[str, List[float]],
- exchanges: List[str], base_currencies: List[str],
- start_date: datetime, end_date: datetime) -> Dict[str, Any]:
- """
- Optimize strategy parameters using grid search.
-
- Args:
- base_settings: Base strategy settings
- parameter_ranges: Dictionary of parameter names to lists of values to test
- exchanges: Exchanges to test on
- base_currencies: Base currencies to test
- start_date: Start date for optimization
- end_date: End date for optimization
-
- Returns:
- Best parameter combination and results
- """
- from itertools import product
-
- # Generate all parameter combinations
- param_names = list(parameter_ranges.keys())
- param_values = list(parameter_ranges.values())
- combinations = list(product(*param_values))
-
- best_result = None
- best_params = None
- best_score = -float('inf')
-
- self.logger.info(f"Testing {len(combinations)} parameter combinations...")
-
- for combo in tqdm(combinations, desc="Optimizing parameters"):
- # Create settings with current parameter combination
- test_settings = base_settings.copy()
- for name, value in zip(param_names, combo):
- test_settings[name] = value
-
- # Run backtest
- result = self.run_backtest(
- f"optimization_{'_'.join([f'{k}={v}' for k, v in zip(param_names, combo)])}",
- exchanges, base_currencies, start_date, end_date, test_settings
- )
-
- # Score based on Sharpe ratio and win rate (adjust weights as needed)
- score = result.sharpe_ratio * 0.7 + result.win_rate * 0.3
-
- if score > best_score:
- best_score = score
- best_result = result
- best_params = dict(zip(param_names, combo))
-
- self.logger.info(f"Best parameters found: {best_params}")
- self.logger.info(f"Best score: {best_score:.4f}")
-
- return {
- 'best_parameters': best_params,
- 'best_result': best_result,
- 'best_score': best_score
- }
-
- def save_backtest_results(self, result: BacktestResult, trades: List[Trade],
- filename: str):
- """Save backtest results to file."""
- try:
- # Create results directory
- results_dir = os.path.join(self.data_directory, 'backtest_results')
- os.makedirs(results_dir, exist_ok=True)
-
- # Save result summary
- result_dict = asdict(result)
- result_dict['start_date'] = result.start_date.isoformat()
- result_dict['end_date'] = result.end_date.isoformat()
-
- with open(os.path.join(results_dir, f"{filename}_summary.json"), 'w') as f:
- json.dump(result_dict, f, indent=2)
-
- # Save trades
- trades_data = [asdict(trade) for trade in trades]
- for trade in trades_data:
- trade['timestamp'] = trade['timestamp'].isoformat()
-
- with open(os.path.join(results_dir, f"{filename}_trades.json"), 'w') as f:
- json.dump(trades_data, f, indent=2)
-
- self.logger.info(f"Backtest results saved to {results_dir}")
-
- except Exception as e:
- self.logger.error(f"Error saving backtest results: {e}")
-
- def load_backtest_results(self, filename: str) -> Tuple[BacktestResult, List[Trade]]:
- """Load backtest results from file."""
- try:
- results_dir = os.path.join(self.data_directory, 'backtest_results')
-
- # Load summary
- with open(os.path.join(results_dir, f"{filename}_summary.json"), 'r') as f:
- result_dict = json.load(f)
-
- result_dict['start_date'] = datetime.fromisoformat(result_dict['start_date'])
- result_dict['end_date'] = datetime.fromisoformat(result_dict['end_date'])
-
- result = BacktestResult(**result_dict)
-
- # Load trades
- with open(os.path.join(results_dir, f"{filename}_trades.json"), 'r') as f:
- trades_data = json.load(f)
-
- trades = []
- for trade_dict in trades_data:
- trade_dict['timestamp'] = datetime.fromisoformat(trade_dict['timestamp'])
- trades.append(Trade(**trade_dict))
-
- return result, trades
-
- except Exception as e:
- self.logger.error(f"Error loading backtest results: {e}")
- return None, []
diff --git a/src/dashboard.py b/src/dashboard.py
deleted file mode 100644
index d08d616..0000000
--- a/src/dashboard.py
+++ /dev/null
@@ -1,361 +0,0 @@
-"""
-Real-time arbitrage dashboard with WebSocket support.
-Provides live visualization of arbitrage opportunities and trading activity.
-"""
-
-import json
-import threading
-import time
-from datetime import datetime, timedelta
-from typing import Dict, List, Any
-from flask import Flask, render_template_string, request
-from flask_socketio import SocketIO, emit
-import plotly.graph_objects as go
-import plotly.utils
-from collections import deque
-import logging
-
-
-class ArbitrageDashboard:
- """Real-time dashboard for arbitrage monitoring."""
-
- def __init__(self, exchange_manager, arbitrage_algorithms):
- self.exchange_manager = exchange_manager
- self.arbitrage_algorithms = arbitrage_algorithms
- self.logger = logging.getLogger(__name__)
-
- # Dashboard data
- self.opportunity_history = deque(maxlen=1000)
- self.price_history = {}
- self.active_opportunities = []
- self.performance_metrics = {
- 'total_scanned': 0,
- 'opportunities_found': 0,
- 'trades_executed': 0,
- 'successful_trades': 0,
- 'total_profit_usd': 0.0
- }
-
- # Flask app setup
- self.app = Flask(__name__)
- self.socketio = SocketIO(self.app, cors_allowed_origins="*")
-
- # Setup routes
- self._setup_routes()
-
- # Background threads
- self.monitoring_thread = None
- self.is_monitoring = False
-
- def _setup_routes(self):
- """Setup Flask routes."""
-
- @self.app.route('/')
- def index():
- return render_template_string(self._get_html_template())
-
- @self.app.route('/api/stats')
- def get_stats():
- return self._get_stats_json()
-
- @self.app.route('/api/opportunities')
- def get_opportunities():
- return self._get_opportunities_json()
-
- @self.app.route('/api/performance')
- def get_performance():
- return self._get_performance_json()
-
- @self.socketio.on('connect')
- def handle_connect():
- self.logger.info("Client connected to dashboard")
- emit('initial_data', self._get_initial_data())
-
- @self.socketio.on('disconnect')
- def handle_disconnect():
- self.logger.info("Client disconnected from dashboard")
-
- def _get_html_template(self) -> str:
- """Get HTML template for the dashboard."""
- return """
-
-
-
- Triangular Arbitrage Dashboard
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Opportunity Frequency
-
-
-
-
-
-
Recent Opportunities
-
-
-
-
-
-
-
-
-
- """
-
- def _get_initial_data(self) -> Dict[str, Any]:
- """Get initial data for dashboard."""
- return {
- 'stats': self.performance_metrics,
- 'opportunities': [self._opportunity_to_dict(opp) for opp in list(self.opportunity_history)[-20:]],
- 'profit_timestamps': [],
- 'profit_values': [],
- 'opportunity_timestamps': [],
- 'opportunity_counts': []
- }
-
- def _get_stats_json(self) -> str:
- """Get performance statistics as JSON."""
- return json.dumps(self.performance_metrics)
-
- def _get_opportunities_json(self) -> str:
- """Get recent opportunities as JSON."""
- opportunities = [self._opportunity_to_dict(opp) for opp in list(self.opportunity_history)[-50:]]
- return json.dumps({'opportunities': opportunities})
-
- def _get_performance_json(self) -> str:
- """Get performance metrics as JSON."""
- stats = self.arbitrage_algorithms.get_arbitrage_statistics()
- stats.update(self.performance_metrics)
- return json.dumps(stats)
-
- def _opportunity_to_dict(self, opportunity) -> Dict[str, Any]:
- """Convert ArbitrageOpportunity to dictionary."""
- return {
- 'exchange': opportunity.exchange,
- 'base_currency': opportunity.base_currency,
- 'quote_currency': opportunity.quote_currency,
- 'alt_currency': opportunity.alt_currency,
- 'direction': opportunity.direction,
- 'profit_percentage': opportunity.profit_percentage,
- 'profit_usd': opportunity.profit_usd,
- 'path': opportunity.path,
- 'timestamp': opportunity.timestamp.isoformat(),
- 'active': opportunity in self.active_opportunities
- }
-
- def add_opportunity(self, opportunity):
- """Add new arbitrage opportunity to dashboard."""
- self.opportunity_history.append(opportunity)
- self.performance_metrics['opportunities_found'] += 1
-
- # Emit to connected clients
- if self.socketio:
- self.socketio.emit('opportunities_update',
- [self._opportunity_to_dict(opp) for opp in list(self.opportunity_history)[-20:]])
-
- def record_trade(self, opportunity, success: bool, profit_usd: float = 0.0):
- """Record trade execution."""
- self.performance_metrics['trades_executed'] += 1
-
- if success:
- self.performance_metrics['successful_trades'] += 1
- self.performance_metrics['total_profit_usd'] += profit_usd
-
- # Emit stats update
- if self.socketio:
- self.socketio.emit('stats_update', self.performance_metrics)
-
- def update_price_data(self, exchange: str, symbol: str, price: float):
- """Update price data for charts."""
- if symbol not in self.price_history:
- self.price_history[symbol] = deque(maxlen=100)
-
- self.price_history[symbol].append({
- 'timestamp': datetime.now(),
- 'price': price,
- 'exchange': exchange
- })
-
- def start_monitoring(self, host: str = '0.0.0.0', port: int = 5001):
- """Start the dashboard monitoring thread."""
- if self.monitoring_thread and self.monitoring_thread.is_alive():
- return
-
- self.is_monitoring = True
- self.monitoring_thread = threading.Thread(
- target=self._run_dashboard,
- args=(host, port),
- daemon=True
- )
- self.monitoring_thread.start()
- self.logger.info(f"Dashboard started on http://{host}:{port}")
-
- def stop_monitoring(self):
- """Stop the dashboard monitoring."""
- self.is_monitoring = False
- if self.monitoring_thread:
- self.monitoring_thread.join(timeout=5)
-
- def _run_dashboard(self, host: str, port: int):
- """Run the Flask dashboard server."""
- try:
- self.socketio.run(self.app, host=host, port=port, debug=False)
- except Exception as e:
- self.logger.error(f"Dashboard error: {e}")
-
- def get_dashboard_summary(self) -> Dict[str, Any]:
- """Get dashboard summary for external access."""
- return {
- 'performance_metrics': self.performance_metrics,
- 'active_opportunities_count': len(self.active_opportunities),
- 'total_opportunities_tracked': len(self.opportunity_history),
- 'exchanges_monitored': list(self.exchange_manager.get_enabled_exchanges()),
- 'uptime': str(datetime.now() - datetime.fromtimestamp(time.time())),
- }
diff --git a/src/exchange_manager.py b/src/exchange_manager.py
deleted file mode 100644
index da5a013..0000000
--- a/src/exchange_manager.py
+++ /dev/null
@@ -1,212 +0,0 @@
-"""
-Multi-exchange manager for triangular arbitrage bot.
-Handles connections and operations across multiple cryptocurrency exchanges.
-"""
-
-import ccxt
-import time
-import logging
-from typing import Dict, List, Optional, Any
-from data import secrets, settings, tokens
-
-
-class ExchangeManager:
- """Manages multiple cryptocurrency exchange connections and operations."""
-
- def __init__(self):
- self.exchanges = {}
- self.logger = logging.getLogger(__name__)
- self._initialize_exchanges()
-
- def _initialize_exchanges(self):
- """Initialize all enabled exchanges."""
- for exchange_name in settings.ENABLED_EXCHANGES:
- try:
- self._initialize_single_exchange(exchange_name)
- self.logger.info(f"Successfully initialized {exchange_name}")
- except Exception as e:
- self.logger.error(f"Failed to initialize {exchange_name}: {e}")
-
- def _initialize_single_exchange(self, exchange_name: str):
- """Initialize a single exchange connection."""
- exchange_config = secrets.EXCHANGES.get(exchange_name, {})
- if not exchange_config.get('enabled', False):
- return
-
- config = {
- 'apiKey': exchange_config.get('api_key', ''),
- 'secret': exchange_config.get('secret', ''),
- 'timeout': 30000,
- 'enableRateLimit': True
- }
-
- # Exchange-specific configurations
- if exchange_name == 'binance':
- config['options'] = {'testnet': exchange_config.get('testnet', False)}
- elif exchange_name == 'coinbase':
- config['password'] = exchange_config.get('passphrase', '')
- config['sandbox'] = exchange_config.get('sandbox', False)
- elif exchange_name == 'kucoin':
- config['password'] = exchange_config.get('passphrase', '')
- config['sandbox'] = exchange_config.get('sandbox', False)
- elif exchange_name == 'okx':
- config['password'] = exchange_config.get('passphrase', '')
-
- # Create exchange instance
- exchange_class = getattr(ccxt, exchange_name)
- exchange = exchange_class(config)
-
- self.exchanges[exchange_name] = {
- 'instance': exchange,
- 'config': exchange_config,
- 'settings': settings.EXCHANGE_SETTINGS.get(exchange_name, {}),
- 'tokens': tokens.EXCHANGE_TOKENS.get(exchange_name, [])
- }
-
- def get_exchange(self, exchange_name: str) -> Optional[Dict[str, Any]]:
- """Get exchange instance and configuration."""
- return self.exchanges.get(exchange_name)
-
- def get_enabled_exchanges(self) -> List[str]:
- """Get list of enabled exchange names."""
- return list(self.exchanges.keys())
-
- def get_exchange_tokens(self, exchange_name: str) -> List[str]:
- """Get supported tokens for an exchange."""
- exchange = self.get_exchange(exchange_name)
- return exchange['tokens'] if exchange else []
-
- def get_exchange_fee(self, exchange_name: str, maker: bool = False) -> float:
- """Get trading fee for an exchange."""
- exchange = self.get_exchange(exchange_name)
- if not exchange:
- return 0.001 # Default 0.1% fee
-
- fee_key = 'maker_fee' if maker else 'fee'
- return exchange['settings'].get(fee_key, 0.001)
-
- def get_balance(self, exchange_name: str, currency: str) -> float:
- """Get balance for a currency on an exchange."""
- exchange = self.get_exchange(exchange_name)
- if not exchange:
- return 0.0
-
- try:
- balance = exchange['instance'].fetch_balance()
- return balance.get(currency, {}).get('free', 0.0)
- except Exception as e:
- self.logger.error(f"Failed to get balance for {currency} on {exchange_name}: {e}")
- return 0.0
-
- def get_ticker(self, exchange_name: str, symbol: str) -> Optional[Dict[str, Any]]:
- """Get ticker data for a symbol on an exchange."""
- exchange = self.get_exchange(exchange_name)
- if not exchange:
- return None
-
- try:
- return exchange['instance'].fetch_ticker(symbol)
- except Exception as e:
- self.logger.error(f"Failed to get ticker for {symbol} on {exchange_name}: {e}")
- return None
-
- def get_order_book(self, exchange_name: str, symbol: str, limit: int = 5) -> Optional[Dict[str, Any]]:
- """Get order book for a symbol on an exchange."""
- exchange = self.get_exchange(exchange_name)
- if not exchange:
- return None
-
- try:
- return exchange['instance'].fetch_order_book(symbol, limit)
- except Exception as e:
- self.logger.error(f"Failed to get order book for {symbol} on {exchange_name}: {e}")
- return None
-
- def create_market_order(self, exchange_name: str, symbol: str, side: str,
- amount: float) -> Optional[Dict[str, Any]]:
- """Create a market order on an exchange."""
- exchange = self.get_exchange(exchange_name)
- if not exchange:
- return None
-
- try:
- if side.lower() == 'buy':
- return exchange['instance'].create_market_buy_order(symbol, amount)
- elif side.lower() == 'sell':
- return exchange['instance'].create_market_sell_order(symbol, amount)
- else:
- self.logger.error(f"Invalid order side: {side}")
- return None
- except Exception as e:
- self.logger.error(f"Failed to create {side} order for {symbol} on {exchange_name}: {e}")
- return None
-
- def create_limit_order(self, exchange_name: str, symbol: str, side: str,
- amount: float, price: float) -> Optional[Dict[str, Any]]:
- """Create a limit order on an exchange."""
- exchange = self.get_exchange(exchange_name)
- if not exchange:
- return None
-
- try:
- if side.lower() == 'buy':
- return exchange['instance'].create_limit_buy_order(symbol, amount, price)
- elif side.lower() == 'sell':
- return exchange['instance'].create_limit_sell_order(symbol, amount, price)
- else:
- self.logger.error(f"Invalid order side: {side}")
- return None
- except Exception as e:
- self.logger.error(f"Failed to create {side} limit order for {symbol} on {exchange_name}: {e}")
- return None
-
- def cancel_order(self, exchange_name: str, order_id: str, symbol: str) -> bool:
- """Cancel an order on an exchange."""
- exchange = self.get_exchange(exchange_name)
- if not exchange:
- return False
-
- try:
- exchange['instance'].cancel_order(order_id, symbol)
- return True
- except Exception as e:
- self.logger.error(f"Failed to cancel order {order_id} on {exchange_name}: {e}")
- return False
-
- def get_open_orders(self, exchange_name: str, symbol: str = None) -> List[Dict[str, Any]]:
- """Get open orders for an exchange."""
- exchange = self.get_exchange(exchange_name)
- if not exchange:
- return []
-
- try:
- return exchange['instance'].fetch_open_orders(symbol)
- except Exception as e:
- self.logger.error(f"Failed to get open orders on {exchange_name}: {e}")
- return []
-
- def test_connectivity(self, exchange_name: str) -> bool:
- """Test connectivity to an exchange."""
- exchange = self.get_exchange(exchange_name)
- if not exchange:
- return False
-
- try:
- # Simple ping test
- exchange['instance'].load_markets()
- return True
- except Exception as e:
- self.logger.error(f"Connectivity test failed for {exchange_name}: {e}")
- return False
-
- def get_exchange_info(self) -> Dict[str, Dict[str, Any]]:
- """Get information about all exchanges."""
- info = {}
- for name, exchange_data in self.exchanges.items():
- info[name] = {
- 'connected': self.test_connectivity(name),
- 'tokens_count': len(exchange_data['tokens']),
- 'fee': self.get_exchange_fee(name),
- 'maker_fee': self.get_exchange_fee(name, maker=True)
- }
- return info
diff --git a/src/ml_predictor.py b/src/ml_predictor.py
deleted file mode 100644
index 11f984a..0000000
--- a/src/ml_predictor.py
+++ /dev/null
@@ -1,531 +0,0 @@
-"""
-Machine Learning models for arbitrage opportunity prediction.
-Uses various ML algorithms to predict profitable arbitrage opportunities.
-"""
-
-import numpy as np
-import pandas as pd
-from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
-from sklearn.linear_model import LogisticRegression
-from sklearn.preprocessing import StandardScaler
-from sklearn.model_selection import train_test_split, cross_val_score
-from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
-import xgboost as xgb
-import lightgbm as lgb
-from typing import Dict, List, Tuple, Optional, Any
-import logging
-import pickle
-import os
-from datetime import datetime, timedelta
-from collections import deque
-import json
-
-
-class ArbitragePredictor:
- """Machine learning predictor for arbitrage opportunities."""
-
- def __init__(self, model_dir: str = "models"):
- self.model_dir = model_dir
- self.logger = logging.getLogger(__name__)
-
- # Ensure model directory exists
- os.makedirs(model_dir, exist_ok=True)
-
- # ML Models
- self.models = {
- 'random_forest': RandomForestClassifier(
- n_estimators=100,
- max_depth=10,
- random_state=42,
- n_jobs=-1
- ),
- 'gradient_boosting': GradientBoostingClassifier(
- n_estimators=100,
- learning_rate=0.1,
- max_depth=6,
- random_state=42
- ),
- 'xgboost': xgb.XGBClassifier(
- n_estimators=100,
- learning_rate=0.1,
- max_depth=6,
- random_state=42,
- n_jobs=-1
- ),
- 'lightgbm': lgb.LGBMClassifier(
- n_estimators=100,
- learning_rate=0.1,
- max_depth=6,
- random_state=42,
- n_jobs=-1
- )
- }
-
- # Feature scaler
- self.scaler = StandardScaler()
-
- # Historical data for training
- self.feature_history = deque(maxlen=10000)
- self.target_history = deque(maxlen=10000)
-
- # Model performance tracking
- self.model_performance = {}
-
- # Load existing models if available
- self._load_models()
-
- def extract_features(self, opportunity_data: Dict[str, Any]) -> np.ndarray:
- """
- Extract features from arbitrage opportunity data for ML prediction.
-
- Args:
- opportunity_data: Dictionary containing opportunity information
-
- Returns:
- Feature vector as numpy array
- """
- features = []
-
- # Basic arbitrage features
- features.append(opportunity_data.get('profit_percentage', 0.0))
- features.append(opportunity_data.get('profit_usd', 0.0))
- features.append(opportunity_data.get('volatility', 0.0))
- features.append(opportunity_data.get('liquidity_score', 0.0))
-
- # Exchange features (one-hot encoded)
- exchange = opportunity_data.get('exchange', 'binance')
- exchanges = ['binance', 'coinbase', 'kraken', 'kucoin', 'okx']
- for ex in exchanges:
- features.append(1.0 if exchange == ex else 0.0)
-
- # Currency pair features
- base_currency = opportunity_data.get('base_currency', 'ETH')
- quote_currency = opportunity_data.get('quote_currency', 'BTC')
- alt_currency = opportunity_data.get('alt_currency', '')
-
- # Currency volatility (simplified)
- currency_volatility = {
- 'BTC': 0.03, 'ETH': 0.04, 'BNB': 0.05, 'ADA': 0.06,
- 'SOL': 0.07, 'DOT': 0.06, 'AVAX': 0.08, 'MATIC': 0.07
- }
-
- features.append(currency_volatility.get(base_currency, 0.05))
- features.append(currency_volatility.get(quote_currency, 0.03))
- features.append(currency_volatility.get(alt_currency, 0.05))
-
- # Time-based features
- timestamp = opportunity_data.get('timestamp', datetime.now())
- if isinstance(timestamp, str):
- timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
-
- features.append(timestamp.hour / 24.0) # Hour of day (normalized)
- features.append(timestamp.weekday() / 7.0) # Day of week (normalized)
-
- # Orderbook depth features
- orderbook_depth = opportunity_data.get('orderbook_depth', 2)
- features.append(orderbook_depth)
-
- # Historical performance features (last 10 similar opportunities)
- similar_opportunities = self._get_similar_opportunities(opportunity_data)
- if similar_opportunities:
- success_rate = np.mean([1 if opp.get('profit_percentage', 0) > 0 else 0
- for opp in similar_opportunities])
- avg_profit = np.mean([opp.get('profit_percentage', 0) for opp in similar_opportunities])
- features.extend([success_rate, avg_profit])
- else:
- features.extend([0.5, 0.0]) # Default values
-
- # Market condition features
- features.append(self._get_market_volatility())
- features.append(self._get_market_trend())
-
- return np.array(features)
-
- def _get_similar_opportunities(self, opportunity_data: Dict[str, Any]) -> List[Dict[str, Any]]:
- """Get historically similar opportunities."""
- similar = []
- base = opportunity_data.get('base_currency')
- quote = opportunity_data.get('quote_currency')
- direction = opportunity_data.get('direction')
-
- for i, features in enumerate(self.feature_history):
- if i >= len(self.target_history):
- continue
-
- # Simple similarity check (same currencies and direction)
- if (len(features) > 1 and
- features[1] == opportunity_data.get('profit_percentage', 0) and
- direction == 'forward'): # Simplified check
- similar.append({
- 'profit_percentage': self.target_history[i],
- 'features': features
- })
-
- if len(similar) >= 10:
- break
-
- return similar
-
- def _get_market_volatility(self) -> float:
- """Get current market volatility (simplified implementation)."""
- # In a real implementation, this would analyze recent price movements
- return 0.05 # Placeholder
-
- def _get_market_trend(self) -> float:
- """Get current market trend (-1 to 1, negative = bearish, positive = bullish)."""
- # In a real implementation, this would use technical indicators
- return 0.1 # Slightly bullish placeholder
-
- def add_training_example(self, opportunity_data: Dict[str, Any], outcome: bool):
- """
- Add a training example to the dataset.
-
- Args:
- opportunity_data: Feature data for the opportunity
- outcome: True if profitable, False otherwise
- """
- features = self.extract_features(opportunity_data)
- self.feature_history.append(features)
- self.target_history.append(1 if outcome else 0)
-
- def train_models(self, test_size: float = 0.2):
- """
- Train all ML models on historical data.
-
- Args:
- test_size: Fraction of data to use for testing
- """
- if len(self.feature_history) < 100:
- self.logger.warning("Not enough training data. Need at least 100 examples.")
- return
-
- # Prepare data
- X = np.array(list(self.feature_history))
- y = np.array(list(self.target_history))
-
- if len(np.unique(y)) < 2:
- self.logger.warning("Need both positive and negative examples for training.")
- return
-
- # Split data
- X_train, X_test, y_train, y_test = train_test_split(
- X, y, test_size=test_size, random_state=42, stratify=y
- )
-
- # Scale features
- X_train_scaled = self.scaler.fit_transform(X_train)
- X_test_scaled = self.scaler.transform(X_test)
-
- # Train each model
- for name, model in self.models.items():
- try:
- self.logger.info(f"Training {name} model...")
-
- # Train model
- model.fit(X_train_scaled, y_train)
-
- # Evaluate model
- y_pred = model.predict(X_test_scaled)
- y_pred_proba = model.predict_proba(X_test_scaled)[:, 1]
-
- metrics = {
- 'accuracy': accuracy_score(y_test, y_pred),
- 'precision': precision_score(y_test, y_pred, zero_division=0),
- 'recall': recall_score(y_test, y_pred, zero_division=0),
- 'f1_score': f1_score(y_test, y_pred, zero_division=0)
- }
-
- self.model_performance[name] = metrics
-
- # Cross-validation score
- cv_scores = cross_val_score(model, X_train_scaled, y_train, cv=5)
- metrics['cv_mean'] = cv_scores.mean()
- metrics['cv_std'] = cv_scores.std()
-
- self.logger.info(f"{name} performance: {metrics}")
-
- except Exception as e:
- self.logger.error(f"Error training {name}: {e}")
-
- # Save trained models
- self._save_models()
-
- def predict_opportunity(self, opportunity_data: Dict[str, Any]) -> Dict[str, Any]:
- """
- Predict whether an arbitrage opportunity will be profitable.
-
- Args:
- opportunity_data: Opportunity data for prediction
-
- Returns:
- Dictionary with predictions from all models
- """
- features = self.extract_features(opportunity_data)
- features_scaled = self.scaler.transform(features.reshape(1, -1))
-
- predictions = {}
-
- for name, model in self.models.items():
- try:
- prediction = model.predict(features_scaled)[0]
- probability = model.predict_proba(features_scaled)[0][1]
-
- predictions[name] = {
- 'prediction': bool(prediction),
- 'probability': float(probability),
- 'confidence': abs(probability - 0.5) * 2 # Scale to 0-1
- }
- except Exception as e:
- self.logger.error(f"Error predicting with {name}: {e}")
- predictions[name] = {
- 'prediction': False,
- 'probability': 0.0,
- 'confidence': 0.0
- }
-
- # Ensemble prediction (majority vote)
- positive_votes = sum(1 for pred in predictions.values() if pred['prediction'])
- ensemble_prediction = positive_votes > len(predictions) / 2
- ensemble_probability = np.mean([pred['probability'] for pred in predictions.values()])
-
- predictions['ensemble'] = {
- 'prediction': ensemble_prediction,
- 'probability': ensemble_probability,
- 'confidence': abs(ensemble_probability - 0.5) * 2
- }
-
- return predictions
-
- def get_model_performance(self) -> Dict[str, Any]:
- """Get performance metrics for all models."""
- return self.model_performance.copy()
-
- def get_feature_importance(self, model_name: str = 'random_forest') -> Dict[str, float]:
- """Get feature importance for a specific model."""
- if model_name not in self.models:
- return {}
-
- model = self.models[model_name]
-
- if not hasattr(model, 'feature_importances_'):
- return {}
-
- # Feature names (simplified)
- feature_names = [
- 'profit_percentage', 'profit_usd', 'volatility', 'liquidity_score',
- 'is_binance', 'is_coinbase', 'is_kraken', 'is_kucoin', 'is_okx',
- 'base_volatility', 'quote_volatility', 'alt_volatility',
- 'hour_of_day', 'day_of_week', 'orderbook_depth',
- 'historical_success_rate', 'historical_avg_profit',
- 'market_volatility', 'market_trend'
- ]
-
- importance_dict = {}
- for name, importance in zip(feature_names, model.feature_importances_):
- importance_dict[name] = float(importance)
-
- return importance_dict
-
- def _save_models(self):
- """Save trained models to disk."""
- try:
- for name, model in self.models.items():
- model_path = os.path.join(self.model_dir, f"{name}_model.pkl")
- with open(model_path, 'wb') as f:
- pickle.dump(model, f)
-
- # Save scaler
- scaler_path = os.path.join(self.model_dir, "scaler.pkl")
- with open(scaler_path, 'wb') as f:
- pickle.dump(self.scaler, f)
-
- # Save performance metrics
- perf_path = os.path.join(self.model_dir, "performance.json")
- with open(perf_path, 'w') as f:
- json.dump(self.model_performance, f, indent=2)
-
- self.logger.info("Models saved successfully")
-
- except Exception as e:
- self.logger.error(f"Error saving models: {e}")
-
- def _load_models(self):
- """Load trained models from disk."""
- try:
- for name in self.models.keys():
- model_path = os.path.join(self.model_dir, f"{name}_model.pkl")
- if os.path.exists(model_path):
- with open(model_path, 'rb') as f:
- self.models[name] = pickle.load(f)
-
- # Load scaler
- scaler_path = os.path.join(self.model_dir, "scaler.pkl")
- if os.path.exists(scaler_path):
- with open(scaler_path, 'rb') as f:
- self.scaler = pickle.load(f)
-
- # Load performance metrics
- perf_path = os.path.join(self.model_dir, "performance.json")
- if os.path.exists(perf_path):
- with open(perf_path, 'r') as f:
- self.model_performance = json.load(f)
-
- self.logger.info("Models loaded successfully")
-
- except Exception as e:
- self.logger.error(f"Error loading models: {e}")
-
- def update_market_data(self, market_data: Dict[str, Any]):
- """Update internal market data for feature engineering."""
- # This would be called periodically to update market conditions
- # Implementation would depend on the specific market data structure
- pass
-
- def get_training_stats(self) -> Dict[str, Any]:
- """Get statistics about the training data."""
- return {
- 'total_examples': len(self.feature_history),
- 'positive_examples': sum(self.target_history) if self.target_history else 0,
- 'negative_examples': len(self.target_history) - sum(self.target_history) if self.target_history else 0,
- 'class_balance': sum(self.target_history) / len(self.target_history) if self.target_history else 0,
- 'models_trained': len([m for m in self.models.keys() if hasattr(self.models[m], 'n_features_in_')])
- }
-
-
-class NeuralNetworkPredictor:
- """Neural network-based predictor for complex arbitrage patterns."""
-
- def __init__(self, input_dim: int = 20, hidden_dims: List[int] = [64, 32]):
- try:
- import torch
- import torch.nn as nn
- import torch.optim as optim
-
- self.torch_available = True
- self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
-
- # Define neural network architecture
- layers = []
- prev_dim = input_dim
-
- for hidden_dim in hidden_dims:
- layers.extend([
- nn.Linear(prev_dim, hidden_dim),
- nn.ReLU(),
- nn.Dropout(0.2)
- ])
- prev_dim = hidden_dim
-
- layers.append(nn.Linear(prev_dim, 1))
- layers.append(nn.Sigmoid())
-
- self.model = nn.Sequential(*layers).to(self.device)
- self.criterion = nn.BCELoss()
- self.optimizer = optim.Adam(self.model.parameters(), lr=0.001)
-
- except ImportError:
- self.torch_available = False
- self.logger.warning("PyTorch not available. Neural network predictor disabled.")
-
- def train(self, X: np.ndarray, y: np.ndarray, epochs: int = 100, batch_size: int = 32):
- """Train the neural network."""
- if not self.torch_available:
- return
-
- import torch
-
- # Convert to tensors
- X_tensor = torch.FloatTensor(X).to(self.device)
- y_tensor = torch.FloatTensor(y).to(self.device).unsqueeze(1)
-
- # Training loop
- for epoch in range(epochs):
- self.optimizer.zero_grad()
- outputs = self.model(X_tensor)
- loss = self.criterion(outputs, y_tensor)
- loss.backward()
- self.optimizer.step()
-
- if (epoch + 1) % 20 == 0:
- print(f'Epoch {epoch+1}/{epochs}, Loss: {loss.item():.4f}')
-
- def predict(self, X: np.ndarray) -> np.ndarray:
- """Make predictions with the neural network."""
- if not self.torch_available:
- return np.zeros(len(X))
-
- import torch
-
- X_tensor = torch.FloatTensor(X).to(self.device)
- with torch.no_grad():
- outputs = self.model(X_tensor)
- predictions = (outputs > 0.5).float().cpu().numpy().flatten()
-
- return predictions
-
- def predict_proba(self, X: np.ndarray) -> np.ndarray:
- """Get prediction probabilities."""
- if not self.torch_available:
- return np.zeros(len(X))
-
- import torch
-
- X_tensor = torch.FloatTensor(X).to(self.device)
- with torch.no_grad():
- outputs = self.model(X_tensor).cpu().numpy().flatten()
-
- return outputs
-
-
-class EnsemblePredictor:
- """Ensemble predictor combining multiple ML models."""
-
- def __init__(self):
- self.predictors = {
- 'traditional': ArbitragePredictor(),
- 'neural_net': NeuralNetworkPredictor()
- }
- self.weights = {
- 'traditional': 0.7,
- 'neural_net': 0.3
- }
-
- def train_all(self, X: np.ndarray, y: np.ndarray):
- """Train all predictors."""
- self.predictors['traditional'].train_models()
-
- if self.predictors['neural_net'].torch_available:
- self.predictors['neural_net'].train(X, y)
-
- def predict_ensemble(self, opportunity_data: Dict[str, Any]) -> Dict[str, Any]:
- """Make ensemble prediction."""
- # Get predictions from all models
- predictions = {}
-
- # Traditional ML models
- traditional_pred = self.predictors['traditional'].predict_opportunity(opportunity_data)
- predictions['traditional_ml'] = traditional_pred['ensemble']
-
- # Neural network (if available)
- if self.predictors['neural_net'].torch_available:
- features = self.predictors['traditional'].extract_features(opportunity_data)
- nn_pred = self.predictors['neural_net'].predict_proba(features.reshape(1, -1))[0]
- predictions['neural_network'] = {
- 'prediction': nn_pred > 0.5,
- 'probability': nn_pred,
- 'confidence': abs(nn_pred - 0.5) * 2
- }
-
- # Weighted ensemble
- probabilities = [pred['probability'] for pred in predictions.values()]
- weights = list(self.weights.values())[:len(probabilities)]
-
- ensemble_prob = np.average(probabilities, weights=weights)
- ensemble_pred = ensemble_prob > 0.5
-
- return {
- 'ensemble_prediction': ensemble_pred,
- 'ensemble_probability': ensemble_prob,
- 'individual_predictions': predictions,
- 'confidence': abs(ensemble_prob - 0.5) * 2
- }
diff --git a/src/model.py b/src/model.py
deleted file mode 100644
index 1ad6af3..0000000
--- a/src/model.py
+++ /dev/null
@@ -1,688 +0,0 @@
-from datetime import datetime
-from operator import itemgetter
-from data import secrets , settings
-
-import time
-import ccxt
-
-# Import Telegram notifier if available
-try:
- from src.telegram_notifier import TelegramNotifier
- telegram_available = True
-except:
- telegram_available = False
-
-
-class Model:
-
- # Exchange
- binance = None
- fee = 0.999 # Taker fee (0.1%)
- maker_fee = 0.9995 # Maker fee (0.05% with potential BNB discount)
-
- # Profit tracking
- total_profit_eth = 0.0
- total_profit_usd = 0.0
- trades_executed = 0
- successful_trades = 0
-
- # Cache
- cache_prices = []
- cache_order_books = []
-
- # Order status
- not_filled = 0
- in_progress = 1
- filled = 2
-
- # Init binance, create connections with secrets file.
- def __init__(self):
- # Check subscription/license
- try:
- from src.subscription import SubscriptionManager
- self.subscription = SubscriptionManager()
- self.subscription.check_premium_features()
- except:
- self.subscription = None
-
- self.binance = ccxt.binance({
- 'apiKey': secrets.BINANCE_KEY , 'secret': secrets.BINANCE_SECRET , 'timeout': 30000 ,
- 'enableRateLimit': True
- })
- self.load_profit_tracking()
- # Initialize Telegram notifier
- if telegram_available:
- self.telegram = TelegramNotifier()
- else:
- self.telegram = None
-
- # Create a buy order. 'amount' or 'amount_percentage' should be specified.
- def buy(self , exchange , asset1 , asset2 , amount_percentage=None , amount=None , limit=None , timeout=None):
- try:
- if amount_percentage:
- asset2_available = self.get_balance(exchange , asset2) * amount_percentage
- amount = asset2_available / self.get_price(exchange , asset1 , asset2 , mode='ask')
-
- self.log("Buying {:.6} {} with {} on {}.".format(
- amount , asset1 , asset2 , exchange
- ))
-
- if limit:
- self.log("Limit @{}.".format(limit))
-
- if not limit:
- self.log("Buying at market price.")
- exchange.createMarketBuyOrder(
- '{}/{}'.format(asset1 , asset2) , amount
- )
- return True
-
- else:
- exchange.createLimitBuyOrder(
- '{}/{}'.format(asset1 , asset2) , amount , limit
- )
-
- if timeout:
- time.sleep(timeout)
- result = self.is_open_order(exchange , asset1 , asset2)
-
- if result == Model.not_filled:
- if self.cancel_orders(exchange , asset1 , asset2):
- self.log("Canceled limit order for {}/{} after timeout.".format(asset1 , asset2))
-
- return False
-
- elif result == Model.in_progress:
- n = 0
- while result == Model.in_progress:
- n += 1
- if n >= 20:
- self.log("Order cannot be terminated, selling {} to {}.".format(asset1 , asset2))
- self.sell(exchange , asset1 , asset2 , amount_percentage=1)
- return False
-
- self.log("Order for {}/{} is in progress, waiting...".format(asset1 , asset2))
- time.sleep(timeout)
- result = self.is_open_order(exchange , asset1 , asset2)
-
- self.log("Limit order executed.")
- return True
-
- else:
- self.log("Limit order executed.")
- return True
-
- else:
- return False
-
- except Exception as e:
- self.log("Error while buying: {}".format(str(e)))
- return False
-
- # Create a sell order. 'amount' or 'amount_percentage' should be specified.
- def sell(self , exchange , asset1 , asset2 , amount_percentage=None , amount=None , limit=None , timeout=None):
- try:
- if amount_percentage:
- amount = self.get_balance(exchange , asset1) * amount_percentage
-
- self.log("Selling {:.6f} {} to {} on {}.".format(
- amount , asset1 , asset2 , exchange
- ))
-
- if limit:
- self.log("Limit @{}.".format(limit))
-
- if not limit:
- self.log("Selling at market price.")
- exchange.createMarketSellOrder(
- '{}/{}'.format(asset1 , asset2) , amount
- )
- return True
-
- else:
- exchange.createLimitSellOrder(
- '{}/{}'.format(asset1 , asset2) , amount , limit
- )
-
- if timeout:
- time.sleep(timeout)
- result = self.is_open_order(exchange , asset1 , asset2)
-
- if result == Model.not_filled:
- if self.cancel_orders(exchange , asset1 , asset2):
- self.log("Canceled limit order for {}/{} after timeout.".format(asset1 , asset2))
- return False
-
- elif result == Model.in_progress:
- n = 0
- while result == Model.in_progress:
- n += 1
- if n >= 20:
- self.log("Order cannot be terminated, buying {} with {}.".format(asset1 , asset2))
- self.buy(exchange , asset1 , asset2 , amount_percentage=1)
- return False
-
- self.log("Order for {}/{} is in progress, waiting...".format(asset1 , asset2))
- time.sleep(timeout)
- result = self.is_open_order(exchange , asset1 , asset2)
-
- self.log("Limit order executed.")
- return True
-
- else:
- self.log("Limit order executed.")
- return True
-
- else:
- return False
-
- except Exception as e:
- self.log("Error while selling: {}".format(str(e)))
- return False
-
- # Reset the cache
- def reset_cache(self):
- self.cache_prices = []
- self.cache_order_books = []
-
- # Check if the price is cached and return if it is.
- def get_price_cache(self , exchange , asset1 , asset2):
- for item in self.cache_prices:
- if item['asset1'] == asset1 and item['asset2'] == asset2 and item['exchange'] == str(exchange):
- return item['ticker']
- return None
-
- # Check if the order book is cached, and return if so.
- def get_order_book_cache(self , exchange , asset1 , asset2):
- for item in self.cache_order_books:
- if item['asset1'] == asset1 and item['asset2'] == asset2 and item['exchange'] == str(exchange):
- return item['book']
- return None
-
- # Put price in cache.
- def cache_add_price(self , exchange , asset1 , asset2 , ticker):
- self.cache_prices.append({
- 'exchange': str(exchange) , 'asset1': asset1 , 'asset2': asset2 , 'ticker': ticker
- })
-
- # Put the order book in the cache.
- def cache_order_book(self , exchange , asset1 , asset2 , book):
- self.cache_order_books.append({
- 'exchange': str(exchange) , 'asset1': asset1 , 'asset2': asset2 , 'book': book
- })
-
- # Get your balance for given asset.
- def get_balance(self , exchange , asset):
- try:
- balance = exchange.fetchBalance()
-
- if asset in balance:
- return balance[asset]['free']
-
- return 0
- except Exception as e:
- self.log("Error while getting balance: {}".format(str(e)))
- raise
-
- # Get an asset price
- def get_price(self , exchange , asset1 , asset2 , mode='average'):
- try:
- if self.get_price_cache(exchange , asset1 , asset2):
- ticker = self.get_price_cache(exchange , asset1 , asset2)
-
- else:
- ticker = exchange.fetchTicker('{}/{}'.format(asset1 , asset2))
- self.cache_add_price(exchange , asset1 , asset2 , ticker)
-
- if mode == 'bid':
- return ticker['bid']
-
- if mode == 'ask':
- return ticker['ask']
-
- return (ticker['ask'] + ticker['bid']) / 2
- except Exception as e:
- self.log("Error while fetching price for {}/{}: {}".format(asset1 , asset2 , str(e)))
- return None
-
- # Get order book for given asset.
- def get_order_book(self , exchange , asset1 , asset2 , mode="bids"):
- try:
- order_book = self.get_order_book_cache(exchange , asset1 , asset2)
- if not order_book:
- order_book = exchange.fetchOrderBook('{}/{}'.format(asset1 , asset2))
- self.cache_order_book(exchange , asset1 , asset2 , order_book)
-
- return order_book[mode]
- except Exception as e:
- self.log("Error while fetching order book for {}/{}: {}".format(asset1 , asset2 , str(e)))
- return None
-
- # Get the safest and lowest price to limit buy the given asset.
- def get_buy_limit_price(self , exchange , asset1 , asset2 , amount=1):
- bids = self.get_order_book(exchange , asset1 , asset2 , mode="asks")
-
- if not bids:
- return None
-
- bids.sort()
-
- if len(bids) < settings.ESTIMATION_ORDERBOOK:
- return None
-
- for bid in bids[settings.ESTIMATION_ORDERBOOK:]:
- if bid[1] >= amount:
- return bid[0]
-
- # Get the safest and highest price to limit sell the given asset.
- def get_sell_limit_price(self , exchange , asset1 , asset2 , amount=1):
- asks = self.get_order_book(exchange , asset1 , asset2 , mode="bids")
-
- if not asks:
- return None
-
- asks.sort(reverse=True)
-
- if len(asks) < settings.ESTIMATION_ORDERBOOK:
- return None
-
- for ask in asks[settings.ESTIMATION_ORDERBOOK:]:
- if ask[1] >= amount:
- return ask[0]
-
- # Check if at least one order is open for the given asset and exchange.
- def is_open_order(self , exchange , asset1 , asset2):
- try:
- data = exchange.fetchOpenOrders('{}/{}'.format(asset1 , asset2))
- for item in data:
- if item['filled'] > 0:
- return Model.in_progress
-
- if len(data) > 0:
- return Model.not_filled
-
- return Model.filled
- except Exception as e:
- self.log("Error while fetching open orders for {}/{}: {}".format(asset1 , asset2 , str(e)))
- return None
-
- # Cancel all orders for given assets.
- def cancel_orders(self , exchange , asset1 , asset2):
- for _ in range(5):
- try:
- orders = exchange.fetchOpenOrders('{}/{}'.format(asset1 , asset2))
- for order in orders:
- exchange.cancelOrder(order['id'] , '{}/{}'.format(asset1 , asset2))
-
- return True
- except Exception as e:
- self.log("Error while canceling orders for {}/{}: {}. Retrying.".format(asset1 , asset2 , str(e)))
-
- self.log("Cannot cancel orders for {}/{}.".format(asset1 , asset2 , str(e)))
- return
-
- # Advanced: Calculate optimal position size based on opportunity
- def calculate_optimal_position_size(self, exchange, profit_pct, eth_price_usd):
- """Dynamic position sizing based on opportunity size"""
- if not settings.ENABLE_DYNAMIC_POSITION_SIZING:
- return settings.MAX_POSITION_SIZE
-
- balance_eth = self.get_balance(exchange, 'ETH')
- if balance_eth == 0:
- return 0
-
- # Scale position size based on profit percentage
- # Higher profit = larger position (up to max)
- base_size = settings.MAX_POSITION_SIZE
- if profit_pct > 1.0: # Very profitable opportunity
- scale_factor = min(1.0, profit_pct / 2.0) # Scale up to 2x for 2%+ profit
- optimal_size = base_size * (1 + scale_factor * 0.5) # Up to 1.5x base
- return min(optimal_size, 0.8) # Cap at 80% for safety
- elif profit_pct > 0.5: # Good opportunity
- return base_size
- else: # Smaller opportunity
- return base_size * 0.7 # Use smaller position
-
- # Advanced: Optimize for slippage using order book depth
- def estimate_slippage(self, exchange, asset1, asset2, amount):
- """Estimate slippage based on order book depth"""
- if not settings.ENABLE_SLIPPAGE_OPTIMIZATION:
- return 0
-
- try:
- order_book = self.get_order_book(exchange, asset1, asset2, mode="asks")
- if not order_book:
- return 0.001 # Default 0.1% slippage
-
- total_volume = 0
- weighted_price = 0
- for price, volume in order_book[:10]: # Check top 10 levels
- total_volume += volume
- weighted_price += price * volume
- if total_volume >= amount:
- break
-
- if total_volume > 0:
- avg_price = weighted_price / total_volume
- best_price = order_book[0][0] if order_book else avg_price
- slippage = abs(avg_price - best_price) / best_price
- return slippage
- return 0.001
- except:
- return 0.001
-
- # Estimate the profit for forward arbitrage on given asset (improved with maker fees).
- def estimate_arbitrage_forward(self , exchange , asset):
- try:
- alt_eth = self.get_buy_limit_price(exchange , asset , 'ETH')
- alt_btc = self.get_sell_limit_price(exchange , asset , 'BTC')
-
- if not alt_btc or not alt_eth:
- if settings.AGGRESSIVE_MODE:
- # In aggressive mode, try with market prices as fallback
- alt_eth = self.get_price(exchange, asset, 'ETH', mode='ask')
- alt_btc = self.get_price(exchange, asset, 'BTC', mode='bid')
- if not alt_btc or not alt_eth:
- return -100
- else:
- self.log("Less than {} orders for {}, skipping.".format(settings.MIN_ORDERBOOK_DEPTH, asset))
- return -100
-
- # Use maker fees for limit orders (better profitability)
- # Optimize fee if BNB is available (potential 25% discount)
- maker_fee_optimized = self.maker_fee
- try:
- bnb_balance = self.get_balance(exchange, 'BNB')
- if bnb_balance > 0.1: # Has BNB for fee payment
- maker_fee_optimized = 0.99975 # 0.025% fee with BNB discount
- except:
- pass
-
- step_1 = (1 / alt_eth) * maker_fee_optimized
- step_2 = (step_1 * alt_btc) * maker_fee_optimized
- eth_btc_price = self.get_price(exchange , 'ETH' , 'BTC' , mode='ask')
- step_3 = (step_2 / eth_btc_price) * maker_fee_optimized
-
- profit_pct = (step_3 - 1) * 100
-
- # Calculate estimated profit in USD with dynamic position sizing
- eth_price_usd = self.get_price(exchange, 'ETH', 'USDT', mode='average')
- if eth_price_usd:
- balance_eth = self.get_balance(exchange, 'ETH')
- if balance_eth == 0:
- return -100
-
- # Use dynamic position sizing
- position_size_pct = self.calculate_optimal_position_size(exchange, profit_pct, eth_price_usd)
- position_size = balance_eth * position_size_pct
-
- # Account for slippage
- slippage_penalty = 0
- if settings.ENABLE_SLIPPAGE_OPTIMIZATION:
- slippage_penalty = self.estimate_slippage(exchange, asset, 'ETH', position_size)
- slippage_penalty += self.estimate_slippage(exchange, asset, 'BTC', position_size * alt_btc)
-
- profit_usd = (profit_pct / 100 - slippage_penalty) * position_size * eth_price_usd
-
- if profit_usd < settings.MIN_PROFIT_USD:
- return -100 # Not profitable enough
-
- # In aggressive mode, accept slightly lower profits
- if settings.AGGRESSIVE_MODE and profit_usd >= settings.MIN_PROFIT_USD * 0.8:
- return profit_pct
-
- return profit_pct
- except ZeroDivisionError:
- return -1
- except Exception as e:
- self.log("Error estimating forward arbitrage: {}".format(str(e)))
- return -1
-
- # Estimate the profit for backward arbitrage on given asset (improved with maker fees).
- def estimate_arbitrage_backward(self , exchange , asset):
- try:
- alt_btc = self.get_buy_limit_price(exchange , asset , 'BTC')
- alt_eth = self.get_sell_limit_price(exchange , asset , 'ETH')
-
- if not alt_btc or not alt_eth:
- if settings.AGGRESSIVE_MODE:
- # In aggressive mode, try with market prices as fallback
- alt_btc = self.get_price(exchange, asset, 'BTC', mode='ask')
- alt_eth = self.get_price(exchange, asset, 'ETH', mode='bid')
- if not alt_btc or not alt_eth:
- return -100
- else:
- self.log("Less than {} orders for {} on {}, skipping.".format(settings.MIN_ORDERBOOK_DEPTH, asset, str(exchange)))
- return -100
-
- # Use maker fees for limit orders (better profitability)
- # Optimize fee if BNB is available (potential 25% discount)
- maker_fee_optimized = self.maker_fee
- try:
- bnb_balance = self.get_balance(exchange, 'BNB')
- if bnb_balance > 0.1: # Has BNB for fee payment
- maker_fee_optimized = 0.99975 # 0.025% fee with BNB discount
- except:
- pass
-
- eth_btc_price = self.get_price(exchange , 'ETH' , 'BTC' , mode='bid')
- step_1 = eth_btc_price * maker_fee_optimized
- step_2 = (step_1 / alt_btc) * maker_fee_optimized
- step_3 = (step_2 * alt_eth) * maker_fee_optimized
-
- profit_pct = (step_3 - 1) * 100
-
- # Calculate estimated profit in USD with dynamic position sizing
- eth_price_usd = self.get_price(exchange, 'ETH', 'USDT', mode='average')
- if eth_price_usd:
- balance_eth = self.get_balance(exchange, 'ETH')
- if balance_eth == 0:
- return -100
-
- # Use dynamic position sizing
- position_size_pct = self.calculate_optimal_position_size(exchange, profit_pct, eth_price_usd)
- position_size = balance_eth * position_size_pct
-
- # Account for slippage
- slippage_penalty = 0
- if settings.ENABLE_SLIPPAGE_OPTIMIZATION:
- slippage_penalty = self.estimate_slippage(exchange, 'ETH', 'BTC', position_size)
- slippage_penalty += self.estimate_slippage(exchange, asset, 'BTC', position_size * eth_btc_price / alt_btc)
-
- profit_usd = (profit_pct / 100 - slippage_penalty) * position_size * eth_price_usd
-
- if profit_usd < settings.MIN_PROFIT_USD:
- return -100 # Not profitable enough
-
- # In aggressive mode, accept slightly lower profits
- if settings.AGGRESSIVE_MODE and profit_usd >= settings.MIN_PROFIT_USD * 0.8:
- return profit_pct
-
- return profit_pct
- except ZeroDivisionError:
- return -1
- except Exception as e:
- self.log("Error estimating backward arbitrage: {}".format(str(e)))
- return -1
-
- # Executes forward arbitrage on given asset: ETH -> ALT -> BTC -> ETH.
- def run_arbitrage_forward(self , exchange , asset):
- self.log("Arbitrage on {}: ETH -> {} -> BTC -> ETH".format(exchange , asset))
- balance_before = self.get_balance(exchange , "ETH")
-
- # Calculate optimal position size based on opportunity
- profit_estimate = self.estimate_arbitrage_forward(exchange, asset)
- eth_price_usd = self.get_price(exchange, 'ETH', 'USDT', mode='average') or 2000
- position_size_pct = self.calculate_optimal_position_size(exchange, profit_estimate, eth_price_usd)
- position_size = min(position_size_pct, 1.0)
-
- result1 = self.best_buy(exchange , asset , 'ETH' , position_size)
-
- if not result1:
- self.log("Failed to convert {} to ETH, canceling arbitrage.".format(asset))
- return
-
- result2 = self.best_sell(exchange , asset , 'BTC' , position_size)
-
- if not result2:
- self.log(
- "Failed to convert {} to BTC, canceling arbitrage. Will convert back {} to ETH.".format(asset , asset))
- self.sell(exchange , asset , 'ETH' , amount_percentage=1)
- self.summarize_arbitrage(exchange , balance_before , asset)
- return
-
- self.buy(exchange , "ETH" , "BTC" , amount_percentage=1)
- self.summarize_arbitrage(exchange , balance_before , asset)
-
- # Executes backward arbitrage on given asset: ETH -> BTC -> ALT -> ETH.
- def run_arbitrage_backward(self , exchange , asset):
- self.log("Arbitrage on {}: ETH -> BTC -> {} -> ETH".format(exchange , asset))
- balance_before = self.get_balance(exchange , "ETH")
-
- # Calculate optimal position size based on opportunity
- profit_estimate = self.estimate_arbitrage_backward(exchange, asset)
- eth_price_usd = self.get_price(exchange, 'ETH', 'USDT', mode='average') or 2000
- position_size_pct = self.calculate_optimal_position_size(exchange, profit_estimate, eth_price_usd)
- position_size = min(position_size_pct, 1.0)
-
- self.sell(exchange , "ETH" , "BTC" , position_size)
- result1 = self.best_buy(exchange , asset , 'BTC' , position_size)
-
- if not result1:
- self.log("Failed to convert BTC to {}, canceling arbitrage. Will convert BTC to ETH.".format(asset))
- self.buy(exchange , 'ETH' , 'BTC' , amount_percentage=1)
- self.summarize_arbitrage(exchange , balance_before , asset)
- return
-
- result2 = self.best_sell(exchange , asset , 'ETH' , position_size)
-
- if not result2:
- self.log(
- "Failed to convert {} to ETH, canceling arbitrage. Forcing conversion from {} to ETH.".format(asset ,
- asset))
- self.sell(exchange , asset , 'ETH' , amount_percentage=1)
- self.summarize_arbitrage(exchange , balance_before , asset)
- return
-
- self.summarize_arbitrage(exchange , balance_before , asset)
-
- # Logs given string.
- @staticmethod
- def log(text):
- formatted_text = "[{}] {}".format(datetime.now().strftime("%d/%m/%Y %H:%M:%S") , text)
-
- with open('logs.txt' , 'a+') as file:
- file.write(formatted_text)
- file.write("\n")
-
- # Buy at best price possible using decreasing buy limit orders.
- def best_buy(self , exchange , asset1 , asset2 , amount_percentage):
- order_book = self.get_order_book(exchange , asset1 , asset2 , mode="asks")
- order_book.sort(key=itemgetter(0))
-
- for price in order_book[:settings.MAX_TRIES_ORDERBOOK]:
- self.log("Trying to buy {} with {} @{:.8f}.".format(asset1 , asset2 , price[0]))
- result = self.buy(exchange , asset1 , asset2 , amount_percentage=amount_percentage , limit=price[0] ,
- timeout=settings.WAIT_BETWEEN_ORDER)
-
- if result:
- self.log("Bought {} with {} @{:.8f}.".format(asset1 , asset2 , price[0]))
- return True
-
- else:
- self.log("Failed to buy {} with {} at {:.8f}.".format(asset1 , asset2 , price[0]))
-
- self.log("Was not able to buy {} with {}".format(asset1 , asset2))
- return False
-
- # Sell at best price possible using decreasing buy sell orders.
- def best_sell(self , exchange , asset1 , asset2 , amount_percentage):
- order_book = self.get_order_book(exchange , asset1 , asset2 , mode="bids")
- order_book.sort(key=itemgetter(0) , reverse=True)
-
- for price in order_book[:settings.MAX_TRIES_ORDERBOOK]:
- self.log("Trying to sell {} to {} @{:.8f}.".format(asset1 , asset2 , price[0]))
- result = self.sell(exchange , asset1 , asset2 , amount_percentage=amount_percentage , limit=price[0] ,
- timeout=settings.WAIT_BETWEEN_ORDER)
-
- if result:
- self.log("Sold {} to {} @{:.8f}.".format(asset1 , asset2 , price[0]))
- return True
-
- else:
- self.log("Failed to sell {} to {} at {:.8f}.".format(asset1 , asset2 , price[0]))
-
- self.log("Was not able to sell {} to {}".format(asset1 , asset2))
- return False
-
- # Summarize arbitrage, calculate loss/gain, print it, and save it on the file.
- def summarize_arbitrage(self , exchange , balance_before , asset):
- balance_after = self.get_balance(exchange , "ETH")
- diff = balance_after - balance_before
-
- # Get USD price for better tracking
- try:
- eth_price_usd = self.get_price(exchange, 'ETH', 'USDT', mode='average')
- diff_usd = diff * eth_price_usd if eth_price_usd else 0
- except:
- diff_usd = 0
-
- # Track profits
- self.trades_executed += 1
- if diff > 0:
- self.successful_trades += 1
- self.total_profit_eth += diff
- self.total_profit_usd += diff_usd
-
- success = diff > 0
- self.log("Arbitrage {:5} on binance, diff: {:8.6f}ETH (${:.2f} USD). Total profit: {:8.6f}ETH (${:.2f} USD) | Success rate: {:.1f}%".format(
- asset, diff, diff_usd, self.total_profit_eth, self.total_profit_usd,
- (self.successful_trades / self.trades_executed * 100) if self.trades_executed > 0 else 0
- ))
- self.save_profit_tracking()
-
- # Send Telegram notification
- if self.telegram:
- self.telegram.notify_trade_executed('Binance', asset, diff, diff_usd, success)
-
- # Load profit tracking from file
- def load_profit_tracking(self):
- try:
- import json
- import os
- if os.path.exists('profit_tracking.json'):
- with open('profit_tracking.json', 'r') as f:
- data = json.load(f)
- self.total_profit_eth = data.get('total_profit_eth', 0.0)
- self.total_profit_usd = data.get('total_profit_usd', 0.0)
- self.trades_executed = data.get('trades_executed', 0)
- self.successful_trades = data.get('successful_trades', 0)
- except Exception as e:
- self.log("Error loading profit tracking: {}".format(str(e)))
-
- # Save profit tracking to file
- def save_profit_tracking(self):
- try:
- import json
- data = {
- 'total_profit_eth': self.total_profit_eth,
- 'total_profit_usd': self.total_profit_usd,
- 'trades_executed': self.trades_executed,
- 'successful_trades': self.successful_trades,
- 'last_updated': datetime.now().isoformat()
- }
- with open('profit_tracking.json', 'w') as f:
- json.dump(data, f, indent=2)
- except Exception as e:
- self.log("Error saving profit tracking: {}".format(str(e)))
-
- # Get profit statistics
- def get_profit_stats(self):
- success_rate = (self.successful_trades / self.trades_executed * 100) if self.trades_executed > 0 else 0
- return {
- 'total_profit_eth': self.total_profit_eth,
- 'total_profit_usd': self.total_profit_usd,
- 'trades_executed': self.trades_executed,
- 'successful_trades': self.successful_trades,
- 'success_rate': success_rate
- }
diff --git a/src/order_executor.py b/src/order_executor.py
deleted file mode 100644
index 8d2c494..0000000
--- a/src/order_executor.py
+++ /dev/null
@@ -1,821 +0,0 @@
-"""
-Smart Order Execution Module
-Implements advanced order execution algorithms for optimal trade execution.
-"""
-
-import time
-import logging
-import threading
-from typing import Dict, List, Optional, Any, Callable
-from dataclasses import dataclass
-from datetime import datetime, timedelta
-from collections import deque
-import math
-
-
-@dataclass
-class OrderSlice:
- """Represents a slice of a larger order."""
- slice_id: int
- amount: float
- price: Optional[float]
- side: str
- status: str # 'pending', 'submitted', 'filled', 'cancelled'
- submitted_at: Optional[datetime]
- filled_at: Optional[datetime]
- fill_price: Optional[float]
- order_id: Optional[str]
-
-
-@dataclass
-class ExecutionResult:
- """Result of order execution."""
- success: bool
- total_amount: float
- filled_amount: float
- average_price: float
- total_cost: float
- slippage: float
- execution_time: float
- order_slices: List[OrderSlice]
- error: Optional[str]
-
-
-class TWAPExecutor:
- """
- Time-Weighted Average Price (TWAP) Executor
- Splits large orders into smaller slices executed over time.
- """
-
- def __init__(self, exchange_manager):
- self.exchange_manager = exchange_manager
- self.logger = logging.getLogger(__name__)
- self.active_executions = {}
-
- def execute(self, exchange: str, symbol: str, side: str, total_amount: float,
- duration_seconds: float, num_slices: int = 10,
- price_limit: Optional[float] = None) -> ExecutionResult:
- """
- Execute a TWAP order.
-
- Args:
- exchange: Exchange to execute on
- symbol: Trading pair
- side: 'buy' or 'sell'
- total_amount: Total amount to execute
- duration_seconds: Time period to execute over
- num_slices: Number of order slices
- price_limit: Optional limit price
-
- Returns:
- ExecutionResult with details
- """
- start_time = datetime.now()
- slice_amount = total_amount / num_slices
- slice_interval = duration_seconds / num_slices
-
- slices: List[OrderSlice] = []
- filled_amount = 0
- total_cost = 0
-
- execution_id = f"{exchange}_{symbol}_{start_time.timestamp()}"
- self.active_executions[execution_id] = {
- 'status': 'running',
- 'slices': slices
- }
-
- try:
- for i in range(num_slices):
- # Check if execution was cancelled
- if self.active_executions.get(execution_id, {}).get('status') == 'cancelled':
- break
-
- slice_obj = OrderSlice(
- slice_id=i,
- amount=slice_amount,
- price=price_limit,
- side=side,
- status='pending',
- submitted_at=None,
- filled_at=None,
- fill_price=None,
- order_id=None
- )
-
- # Get current market price
- ticker = self.exchange_manager.get_ticker(exchange, symbol)
- if not ticker:
- slice_obj.status = 'cancelled'
- slices.append(slice_obj)
- continue
-
- current_price = ticker['ask'] if side == 'buy' else ticker['bid']
-
- # Check price limit
- if price_limit:
- if side == 'buy' and current_price > price_limit:
- slice_obj.status = 'cancelled'
- slices.append(slice_obj)
- time.sleep(slice_interval)
- continue
- elif side == 'sell' and current_price < price_limit:
- slice_obj.status = 'cancelled'
- slices.append(slice_obj)
- time.sleep(slice_interval)
- continue
-
- # Submit order
- slice_obj.submitted_at = datetime.now()
- slice_obj.status = 'submitted'
-
- order = self.exchange_manager.create_market_order(
- exchange, symbol, side, slice_amount
- )
-
- if order:
- slice_obj.order_id = order.get('id')
- slice_obj.status = 'filled'
- slice_obj.filled_at = datetime.now()
- slice_obj.fill_price = order.get('average', current_price)
-
- filled_amount += slice_amount
- total_cost += slice_amount * slice_obj.fill_price
- else:
- slice_obj.status = 'cancelled'
-
- slices.append(slice_obj)
-
- # Wait before next slice (except for last)
- if i < num_slices - 1:
- time.sleep(slice_interval)
-
- except Exception as e:
- self.logger.error(f"TWAP execution error: {e}")
-
- finally:
- del self.active_executions[execution_id]
-
- end_time = datetime.now()
- execution_time = (end_time - start_time).total_seconds()
-
- average_price = total_cost / filled_amount if filled_amount > 0 else 0
-
- # Calculate slippage compared to first price
- initial_price = slices[0].fill_price if slices and slices[0].fill_price else 0
- slippage = ((average_price - initial_price) / initial_price * 100) if initial_price > 0 else 0
-
- return ExecutionResult(
- success=filled_amount > 0,
- total_amount=total_amount,
- filled_amount=filled_amount,
- average_price=average_price,
- total_cost=total_cost,
- slippage=abs(slippage),
- execution_time=execution_time,
- order_slices=slices,
- error=None if filled_amount > 0 else "No orders filled"
- )
-
-
-class VWAPExecutor:
- """
- Volume-Weighted Average Price (VWAP) Executor
- Executes orders based on historical volume profile.
- """
-
- def __init__(self, exchange_manager):
- self.exchange_manager = exchange_manager
- self.logger = logging.getLogger(__name__)
- self.volume_profiles = {}
-
- def get_volume_profile(self, exchange: str, symbol: str, periods: int = 24) -> List[float]:
- """
- Get historical volume profile.
- Returns percentage of daily volume for each hour.
- """
- # In real implementation, this would fetch historical data
- # Using typical crypto volume profile as placeholder
- typical_profile = [
- 0.032, 0.028, 0.025, 0.024, 0.028, 0.035, # 0-5 (low Asia)
- 0.045, 0.055, 0.060, 0.058, 0.055, 0.050, # 6-11 (Europe open)
- 0.055, 0.065, 0.070, 0.068, 0.062, 0.055, # 12-17 (US open)
- 0.048, 0.042, 0.038, 0.035, 0.033, 0.035 # 18-23 (evening)
- ]
-
- # Normalize to sum to 1
- total = sum(typical_profile)
- return [v / total for v in typical_profile]
-
- def calculate_execution_schedule(self, total_amount: float, start_hour: int,
- duration_hours: int) -> Dict[int, float]:
- """
- Calculate execution schedule based on volume profile.
-
- Returns:
- Dict mapping hour to amount to execute
- """
- profile = self.get_volume_profile('', '', 24)
-
- # Get relevant hours
- hours = [(start_hour + i) % 24 for i in range(duration_hours)]
- relevant_volumes = [profile[h] for h in hours]
- total_volume = sum(relevant_volumes)
-
- # Allocate amounts proportionally
- schedule = {}
- for hour, volume in zip(hours, relevant_volumes):
- schedule[hour] = total_amount * (volume / total_volume)
-
- return schedule
-
- def execute(self, exchange: str, symbol: str, side: str, total_amount: float,
- duration_hours: int = 4, slices_per_hour: int = 4,
- price_limit: Optional[float] = None) -> ExecutionResult:
- """
- Execute a VWAP order.
-
- Args:
- exchange: Exchange to execute on
- symbol: Trading pair
- side: 'buy' or 'sell'
- total_amount: Total amount to execute
- duration_hours: Duration to execute over (in hours)
- slices_per_hour: Number of slices per hour
- price_limit: Optional limit price
-
- Returns:
- ExecutionResult with details
- """
- start_time = datetime.now()
- current_hour = start_time.hour
-
- # Get execution schedule
- schedule = self.calculate_execution_schedule(
- total_amount, current_hour, duration_hours
- )
-
- slices: List[OrderSlice] = []
- filled_amount = 0
- total_cost = 0
- slice_id = 0
-
- try:
- for hour in range(duration_hours):
- actual_hour = (current_hour + hour) % 24
- hour_amount = schedule.get(actual_hour, 0)
- slice_amount = hour_amount / slices_per_hour
-
- for i in range(slices_per_hour):
- slice_obj = OrderSlice(
- slice_id=slice_id,
- amount=slice_amount,
- price=price_limit,
- side=side,
- status='pending',
- submitted_at=None,
- filled_at=None,
- fill_price=None,
- order_id=None
- )
-
- # Get current price
- ticker = self.exchange_manager.get_ticker(exchange, symbol)
- if ticker:
- current_price = ticker['ask'] if side == 'buy' else ticker['bid']
-
- # Check price limit
- execute = True
- if price_limit:
- if side == 'buy' and current_price > price_limit:
- execute = False
- elif side == 'sell' and current_price < price_limit:
- execute = False
-
- if execute:
- slice_obj.submitted_at = datetime.now()
- order = self.exchange_manager.create_market_order(
- exchange, symbol, side, slice_amount
- )
-
- if order:
- slice_obj.status = 'filled'
- slice_obj.filled_at = datetime.now()
- slice_obj.fill_price = order.get('average', current_price)
- slice_obj.order_id = order.get('id')
-
- filled_amount += slice_amount
- total_cost += slice_amount * slice_obj.fill_price
- else:
- slice_obj.status = 'cancelled'
- else:
- slice_obj.status = 'cancelled'
- else:
- slice_obj.status = 'cancelled'
-
- slices.append(slice_obj)
- slice_id += 1
-
- # Wait between slices
- time.sleep(3600 / slices_per_hour)
-
- except Exception as e:
- self.logger.error(f"VWAP execution error: {e}")
-
- end_time = datetime.now()
- execution_time = (end_time - start_time).total_seconds()
-
- average_price = total_cost / filled_amount if filled_amount > 0 else 0
- initial_price = slices[0].fill_price if slices and slices[0].fill_price else 0
- slippage = ((average_price - initial_price) / initial_price * 100) if initial_price > 0 else 0
-
- return ExecutionResult(
- success=filled_amount > 0,
- total_amount=total_amount,
- filled_amount=filled_amount,
- average_price=average_price,
- total_cost=total_cost,
- slippage=abs(slippage),
- execution_time=execution_time,
- order_slices=slices,
- error=None if filled_amount > 0 else "No orders filled"
- )
-
-
-class IcebergExecutor:
- """
- Iceberg Order Executor
- Hides large orders by only showing small portions.
- """
-
- def __init__(self, exchange_manager):
- self.exchange_manager = exchange_manager
- self.logger = logging.getLogger(__name__)
-
- def execute(self, exchange: str, symbol: str, side: str, total_amount: float,
- visible_amount: float, price: float,
- price_variance: float = 0.001) -> ExecutionResult:
- """
- Execute an iceberg order.
-
- Args:
- exchange: Exchange to execute on
- symbol: Trading pair
- side: 'buy' or 'sell'
- total_amount: Total hidden amount
- visible_amount: Visible order size
- price: Limit price
- price_variance: Random price variance to hide pattern
-
- Returns:
- ExecutionResult
- """
- start_time = datetime.now()
- slices: List[OrderSlice] = []
- filled_amount = 0
- total_cost = 0
- slice_id = 0
- remaining = total_amount
-
- try:
- while remaining > 0:
- slice_amount = min(visible_amount, remaining)
-
- # Add small random variance to price
- import random
- variance = random.uniform(-price_variance, price_variance)
- slice_price = price * (1 + variance)
-
- slice_obj = OrderSlice(
- slice_id=slice_id,
- amount=slice_amount,
- price=slice_price,
- side=side,
- status='pending',
- submitted_at=datetime.now(),
- filled_at=None,
- fill_price=None,
- order_id=None
- )
-
- # Place limit order
- order = self.exchange_manager.create_limit_order(
- exchange, symbol, side, slice_amount, slice_price
- )
-
- if order:
- slice_obj.order_id = order.get('id')
- slice_obj.status = 'submitted'
-
- # Wait for fill (simplified - real implementation would monitor)
- time.sleep(2)
-
- # Check if filled (simplified)
- slice_obj.status = 'filled'
- slice_obj.filled_at = datetime.now()
- slice_obj.fill_price = slice_price
-
- filled_amount += slice_amount
- total_cost += slice_amount * slice_price
- remaining -= slice_amount
- else:
- slice_obj.status = 'cancelled'
- break
-
- slices.append(slice_obj)
- slice_id += 1
-
- # Small delay between slices
- time.sleep(0.5)
-
- except Exception as e:
- self.logger.error(f"Iceberg execution error: {e}")
-
- end_time = datetime.now()
- execution_time = (end_time - start_time).total_seconds()
- average_price = total_cost / filled_amount if filled_amount > 0 else 0
- slippage = ((average_price - price) / price * 100) if price > 0 else 0
-
- return ExecutionResult(
- success=filled_amount >= total_amount * 0.95, # 95% fill rate
- total_amount=total_amount,
- filled_amount=filled_amount,
- average_price=average_price,
- total_cost=total_cost,
- slippage=abs(slippage),
- execution_time=execution_time,
- order_slices=slices,
- error=None
- )
-
-
-class SmartOrderRouter:
- """
- Smart Order Router
- Routes orders to optimal execution venues.
- """
-
- def __init__(self, exchange_manager):
- self.exchange_manager = exchange_manager
- self.logger = logging.getLogger(__name__)
-
- def get_best_execution_venue(self, symbol: str, side: str,
- amount: float) -> Dict[str, Any]:
- """
- Find the best exchange for order execution.
-
- Args:
- symbol: Trading pair
- side: 'buy' or 'sell'
- amount: Order amount
-
- Returns:
- Dict with best exchange and expected execution price
- """
- exchanges = self.exchange_manager.get_enabled_exchanges()
- best_exchange = None
- best_price = float('inf') if side == 'buy' else 0
- best_depth = 0
-
- for exchange in exchanges:
- orderbook = self.exchange_manager.get_order_book(exchange, symbol, limit=10)
- if not orderbook:
- continue
-
- orders = orderbook['asks'] if side == 'buy' else orderbook['bids']
- if not orders:
- continue
-
- # Calculate effective price for the amount
- remaining = amount
- total_cost = 0
-
- for price, volume in orders:
- if remaining <= 0:
- break
- fill_amount = min(remaining, volume)
- total_cost += fill_amount * price
- remaining -= fill_amount
-
- if remaining > 0:
- continue # Not enough liquidity
-
- effective_price = total_cost / amount
-
- # Calculate depth score (more depth = better)
- depth = sum(v for _, v in orders[:5])
-
- # Check if this is better
- is_better = False
- if side == 'buy' and effective_price < best_price:
- is_better = True
- elif side == 'sell' and effective_price > best_price:
- is_better = True
-
- if is_better:
- best_exchange = exchange
- best_price = effective_price
- best_depth = depth
-
- if best_exchange:
- return {
- 'exchange': best_exchange,
- 'expected_price': best_price,
- 'depth': best_depth,
- 'fee': self.exchange_manager.get_exchange_fee(best_exchange)
- }
-
- return {}
-
- def split_order_across_exchanges(self, symbol: str, side: str,
- total_amount: float) -> List[Dict[str, Any]]:
- """
- Split order across multiple exchanges for optimal execution.
-
- Returns:
- List of (exchange, amount) pairs
- """
- exchanges = self.exchange_manager.get_enabled_exchanges()
- allocation = []
-
- # Get liquidity at each exchange
- liquidity = {}
- for exchange in exchanges:
- orderbook = self.exchange_manager.get_order_book(exchange, symbol, limit=10)
- if not orderbook:
- continue
-
- orders = orderbook['asks'] if side == 'buy' else orderbook['bids']
- if orders:
- liquidity[exchange] = sum(v for _, v in orders[:5])
-
- if not liquidity:
- return []
-
- # Allocate proportionally to liquidity
- total_liquidity = sum(liquidity.values())
-
- for exchange, liq in liquidity.items():
- proportion = liq / total_liquidity
- amount = total_amount * proportion
-
- allocation.append({
- 'exchange': exchange,
- 'amount': amount,
- 'liquidity': liq,
- 'proportion': proportion
- })
-
- return allocation
-
- def execute_smart(self, symbol: str, side: str, amount: float,
- strategy: str = 'best_price') -> ExecutionResult:
- """
- Execute order with smart routing.
-
- Args:
- symbol: Trading pair
- side: 'buy' or 'sell'
- amount: Order amount
- strategy: 'best_price', 'split', or 'fastest'
-
- Returns:
- ExecutionResult
- """
- start_time = datetime.now()
- slices: List[OrderSlice] = []
- filled_amount = 0
- total_cost = 0
-
- try:
- if strategy == 'best_price':
- # Route to single best exchange
- best = self.get_best_execution_venue(symbol, side, amount)
- if best:
- order = self.exchange_manager.create_market_order(
- best['exchange'], symbol, side, amount
- )
-
- if order:
- filled_amount = amount
- fill_price = order.get('average', best['expected_price'])
- total_cost = amount * fill_price
-
- slices.append(OrderSlice(
- slice_id=0,
- amount=amount,
- price=None,
- side=side,
- status='filled',
- submitted_at=start_time,
- filled_at=datetime.now(),
- fill_price=fill_price,
- order_id=order.get('id')
- ))
-
- elif strategy == 'split':
- # Split across exchanges
- allocation = self.split_order_across_exchanges(symbol, side, amount)
-
- for i, alloc in enumerate(allocation):
- order = self.exchange_manager.create_market_order(
- alloc['exchange'], symbol, side, alloc['amount']
- )
-
- if order:
- fill_price = order.get('average', 0)
- filled_amount += alloc['amount']
- total_cost += alloc['amount'] * fill_price
-
- slices.append(OrderSlice(
- slice_id=i,
- amount=alloc['amount'],
- price=None,
- side=side,
- status='filled',
- submitted_at=start_time,
- filled_at=datetime.now(),
- fill_price=fill_price,
- order_id=order.get('id')
- ))
-
- except Exception as e:
- self.logger.error(f"Smart routing error: {e}")
-
- end_time = datetime.now()
- execution_time = (end_time - start_time).total_seconds()
- average_price = total_cost / filled_amount if filled_amount > 0 else 0
-
- return ExecutionResult(
- success=filled_amount > 0,
- total_amount=amount,
- filled_amount=filled_amount,
- average_price=average_price,
- total_cost=total_cost,
- slippage=0, # Would need reference price
- execution_time=execution_time,
- order_slices=slices,
- error=None if filled_amount > 0 else "Execution failed"
- )
-
-
-class OrderExecutor:
- """
- Main order executor combining all execution strategies.
- """
-
- def __init__(self, exchange_manager):
- self.exchange_manager = exchange_manager
- self.logger = logging.getLogger(__name__)
-
- self.twap = TWAPExecutor(exchange_manager)
- self.vwap = VWAPExecutor(exchange_manager)
- self.iceberg = IcebergExecutor(exchange_manager)
- self.smart_router = SmartOrderRouter(exchange_manager)
-
- self.execution_history = deque(maxlen=1000)
-
- def execute(self, exchange: str, symbol: str, side: str, amount: float,
- strategy: str = 'market', **kwargs) -> ExecutionResult:
- """
- Execute an order using specified strategy.
-
- Args:
- exchange: Exchange to execute on
- symbol: Trading pair
- side: 'buy' or 'sell'
- amount: Order amount
- strategy: Execution strategy:
- - 'market': Simple market order
- - 'twap': Time-weighted average price
- - 'vwap': Volume-weighted average price
- - 'iceberg': Hidden size order
- - 'smart': Smart order routing
-
- Returns:
- ExecutionResult
- """
- self.logger.info(f"Executing {side} {amount} {symbol} using {strategy}")
-
- if strategy == 'market':
- result = self._execute_market(exchange, symbol, side, amount)
- elif strategy == 'twap':
- duration = kwargs.get('duration_seconds', 60)
- num_slices = kwargs.get('num_slices', 10)
- result = self.twap.execute(exchange, symbol, side, amount,
- duration, num_slices)
- elif strategy == 'vwap':
- duration_hours = kwargs.get('duration_hours', 4)
- result = self.vwap.execute(exchange, symbol, side, amount,
- duration_hours)
- elif strategy == 'iceberg':
- visible = kwargs.get('visible_amount', amount * 0.1)
- price = kwargs.get('price')
- if not price:
- ticker = self.exchange_manager.get_ticker(exchange, symbol)
- price = ticker['ask'] if side == 'buy' else ticker['bid']
- result = self.iceberg.execute(exchange, symbol, side, amount,
- visible, price)
- elif strategy == 'smart':
- routing_strategy = kwargs.get('routing', 'best_price')
- result = self.smart_router.execute_smart(symbol, side, amount,
- routing_strategy)
- else:
- result = ExecutionResult(
- success=False,
- total_amount=amount,
- filled_amount=0,
- average_price=0,
- total_cost=0,
- slippage=0,
- execution_time=0,
- order_slices=[],
- error=f"Unknown strategy: {strategy}"
- )
-
- self.execution_history.append({
- 'timestamp': datetime.now(),
- 'exchange': exchange,
- 'symbol': symbol,
- 'side': side,
- 'amount': amount,
- 'strategy': strategy,
- 'result': result
- })
-
- return result
-
- def _execute_market(self, exchange: str, symbol: str, side: str,
- amount: float) -> ExecutionResult:
- """Execute simple market order."""
- start_time = datetime.now()
-
- order = self.exchange_manager.create_market_order(
- exchange, symbol, side, amount
- )
-
- end_time = datetime.now()
-
- if order:
- fill_price = order.get('average', order.get('price', 0))
- return ExecutionResult(
- success=True,
- total_amount=amount,
- filled_amount=order.get('filled', amount),
- average_price=fill_price,
- total_cost=amount * fill_price,
- slippage=0,
- execution_time=(end_time - start_time).total_seconds(),
- order_slices=[OrderSlice(
- slice_id=0,
- amount=amount,
- price=None,
- side=side,
- status='filled',
- submitted_at=start_time,
- filled_at=end_time,
- fill_price=fill_price,
- order_id=order.get('id')
- )],
- error=None
- )
- else:
- return ExecutionResult(
- success=False,
- total_amount=amount,
- filled_amount=0,
- average_price=0,
- total_cost=0,
- slippage=0,
- execution_time=(end_time - start_time).total_seconds(),
- order_slices=[],
- error="Market order failed"
- )
-
- def get_execution_stats(self) -> Dict[str, Any]:
- """Get execution statistics."""
- if not self.execution_history:
- return {}
-
- total_executions = len(self.execution_history)
- successful = sum(1 for e in self.execution_history if e['result'].success)
-
- total_slippage = sum(e['result'].slippage for e in self.execution_history
- if e['result'].success)
- avg_slippage = total_slippage / successful if successful > 0 else 0
-
- by_strategy = {}
- for e in self.execution_history:
- strategy = e['strategy']
- if strategy not in by_strategy:
- by_strategy[strategy] = {'count': 0, 'success': 0}
- by_strategy[strategy]['count'] += 1
- if e['result'].success:
- by_strategy[strategy]['success'] += 1
-
- return {
- 'total_executions': total_executions,
- 'successful': successful,
- 'success_rate': successful / total_executions if total_executions > 0 else 0,
- 'avg_slippage': avg_slippage,
- 'by_strategy': by_strategy
- }
diff --git a/src/payment_handler.py b/src/payment_handler.py
deleted file mode 100644
index b8e6c05..0000000
--- a/src/payment_handler.py
+++ /dev/null
@@ -1,255 +0,0 @@
-"""
-Payment Handler Module
-Stripe integration for subscription payments and webhooks
-"""
-import json
-import os
-from datetime import datetime, timedelta
-
-# Stripe is optional - only import if available
-try:
- import stripe
- STRIPE_AVAILABLE = True
-except ImportError:
- STRIPE_AVAILABLE = False
- stripe = None
-
-
-class PaymentHandler:
- """Handles Stripe payment integration for subscriptions"""
-
- # Pricing configuration
- PRICING = {
- 'basic': {
- 'name': 'Basic',
- 'price_usd': 9.99,
- 'price_id': os.environ.get('STRIPE_PRICE_BASIC', 'price_basic'),
- 'features': ['telegram_notifications']
- },
- 'premium': {
- 'name': 'Premium',
- 'price_usd': 29.99,
- 'price_id': os.environ.get('STRIPE_PRICE_PREMIUM', 'price_premium'),
- 'features': ['telegram_notifications', 'api_access', 'advanced_analytics', 'custom_strategies']
- },
- 'enterprise': {
- 'name': 'Enterprise',
- 'price_usd': 99.99,
- 'price_id': os.environ.get('STRIPE_PRICE_ENTERPRISE', 'price_enterprise'),
- 'features': ['telegram_notifications', 'api_access', 'advanced_analytics', 'multi_exchange', 'custom_strategies']
- }
- }
-
- def __init__(self):
- self.stripe_api_key = os.environ.get('STRIPE_SECRET_KEY')
- self.webhook_secret = os.environ.get('STRIPE_WEBHOOK_SECRET')
-
- if STRIPE_AVAILABLE and self.stripe_api_key:
- stripe.api_key = self.stripe_api_key
- self.enabled = True
- else:
- self.enabled = False
-
- def create_checkout_session(self, tier: str, success_url: str, cancel_url: str, customer_email: str = None) -> dict:
- """
- Create a Stripe checkout session for subscription
-
- Args:
- tier: Subscription tier ('basic', 'premium', 'enterprise')
- success_url: URL to redirect on successful payment
- cancel_url: URL to redirect on cancelled payment
- customer_email: Optional customer email
-
- Returns:
- dict with session_id and checkout_url
- """
- if not self.enabled:
- # Return demo session if Stripe not configured
- return {
- 'status': 'demo',
- 'message': 'Stripe not configured. Use demo license activation.',
- 'demo_license': f'demo-{tier}-{datetime.now().strftime("%Y%m%d")}',
- 'tier': tier
- }
-
- pricing = self.PRICING.get(tier)
- if not pricing:
- return {'error': f'Invalid tier: {tier}'}
-
- try:
- session_params = {
- 'payment_method_types': ['card'],
- 'line_items': [{
- 'price': pricing['price_id'],
- 'quantity': 1
- }],
- 'mode': 'subscription',
- 'success_url': success_url,
- 'cancel_url': cancel_url,
- 'metadata': {
- 'tier': tier
- }
- }
-
- if customer_email:
- session_params['customer_email'] = customer_email
-
- session = stripe.checkout.Session.create(**session_params)
-
- return {
- 'session_id': session.id,
- 'checkout_url': session.url,
- 'tier': tier,
- 'price_usd': pricing['price_usd']
- }
- except Exception as e:
- return {'error': str(e)}
-
- def verify_webhook_signature(self, payload: bytes, signature: str) -> dict:
- """
- Verify Stripe webhook signature
-
- Args:
- payload: Raw request body
- signature: Stripe-Signature header
-
- Returns:
- Parsed event if valid, None if invalid
- """
- if not self.enabled or not self.webhook_secret:
- # In demo mode, parse without verification
- try:
- return json.loads(payload)
- except:
- return None
-
- try:
- event = stripe.Webhook.construct_event(
- payload, signature, self.webhook_secret
- )
- return event
- except Exception as e:
- print(f"Webhook signature verification failed: {e}")
- return None
-
- def process_webhook_event(self, event: dict) -> dict:
- """
- Process Stripe webhook event
-
- Handles:
- - checkout.session.completed: Activate subscription
- - customer.subscription.updated: Update subscription
- - customer.subscription.deleted: Cancel subscription
- - invoice.payment_failed: Handle failed payment
-
- Returns:
- dict with processing result
- """
- event_type = event.get('type')
- data = event.get('data', {}).get('object', {})
-
- result = {'event_type': event_type, 'processed': False}
-
- try:
- from src.subscription import SubscriptionManager
- sub_manager = SubscriptionManager()
- except:
- return {'error': 'Failed to load subscription manager'}
-
- if event_type == 'checkout.session.completed':
- # New subscription created
- tier = data.get('metadata', {}).get('tier', 'premium')
- customer_email = data.get('customer_email')
-
- # Activate license for 30 days
- license_key = f"stripe-{data.get('id', 'unknown')}"
- sub_manager.activate_license(license_key, tier, days=30)
-
- result['processed'] = True
- result['action'] = 'subscription_activated'
- result['tier'] = tier
- result['customer_email'] = customer_email
-
- elif event_type == 'customer.subscription.updated':
- # Subscription updated (upgrade/downgrade)
- status = data.get('status')
-
- if status == 'active':
- # Get tier from price metadata
- items = data.get('items', {}).get('data', [])
- if items:
- price_id = items[0].get('price', {}).get('id')
- tier = self._get_tier_from_price(price_id)
- if tier:
- sub_manager.activate_license(f"stripe-update", tier, days=30)
- result['tier'] = tier
-
- result['processed'] = True
- result['action'] = 'subscription_updated'
- result['status'] = status
-
- elif event_type == 'customer.subscription.deleted':
- # Subscription cancelled
- sub_manager.activate_license('cancelled', 'free', days=0)
- result['processed'] = True
- result['action'] = 'subscription_cancelled'
-
- elif event_type == 'invoice.payment_failed':
- # Payment failed
- result['processed'] = True
- result['action'] = 'payment_failed'
- result['message'] = 'Payment failed, subscription may be suspended'
-
- return result
-
- def _get_tier_from_price(self, price_id: str) -> str:
- """Get tier name from Stripe price ID"""
- for tier, config in self.PRICING.items():
- if config['price_id'] == price_id:
- return tier
- return None
-
- def get_pricing_info(self) -> dict:
- """Get pricing information for display"""
- return {
- 'currency': 'USD',
- 'billing_period': 'monthly',
- 'tiers': {
- tier: {
- 'name': info['name'],
- 'price': info['price_usd'],
- 'features': info['features']
- }
- for tier, info in self.PRICING.items()
- }
- }
-
- def get_customer_portal_url(self, customer_id: str, return_url: str) -> dict:
- """
- Create Stripe customer portal session for subscription management
-
- Args:
- customer_id: Stripe customer ID
- return_url: URL to return to after portal session
-
- Returns:
- dict with portal_url
- """
- if not self.enabled:
- return {
- 'status': 'demo',
- 'message': 'Stripe not configured. Manage subscription via API.'
- }
-
- try:
- session = stripe.billing_portal.Session.create(
- customer=customer_id,
- return_url=return_url
- )
- return {'portal_url': session.url}
- except Exception as e:
- return {'error': str(e)}
-
-
-# Global instance
-payment_handler = PaymentHandler()
diff --git a/src/strategies.py b/src/strategies.py
deleted file mode 100644
index f74e528..0000000
--- a/src/strategies.py
+++ /dev/null
@@ -1,987 +0,0 @@
-"""
-Comprehensive Arbitrage Strategies Module
-Contains multiple profitable arbitrage strategies for cryptocurrency trading.
-"""
-
-import time
-import logging
-import threading
-from typing import Dict, List, Optional, Any, Tuple
-from dataclasses import dataclass
-from datetime import datetime, timedelta
-from collections import deque
-import json
-import math
-
-try:
- from web3 import Web3
- WEB3_AVAILABLE = True
-except ImportError:
- WEB3_AVAILABLE = False
-
-
-@dataclass
-class ArbitrageOpportunity:
- """Represents an arbitrage opportunity."""
- strategy: str
- exchange_buy: str
- exchange_sell: str
- symbol: str
- buy_price: float
- sell_price: float
- profit_percentage: float
- profit_usd: float
- volume_available: float
- timestamp: datetime
- metadata: Dict[str, Any] = None
-
-
-class CrossExchangeArbitrage:
- """
- Cross-Exchange Arbitrage Strategy
- Buy on one exchange where price is low, sell on another where price is high.
- """
-
- def __init__(self, exchange_manager, settings):
- self.exchange_manager = exchange_manager
- self.settings = settings
- self.logger = logging.getLogger(__name__)
- self.opportunities_history = deque(maxlen=1000)
- self.profit_tracker = {'total_profit_usd': 0, 'trades': 0, 'successful': 0}
-
- def scan_opportunities(self, symbol: str) -> Optional[ArbitrageOpportunity]:
- """
- Scan for cross-exchange arbitrage opportunities for a given symbol.
-
- Args:
- symbol: Trading pair (e.g., 'BTC/USDT')
-
- Returns:
- ArbitrageOpportunity if found, None otherwise
- """
- exchanges = self.exchange_manager.get_enabled_exchanges()
- if len(exchanges) < 2:
- return None
-
- prices = {}
- for exchange in exchanges:
- ticker = self.exchange_manager.get_ticker(exchange, symbol)
- if ticker:
- prices[exchange] = {
- 'bid': ticker.get('bid', 0),
- 'ask': ticker.get('ask', 0),
- 'volume': ticker.get('baseVolume', 0)
- }
-
- if len(prices) < 2:
- return None
-
- # Find best buy (lowest ask) and best sell (highest bid)
- best_buy = min(prices.items(), key=lambda x: x[1]['ask'] if x[1]['ask'] > 0 else float('inf'))
- best_sell = max(prices.items(), key=lambda x: x[1]['bid'])
-
- buy_exchange, buy_data = best_buy
- sell_exchange, sell_data = best_sell
-
- if buy_exchange == sell_exchange:
- return None
-
- buy_price = buy_data['ask']
- sell_price = sell_data['bid']
-
- if buy_price <= 0 or sell_price <= 0:
- return None
-
- # Calculate fees
- buy_fee = self.exchange_manager.get_exchange_fee(buy_exchange)
- sell_fee = self.exchange_manager.get_exchange_fee(sell_exchange)
-
- # Calculate profit after fees
- effective_buy = buy_price * (1 + buy_fee)
- effective_sell = sell_price * (1 - sell_fee)
-
- profit_pct = ((effective_sell - effective_buy) / effective_buy) * 100
-
- # Estimate USD profit (using sell price as reference)
- volume = min(buy_data['volume'], sell_data['volume']) * 0.1 # Use 10% of available volume
- profit_usd = (effective_sell - effective_buy) * volume
-
- min_profit = getattr(self.settings, 'CROSS_EXCHANGE_MIN_PROFIT_USD', 5.0)
-
- if profit_pct > 0 and profit_usd >= min_profit:
- opportunity = ArbitrageOpportunity(
- strategy='cross_exchange',
- exchange_buy=buy_exchange,
- exchange_sell=sell_exchange,
- symbol=symbol,
- buy_price=buy_price,
- sell_price=sell_price,
- profit_percentage=profit_pct,
- profit_usd=profit_usd,
- volume_available=volume,
- timestamp=datetime.now(),
- metadata={
- 'buy_fee': buy_fee,
- 'sell_fee': sell_fee,
- 'effective_buy': effective_buy,
- 'effective_sell': effective_sell
- }
- )
- self.opportunities_history.append(opportunity)
- return opportunity
-
- return None
-
- def execute(self, opportunity: ArbitrageOpportunity, amount: float) -> Dict[str, Any]:
- """
- Execute cross-exchange arbitrage.
-
- Args:
- opportunity: The arbitrage opportunity to execute
- amount: Amount to trade
-
- Returns:
- Execution result with profit/loss details
- """
- result = {
- 'success': False,
- 'buy_order': None,
- 'sell_order': None,
- 'profit_usd': 0,
- 'error': None
- }
-
- try:
- # Check if we have balance on buy exchange
- quote_currency = opportunity.symbol.split('/')[1]
- balance = self.exchange_manager.get_balance(
- opportunity.exchange_buy, quote_currency
- )
-
- required_balance = amount * opportunity.buy_price * 1.01 # 1% buffer
- if balance < required_balance:
- result['error'] = f"Insufficient balance on {opportunity.exchange_buy}"
- return result
-
- # Execute buy order
- buy_order = self.exchange_manager.create_market_order(
- opportunity.exchange_buy,
- opportunity.symbol,
- 'buy',
- amount
- )
-
- if not buy_order:
- result['error'] = "Failed to execute buy order"
- return result
-
- result['buy_order'] = buy_order
-
- # Execute sell order
- sell_order = self.exchange_manager.create_market_order(
- opportunity.exchange_sell,
- opportunity.symbol,
- 'sell',
- amount
- )
-
- if not sell_order:
- result['error'] = "Failed to execute sell order"
- # Attempt to reverse the buy
- self.exchange_manager.create_market_order(
- opportunity.exchange_buy,
- opportunity.symbol,
- 'sell',
- amount
- )
- return result
-
- result['sell_order'] = sell_order
- result['success'] = True
-
- # Calculate actual profit
- buy_cost = buy_order.get('cost', amount * opportunity.buy_price)
- sell_revenue = sell_order.get('cost', amount * opportunity.sell_price)
- result['profit_usd'] = sell_revenue - buy_cost
-
- # Update tracker
- self.profit_tracker['trades'] += 1
- if result['profit_usd'] > 0:
- self.profit_tracker['successful'] += 1
- self.profit_tracker['total_profit_usd'] += result['profit_usd']
-
- self.logger.info(f"Cross-exchange arb executed: {result['profit_usd']:.2f} USD profit")
-
- except Exception as e:
- result['error'] = str(e)
- self.logger.error(f"Cross-exchange arbitrage execution failed: {e}")
-
- return result
-
- def get_stats(self) -> Dict[str, Any]:
- """Get strategy statistics."""
- success_rate = (self.profit_tracker['successful'] / self.profit_tracker['trades'] * 100
- if self.profit_tracker['trades'] > 0 else 0)
- return {
- **self.profit_tracker,
- 'success_rate': success_rate,
- 'opportunities_found': len(self.opportunities_history)
- }
-
-
-class FuturesSpotArbitrage:
- """
- Futures-Spot Arbitrage Strategy
- Profit from the basis (price difference) between futures and spot markets.
- """
-
- def __init__(self, exchange_manager, settings):
- self.exchange_manager = exchange_manager
- self.settings = settings
- self.logger = logging.getLogger(__name__)
- self.positions = {}
- self.profit_tracker = {'total_profit_usd': 0, 'trades': 0}
-
- def calculate_basis(self, exchange: str, symbol: str) -> Optional[Dict[str, float]]:
- """
- Calculate the basis (futures - spot price difference).
-
- Args:
- exchange: Exchange name
- symbol: Base trading pair (e.g., 'BTC/USDT')
-
- Returns:
- Basis information or None
- """
- try:
- # Get spot price
- spot_ticker = self.exchange_manager.get_ticker(exchange, symbol)
- if not spot_ticker:
- return None
-
- spot_price = (spot_ticker['bid'] + spot_ticker['ask']) / 2
-
- # Get futures price (assuming perpetual)
- # Note: This requires exchange to support futures
- futures_symbol = symbol.replace('/', '') + ':USDT' # Binance perpetual format
-
- exchange_instance = self.exchange_manager.get_exchange(exchange)
- if not exchange_instance:
- return None
-
- try:
- futures_ticker = exchange_instance['instance'].fetch_ticker(futures_symbol)
- futures_price = (futures_ticker['bid'] + futures_ticker['ask']) / 2
- except:
- # Exchange might not support futures
- return None
-
- basis = futures_price - spot_price
- basis_pct = (basis / spot_price) * 100
-
- # Annualized basis (assuming quarterly expiry)
- days_to_expiry = 90 # Simplified
- annualized_basis = basis_pct * (365 / days_to_expiry)
-
- return {
- 'spot_price': spot_price,
- 'futures_price': futures_price,
- 'basis': basis,
- 'basis_pct': basis_pct,
- 'annualized_basis': annualized_basis,
- 'timestamp': datetime.now()
- }
-
- except Exception as e:
- self.logger.debug(f"Error calculating basis for {symbol}: {e}")
- return None
-
- def scan_opportunities(self, exchange: str, symbols: List[str]) -> List[Dict[str, Any]]:
- """Scan for futures-spot arbitrage opportunities."""
- opportunities = []
-
- min_basis = getattr(self.settings, 'MIN_BASIS_PERCENTAGE', 0.5)
-
- for symbol in symbols:
- basis_info = self.calculate_basis(exchange, symbol)
- if basis_info and abs(basis_info['basis_pct']) > min_basis:
- opportunities.append({
- 'symbol': symbol,
- 'direction': 'short_futures' if basis_info['basis'] > 0 else 'long_futures',
- **basis_info
- })
-
- return sorted(opportunities, key=lambda x: abs(x['basis_pct']), reverse=True)
-
- def execute_cash_and_carry(self, exchange: str, symbol: str, amount: float) -> Dict[str, Any]:
- """
- Execute cash-and-carry arbitrage (when futures > spot).
- Buy spot, short futures, wait for convergence.
- """
- result = {'success': False, 'error': None}
-
- try:
- basis_info = self.calculate_basis(exchange, symbol)
- if not basis_info or basis_info['basis'] <= 0:
- result['error'] = "No positive basis opportunity"
- return result
-
- # Buy spot
- spot_order = self.exchange_manager.create_market_order(
- exchange, symbol, 'buy', amount
- )
-
- if not spot_order:
- result['error'] = "Failed to buy spot"
- return result
-
- # Short futures (This is simplified - actual implementation needs futures API)
- # futures_order = self.exchange_manager.create_futures_order(...)
-
- self.positions[symbol] = {
- 'type': 'cash_and_carry',
- 'spot_amount': amount,
- 'spot_price': basis_info['spot_price'],
- 'futures_price': basis_info['futures_price'],
- 'entry_time': datetime.now(),
- 'expected_profit_pct': basis_info['basis_pct']
- }
-
- result['success'] = True
- result['position'] = self.positions[symbol]
- self.logger.info(f"Cash-and-carry position opened: {symbol}, expected {basis_info['basis_pct']:.2f}% profit")
-
- except Exception as e:
- result['error'] = str(e)
- self.logger.error(f"Cash-and-carry execution failed: {e}")
-
- return result
-
-
-class FundingRateArbitrage:
- """
- Funding Rate Arbitrage Strategy
- Collect funding rate payments on perpetual futures while hedging with spot.
- """
-
- def __init__(self, exchange_manager, settings):
- self.exchange_manager = exchange_manager
- self.settings = settings
- self.logger = logging.getLogger(__name__)
- self.positions = {}
- self.funding_collected = 0
-
- def get_funding_rate(self, exchange: str, symbol: str) -> Optional[Dict[str, Any]]:
- """Get current funding rate for a perpetual contract."""
- try:
- exchange_instance = self.exchange_manager.get_exchange(exchange)
- if not exchange_instance:
- return None
-
- # Fetch funding rate (exchange-specific)
- perp_symbol = symbol.replace('/', '') + ':USDT'
-
- try:
- funding_info = exchange_instance['instance'].fetch_funding_rate(perp_symbol)
- return {
- 'symbol': symbol,
- 'funding_rate': funding_info.get('fundingRate', 0),
- 'next_funding_time': funding_info.get('fundingTimestamp'),
- 'annualized': funding_info.get('fundingRate', 0) * 3 * 365 * 100 # 8h period
- }
- except:
- return None
-
- except Exception as e:
- self.logger.debug(f"Error getting funding rate: {e}")
- return None
-
- def scan_opportunities(self, exchange: str, symbols: List[str]) -> List[Dict[str, Any]]:
- """Find high funding rate opportunities."""
- opportunities = []
- min_rate = getattr(self.settings, 'MIN_FUNDING_RATE', 0.01) # 0.01% per period
-
- for symbol in symbols:
- funding = self.get_funding_rate(exchange, symbol)
- if funding and abs(funding['funding_rate']) > min_rate:
- opportunities.append({
- **funding,
- 'direction': 'short' if funding['funding_rate'] > 0 else 'long',
- 'expected_return_8h': abs(funding['funding_rate']) * 100
- })
-
- return sorted(opportunities, key=lambda x: abs(x['funding_rate']), reverse=True)
-
- def execute_delta_neutral(self, exchange: str, symbol: str, amount: float) -> Dict[str, Any]:
- """
- Execute delta-neutral funding rate strategy.
- If funding is positive: short perp, long spot (collect funding)
- If funding is negative: long perp, short spot (collect funding)
- """
- result = {'success': False, 'error': None}
-
- funding = self.get_funding_rate(exchange, symbol)
- if not funding:
- result['error'] = "Could not fetch funding rate"
- return result
-
- try:
- if funding['funding_rate'] > 0:
- # Short perp, long spot
- spot_order = self.exchange_manager.create_market_order(
- exchange, symbol, 'buy', amount
- )
- # perp_order = short perp (requires futures API)
- position_type = 'short_perp_long_spot'
- else:
- # Long perp, short spot (or skip if no margin shorting)
- position_type = 'long_perp_short_spot'
-
- self.positions[symbol] = {
- 'type': position_type,
- 'amount': amount,
- 'funding_rate': funding['funding_rate'],
- 'entry_time': datetime.now()
- }
-
- result['success'] = True
- result['position'] = self.positions[symbol]
- self.logger.info(f"Funding rate position opened: {symbol}, rate: {funding['funding_rate']*100:.4f}%")
-
- except Exception as e:
- result['error'] = str(e)
-
- return result
-
-
-class GridTradingStrategy:
- """
- Grid Trading Strategy
- Place buy orders below current price and sell orders above.
- Profit from price oscillations within a range.
- """
-
- def __init__(self, exchange_manager, settings):
- self.exchange_manager = exchange_manager
- self.settings = settings
- self.logger = logging.getLogger(__name__)
- self.grids = {}
- self.profit_tracker = {'total_profit_usd': 0, 'trades': 0}
-
- def create_grid(self, exchange: str, symbol: str, lower_price: float,
- upper_price: float, num_grids: int, amount_per_grid: float) -> Dict[str, Any]:
- """
- Create a trading grid.
-
- Args:
- exchange: Exchange to trade on
- symbol: Trading pair
- lower_price: Lower bound of the grid
- upper_price: Upper bound of the grid
- num_grids: Number of grid levels
- amount_per_grid: Amount to trade at each grid level
- """
- grid_spacing = (upper_price - lower_price) / (num_grids - 1)
-
- grid_levels = []
- for i in range(num_grids):
- price = lower_price + (i * grid_spacing)
- grid_levels.append({
- 'price': price,
- 'buy_order': None,
- 'sell_order': None,
- 'filled': False
- })
-
- # Get current price
- ticker = self.exchange_manager.get_ticker(exchange, symbol)
- if not ticker:
- return {'success': False, 'error': 'Could not get ticker'}
-
- current_price = (ticker['bid'] + ticker['ask']) / 2
-
- grid = {
- 'exchange': exchange,
- 'symbol': symbol,
- 'lower_price': lower_price,
- 'upper_price': upper_price,
- 'num_grids': num_grids,
- 'grid_spacing': grid_spacing,
- 'amount_per_grid': amount_per_grid,
- 'levels': grid_levels,
- 'current_price': current_price,
- 'created_at': datetime.now(),
- 'active': True
- }
-
- grid_id = f"{exchange}_{symbol}_{datetime.now().timestamp()}"
- self.grids[grid_id] = grid
-
- # Place initial orders
- self._place_grid_orders(grid_id)
-
- return {'success': True, 'grid_id': grid_id, 'grid': grid}
-
- def _place_grid_orders(self, grid_id: str):
- """Place buy/sell orders for grid levels."""
- grid = self.grids.get(grid_id)
- if not grid or not grid['active']:
- return
-
- current_price = grid['current_price']
-
- for level in grid['levels']:
- if level['filled']:
- continue
-
- if level['price'] < current_price:
- # Place buy order below current price
- order = self.exchange_manager.create_limit_order(
- grid['exchange'],
- grid['symbol'],
- 'buy',
- grid['amount_per_grid'],
- level['price']
- )
- level['buy_order'] = order
- else:
- # Place sell order above current price
- order = self.exchange_manager.create_limit_order(
- grid['exchange'],
- grid['symbol'],
- 'sell',
- grid['amount_per_grid'],
- level['price']
- )
- level['sell_order'] = order
-
- def check_and_update_grid(self, grid_id: str) -> Dict[str, Any]:
- """Check filled orders and place new ones."""
- grid = self.grids.get(grid_id)
- if not grid or not grid['active']:
- return {'updated': False}
-
- fills = []
-
- # Check each level for filled orders
- for level in grid['levels']:
- # Check buy orders
- if level['buy_order']:
- order_id = level['buy_order'].get('id')
- # In real implementation, fetch order status
- # If filled, place corresponding sell order
-
- # Check sell orders
- if level['sell_order']:
- order_id = level['sell_order'].get('id')
- # If filled, place corresponding buy order
-
- return {'updated': True, 'fills': fills}
-
- def close_grid(self, grid_id: str) -> Dict[str, Any]:
- """Close a grid and cancel all pending orders."""
- grid = self.grids.get(grid_id)
- if not grid:
- return {'success': False, 'error': 'Grid not found'}
-
- grid['active'] = False
-
- # Cancel all pending orders
- for level in grid['levels']:
- if level['buy_order']:
- self.exchange_manager.cancel_order(
- grid['exchange'],
- level['buy_order'].get('id'),
- grid['symbol']
- )
- if level['sell_order']:
- self.exchange_manager.cancel_order(
- grid['exchange'],
- level['sell_order'].get('id'),
- grid['symbol']
- )
-
- return {'success': True, 'grid_id': grid_id}
-
- def get_grid_stats(self, grid_id: str) -> Dict[str, Any]:
- """Get statistics for a grid."""
- grid = self.grids.get(grid_id)
- if not grid:
- return {}
-
- filled_levels = sum(1 for l in grid['levels'] if l['filled'])
- return {
- 'grid_id': grid_id,
- 'symbol': grid['symbol'],
- 'active': grid['active'],
- 'filled_levels': filled_levels,
- 'total_levels': grid['num_grids'],
- 'created_at': grid['created_at'].isoformat()
- }
-
-
-class MarketMakingStrategy:
- """
- Market Making Strategy
- Provide liquidity by quoting both bid and ask prices.
- Profit from the bid-ask spread.
- """
-
- def __init__(self, exchange_manager, settings):
- self.exchange_manager = exchange_manager
- self.settings = settings
- self.logger = logging.getLogger(__name__)
- self.active_quotes = {}
- self.inventory = {}
- self.profit_tracker = {'total_profit_usd': 0, 'trades': 0, 'spread_earned': 0}
-
- def calculate_optimal_spread(self, exchange: str, symbol: str) -> float:
- """
- Calculate optimal spread based on volatility and inventory.
- """
- # Get recent price data for volatility calculation
- ticker = self.exchange_manager.get_ticker(exchange, symbol)
- if not ticker:
- return 0.002 # Default 0.2% spread
-
- # Get orderbook for market spread
- orderbook = self.exchange_manager.get_order_book(exchange, symbol, limit=5)
- if not orderbook:
- return 0.002
-
- best_bid = orderbook['bids'][0][0] if orderbook['bids'] else 0
- best_ask = orderbook['asks'][0][0] if orderbook['asks'] else 0
-
- if best_bid <= 0 or best_ask <= 0:
- return 0.002
-
- market_spread = (best_ask - best_bid) / best_bid
-
- # Adjust spread based on inventory
- base_currency = symbol.split('/')[0]
- inventory_skew = self.inventory.get(base_currency, 0)
-
- # Wider spread when inventory is skewed
- inventory_adjustment = abs(inventory_skew) * 0.0001
-
- # Minimum spread should cover fees
- fee = self.exchange_manager.get_exchange_fee(exchange)
- min_spread = fee * 2.5 # Need to cover both sides plus profit
-
- optimal_spread = max(market_spread * 0.8, min_spread) + inventory_adjustment
-
- return optimal_spread
-
- def quote(self, exchange: str, symbol: str, amount: float) -> Dict[str, Any]:
- """
- Place bid and ask quotes.
-
- Args:
- exchange: Exchange to quote on
- symbol: Trading pair
- amount: Amount to quote on each side
- """
- ticker = self.exchange_manager.get_ticker(exchange, symbol)
- if not ticker:
- return {'success': False, 'error': 'Could not get ticker'}
-
- mid_price = (ticker['bid'] + ticker['ask']) / 2
- spread = self.calculate_optimal_spread(exchange, symbol)
-
- bid_price = mid_price * (1 - spread / 2)
- ask_price = mid_price * (1 + spread / 2)
-
- # Cancel existing quotes
- quote_id = f"{exchange}_{symbol}"
- if quote_id in self.active_quotes:
- self._cancel_quotes(quote_id)
-
- # Place new quotes
- bid_order = self.exchange_manager.create_limit_order(
- exchange, symbol, 'buy', amount, bid_price
- )
-
- ask_order = self.exchange_manager.create_limit_order(
- exchange, symbol, 'sell', amount, ask_price
- )
-
- self.active_quotes[quote_id] = {
- 'exchange': exchange,
- 'symbol': symbol,
- 'bid_order': bid_order,
- 'ask_order': ask_order,
- 'bid_price': bid_price,
- 'ask_price': ask_price,
- 'spread': spread,
- 'amount': amount,
- 'timestamp': datetime.now()
- }
-
- self.logger.info(f"Market making quotes placed: {symbol} bid={bid_price:.6f} ask={ask_price:.6f}")
-
- return {
- 'success': True,
- 'quote_id': quote_id,
- 'bid_price': bid_price,
- 'ask_price': ask_price,
- 'spread_pct': spread * 100
- }
-
- def _cancel_quotes(self, quote_id: str):
- """Cancel existing quotes."""
- quote = self.active_quotes.get(quote_id)
- if not quote:
- return
-
- if quote['bid_order']:
- self.exchange_manager.cancel_order(
- quote['exchange'],
- quote['bid_order'].get('id'),
- quote['symbol']
- )
- if quote['ask_order']:
- self.exchange_manager.cancel_order(
- quote['exchange'],
- quote['ask_order'].get('id'),
- quote['symbol']
- )
-
- def update_inventory(self, symbol: str, amount: float, side: str):
- """Update inventory after a fill."""
- base_currency = symbol.split('/')[0]
- current = self.inventory.get(base_currency, 0)
-
- if side == 'buy':
- self.inventory[base_currency] = current + amount
- else:
- self.inventory[base_currency] = current - amount
-
- def get_stats(self) -> Dict[str, Any]:
- """Get market making statistics."""
- return {
- **self.profit_tracker,
- 'active_quotes': len(self.active_quotes),
- 'inventory': self.inventory.copy()
- }
-
-
-class DEXCEXArbitrage:
- """
- DEX-CEX Arbitrage Strategy
- Profit from price differences between decentralized and centralized exchanges.
- """
-
- def __init__(self, exchange_manager, settings):
- self.exchange_manager = exchange_manager
- self.settings = settings
- self.logger = logging.getLogger(__name__)
- self.web3 = None
-
- if WEB3_AVAILABLE:
- rpc_url = getattr(settings, 'WEB3_RPC_URL', 'https://eth-mainnet.g.alchemy.com/v2/demo')
- self.web3 = Web3(Web3.HTTPProvider(rpc_url))
-
- # DEX router addresses
- self.routers = {
- 'uniswap_v2': '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D',
- 'uniswap_v3': '0xE592427A0AEce92De3Edee1F18E0157C05861564',
- 'sushiswap': '0xd9e1cE17f2641f24aE83637ab66a2cca9C378B9F'
- }
-
- self.profit_tracker = {'total_profit_usd': 0, 'trades': 0}
-
- def get_dex_price(self, dex: str, token_in: str, token_out: str, amount: float) -> Optional[float]:
- """
- Get price quote from a DEX.
-
- Args:
- dex: DEX name (uniswap_v2, sushiswap, etc.)
- token_in: Input token address
- token_out: Output token address
- amount: Amount to swap
-
- Returns:
- Price or None
- """
- if not self.web3 or not self.web3.is_connected():
- return None
-
- try:
- # This is simplified - actual implementation needs ABI and proper contract calls
- router_address = self.routers.get(dex)
- if not router_address:
- return None
-
- # In real implementation:
- # 1. Load router contract ABI
- # 2. Call getAmountsOut or similar
- # 3. Return price
-
- # Placeholder
- return None
-
- except Exception as e:
- self.logger.debug(f"Error getting DEX price: {e}")
- return None
-
- def scan_opportunities(self, cex_exchange: str, symbol: str) -> Optional[Dict[str, Any]]:
- """
- Scan for DEX-CEX arbitrage opportunities.
- """
- if not WEB3_AVAILABLE or not self.web3:
- return None
-
- # Get CEX price
- cex_ticker = self.exchange_manager.get_ticker(cex_exchange, symbol)
- if not cex_ticker:
- return None
-
- cex_bid = cex_ticker['bid']
- cex_ask = cex_ticker['ask']
-
- # Token addresses (would need mapping)
- # This is simplified
-
- opportunities = []
-
- for dex in self.routers.keys():
- # In real implementation, get DEX prices and compare
- pass
-
- return None
-
- def execute_flash_swap(self, dex: str, cex: str, symbol: str, amount: float) -> Dict[str, Any]:
- """
- Execute flash swap arbitrage.
- Uses flash loans for zero-capital arbitrage.
- """
- result = {'success': False, 'error': 'Flash swap not implemented'}
-
- # Flash swap implementation would:
- # 1. Borrow tokens via flash loan
- # 2. Swap on one venue
- # 3. Swap back on other venue
- # 4. Repay flash loan + fee
- # 5. Keep profit
-
- return result
-
-
-class StrategyOrchestrator:
- """
- Orchestrates multiple arbitrage strategies.
- """
-
- def __init__(self, exchange_manager, settings):
- self.exchange_manager = exchange_manager
- self.settings = settings
- self.logger = logging.getLogger(__name__)
-
- # Initialize all strategies
- self.strategies = {
- 'cross_exchange': CrossExchangeArbitrage(exchange_manager, settings),
- 'futures_spot': FuturesSpotArbitrage(exchange_manager, settings),
- 'funding_rate': FundingRateArbitrage(exchange_manager, settings),
- 'grid_trading': GridTradingStrategy(exchange_manager, settings),
- 'market_making': MarketMakingStrategy(exchange_manager, settings),
- 'dex_cex': DEXCEXArbitrage(exchange_manager, settings)
- }
-
- self.active_strategies = set()
- self.running = False
-
- def enable_strategy(self, strategy_name: str):
- """Enable a strategy."""
- if strategy_name in self.strategies:
- self.active_strategies.add(strategy_name)
- self.logger.info(f"Strategy enabled: {strategy_name}")
-
- def disable_strategy(self, strategy_name: str):
- """Disable a strategy."""
- self.active_strategies.discard(strategy_name)
- self.logger.info(f"Strategy disabled: {strategy_name}")
-
- def run_scan_cycle(self, symbols: List[str]) -> List[ArbitrageOpportunity]:
- """Run a scan cycle across all active strategies."""
- opportunities = []
-
- for strategy_name in self.active_strategies:
- strategy = self.strategies.get(strategy_name)
- if not strategy:
- continue
-
- try:
- if strategy_name == 'cross_exchange':
- for symbol in symbols:
- opp = strategy.scan_opportunities(symbol)
- if opp:
- opportunities.append(opp)
-
- except Exception as e:
- self.logger.error(f"Error in {strategy_name} scan: {e}")
-
- return opportunities
-
- def get_all_stats(self) -> Dict[str, Any]:
- """Get statistics from all strategies."""
- stats = {}
- for name, strategy in self.strategies.items():
- if hasattr(strategy, 'get_stats'):
- stats[name] = strategy.get_stats()
- return stats
-
- def start(self, symbols: List[str], interval_seconds: float = 1.0):
- """Start the strategy orchestrator."""
- self.running = True
- self.logger.info("Strategy orchestrator started")
-
- while self.running:
- try:
- opportunities = self.run_scan_cycle(symbols)
-
- for opp in opportunities:
- self.logger.info(f"Opportunity found: {opp}")
- # Execute based on risk management rules
-
- time.sleep(interval_seconds)
-
- except Exception as e:
- self.logger.error(f"Error in scan cycle: {e}")
- time.sleep(5) # Back off on error
-
- def stop(self):
- """Stop the strategy orchestrator."""
- self.running = False
- self.logger.info("Strategy orchestrator stopped")
-
-
-# Test function for paper trading
-def test_paper_trading():
- """Test strategies in paper trading mode."""
- print("=" * 60)
- print("Paper Trading Test - Arbitrage Strategies")
- print("=" * 60)
-
- # Create mock settings
- class MockSettings:
- CROSS_EXCHANGE_MIN_PROFIT_USD = 5.0
- MIN_BASIS_PERCENTAGE = 0.5
- MIN_FUNDING_RATE = 0.01
- WEB3_RPC_URL = 'https://eth-mainnet.g.alchemy.com/v2/demo'
-
- print("\n✓ CrossExchangeArbitrage initialized")
- print("✓ FuturesSpotArbitrage initialized")
- print("✓ FundingRateArbitrage initialized")
- print("✓ GridTradingStrategy initialized")
- print("✓ MarketMakingStrategy initialized")
- print("✓ DEXCEXArbitrage initialized")
- print("✓ StrategyOrchestrator initialized")
-
- print("\n" + "=" * 60)
- print("All strategies loaded successfully!")
- print("Configure API keys in data/secrets.py to enable live trading.")
- print("=" * 60)
-
-
-if __name__ == "__main__":
- test_paper_trading()
diff --git a/src/strategy_runner.py b/src/strategy_runner.py
deleted file mode 100644
index ddef87d..0000000
--- a/src/strategy_runner.py
+++ /dev/null
@@ -1,332 +0,0 @@
-"""
-Strategy Runner - Main entry point for running multiple arbitrage strategies.
-"""
-
-import time
-import logging
-import threading
-import signal
-import sys
-from datetime import datetime
-from typing import Dict, List, Optional, Any
-import json
-
-from src.strategies import (
- StrategyOrchestrator,
- CrossExchangeArbitrage,
- FuturesSpotArbitrage,
- FundingRateArbitrage,
- GridTradingStrategy,
- MarketMakingStrategy,
- DEXCEXArbitrage
-)
-from src.order_executor import OrderExecutor
-from src.exchange_manager import ExchangeManager
-from data import settings, tokens
-
-
-class StrategyRunner:
- """
- Main runner for executing multiple arbitrage strategies.
- """
-
- def __init__(self):
- self.logger = logging.getLogger(__name__)
- self._setup_logging()
-
- # Initialize components
- self.exchange_manager = ExchangeManager()
- self.order_executor = OrderExecutor(self.exchange_manager)
- self.orchestrator = StrategyOrchestrator(self.exchange_manager, settings)
-
- # State
- self.running = False
- self.start_time = None
- self.stats = {
- 'total_opportunities': 0,
- 'executed_trades': 0,
- 'total_profit_usd': 0,
- 'start_balance': {}
- }
-
- # Signal handling
- signal.signal(signal.SIGINT, self._signal_handler)
- signal.signal(signal.SIGTERM, self._signal_handler)
-
- def _setup_logging(self):
- """Configure logging."""
- logging.basicConfig(
- level=logging.INFO,
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
- handlers=[
- logging.StreamHandler(),
- logging.FileHandler('strategy_runner.log')
- ]
- )
-
- def _signal_handler(self, signum, frame):
- """Handle shutdown signals."""
- self.logger.info("Shutdown signal received")
- self.stop()
-
- def enable_strategies(self, strategies: List[str]):
- """Enable specified strategies."""
- for strategy in strategies:
- self.orchestrator.enable_strategy(strategy)
-
- def get_trading_symbols(self) -> List[str]:
- """Get list of trading symbols to scan."""
- symbols = []
-
- for exchange in self.exchange_manager.get_enabled_exchanges():
- exchange_tokens = self.exchange_manager.get_exchange_tokens(exchange)
- for token in exchange_tokens:
- symbol = f"{token}/{settings.DEFAULT_QUOTE_CURRENCY}"
- if symbol not in symbols:
- symbols.append(symbol)
-
- return symbols
-
- def record_start_balances(self):
- """Record starting balances for P&L tracking."""
- for exchange in self.exchange_manager.get_enabled_exchanges():
- for currency in [settings.DEFAULT_BASE_CURRENCY,
- settings.DEFAULT_QUOTE_CURRENCY, 'USDT']:
- balance = self.exchange_manager.get_balance(exchange, currency)
- key = f"{exchange}_{currency}"
- self.stats['start_balance'][key] = balance
-
- def calculate_pnl(self) -> Dict[str, float]:
- """Calculate profit and loss since start."""
- pnl = {}
-
- for exchange in self.exchange_manager.get_enabled_exchanges():
- for currency in [settings.DEFAULT_BASE_CURRENCY,
- settings.DEFAULT_QUOTE_CURRENCY, 'USDT']:
- key = f"{exchange}_{currency}"
- start = self.stats['start_balance'].get(key, 0)
- current = self.exchange_manager.get_balance(exchange, currency)
- pnl[key] = current - start
-
- return pnl
-
- def run_scan_cycle(self, symbols: List[str]):
- """Run one scanning cycle."""
- opportunities = self.orchestrator.run_scan_cycle(symbols)
-
- for opp in opportunities:
- self.stats['total_opportunities'] += 1
-
- # Risk check
- if not self._passes_risk_check(opp):
- self.logger.info(f"Opportunity skipped due to risk: {opp}")
- continue
-
- # Execute
- result = self._execute_opportunity(opp)
-
- if result.get('success'):
- self.stats['executed_trades'] += 1
- self.stats['total_profit_usd'] += result.get('profit_usd', 0)
-
- def _passes_risk_check(self, opportunity) -> bool:
- """Check if opportunity passes risk management rules."""
- # Check circuit breaker
- if hasattr(settings, 'ENABLE_CIRCUIT_BREAKER') and settings.ENABLE_CIRCUIT_BREAKER:
- # Check consecutive losses
- # This would be tracked in actual implementation
- pass
-
- # Check daily loss limit
- if hasattr(settings, 'MAX_DAILY_LOSS_PERCENTAGE'):
- pnl = self.calculate_pnl()
- # Check if daily loss exceeded
- pass
-
- # Check position size
- if opportunity.profit_usd > 1000: # Example threshold
- # Large trade, require extra validation
- pass
-
- return True
-
- def _execute_opportunity(self, opportunity) -> Dict[str, Any]:
- """Execute an arbitrage opportunity."""
- try:
- strategy_name = opportunity.strategy
-
- if strategy_name == 'cross_exchange':
- strategy = self.orchestrator.strategies['cross_exchange']
- # Calculate position size
- amount = self._calculate_position_size(opportunity)
- result = strategy.execute(opportunity, amount)
- return result
-
- # Add other strategy executions here
-
- except Exception as e:
- self.logger.error(f"Execution error: {e}")
- return {'success': False, 'error': str(e)}
-
- return {'success': False}
-
- def _calculate_position_size(self, opportunity) -> float:
- """Calculate optimal position size."""
- base_currency = opportunity.symbol.split('/')[0]
- balance = self.exchange_manager.get_balance(
- opportunity.exchange_buy, base_currency
- )
-
- max_size = balance * settings.MAX_POSITION_SIZE
-
- # Adjust based on confidence
- if opportunity.profit_percentage > 1.0:
- # High profit opportunity, use larger size
- size = max_size
- elif opportunity.profit_percentage > 0.5:
- size = max_size * 0.7
- else:
- size = max_size * 0.5
-
- return min(size, opportunity.volume_available * 0.5)
-
- def print_status(self):
- """Print current status."""
- runtime = (datetime.now() - self.start_time).total_seconds() if self.start_time else 0
-
- status = f"""
-{'='*60}
-Strategy Runner Status
-{'='*60}
-Runtime: {runtime:.0f} seconds
-Opportunities Found: {self.stats['total_opportunities']}
-Trades Executed: {self.stats['executed_trades']}
-Total Profit: ${self.stats['total_profit_usd']:.2f}
-
-Active Strategies:
-"""
- for strategy in self.orchestrator.active_strategies:
- status += f" - {strategy}\n"
-
- status += f"{'='*60}"
- print(status)
-
- def start(self, strategies: List[str] = None, paper_mode: bool = True):
- """
- Start the strategy runner.
-
- Args:
- strategies: List of strategies to enable
- paper_mode: If True, don't execute real trades
- """
- self.logger.info("=" * 60)
- self.logger.info("Strategy Runner Starting")
- self.logger.info("=" * 60)
-
- if paper_mode:
- self.logger.info("Running in PAPER TRADING mode - no real trades")
-
- # Enable strategies
- if strategies is None:
- strategies = ['cross_exchange', 'grid_trading', 'market_making']
-
- self.enable_strategies(strategies)
-
- # Record starting balances
- self.record_start_balances()
-
- self.running = True
- self.start_time = datetime.now()
-
- # Get symbols to scan
- symbols = self.get_trading_symbols()
- self.logger.info(f"Scanning {len(symbols)} symbols")
-
- # Main loop
- scan_interval = 1.0 # seconds
- status_interval = 60 # seconds
- last_status = time.time()
-
- while self.running:
- try:
- self.run_scan_cycle(symbols)
-
- # Print status periodically
- if time.time() - last_status > status_interval:
- self.print_status()
- last_status = time.time()
-
- time.sleep(scan_interval)
-
- except Exception as e:
- self.logger.error(f"Error in main loop: {e}")
- time.sleep(5) # Back off
-
- self.logger.info("Strategy Runner stopped")
- self.print_status()
-
- def stop(self):
- """Stop the strategy runner."""
- self.logger.info("Stopping Strategy Runner...")
- self.running = False
- self.orchestrator.stop()
-
- def get_stats(self) -> Dict[str, Any]:
- """Get runner statistics."""
- return {
- **self.stats,
- 'strategy_stats': self.orchestrator.get_all_stats(),
- 'execution_stats': self.order_executor.get_execution_stats(),
- 'pnl': self.calculate_pnl()
- }
-
-
-def main():
- """Main entry point."""
- print("=" * 60)
- print("Multi-Strategy Arbitrage Bot")
- print("=" * 60)
-
- # Configuration
- paper_mode = getattr(settings, 'PAPER_TRADING', True)
-
- strategies = [
- 'cross_exchange',
- 'grid_trading',
- 'market_making'
- ]
-
- # Optional strategies if configured
- if getattr(settings, 'ENABLE_FUTURES_ARBITRAGE', False):
- strategies.append('futures_spot')
- strategies.append('funding_rate')
-
- if getattr(settings, 'ENABLE_DEX_ARBITRAGE', False):
- strategies.append('dex_cex')
-
- print(f"\nEnabled Strategies: {', '.join(strategies)}")
- print(f"Paper Trading: {'Yes' if paper_mode else 'No - REAL TRADING'}")
- print(f"Base Currency: {settings.DEFAULT_BASE_CURRENCY}")
- print(f"Quote Currency: {settings.DEFAULT_QUOTE_CURRENCY}")
- print("=" * 60)
-
- if not paper_mode:
- print("\n⚠️ WARNING: Real trading mode enabled!")
- print("Press Ctrl+C to cancel within 5 seconds...")
- time.sleep(5)
-
- runner = StrategyRunner()
-
- try:
- runner.start(strategies=strategies, paper_mode=paper_mode)
- except KeyboardInterrupt:
- runner.stop()
-
- # Print final stats
- print("\nFinal Statistics:")
- stats = runner.get_stats()
- print(json.dumps(stats, indent=2, default=str))
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/subscription.py b/src/subscription.py
deleted file mode 100644
index 8549f01..0000000
--- a/src/subscription.py
+++ /dev/null
@@ -1,149 +0,0 @@
-"""
-Subscription and licensing system for monetization
-"""
-import json
-import os
-from datetime import datetime, timedelta
-from data import settings
-
-class SubscriptionManager:
- def __init__(self):
- self.license_file = 'license.json'
- self.load_license()
-
- def load_license(self):
- """Load license information"""
- self.license_data = {
- 'tier': 'free', # free, basic, premium, enterprise
- 'expires_at': None,
- 'features': {
- 'telegram_notifications': False,
- 'api_access': False,
- 'advanced_analytics': False,
- 'multi_exchange': False,
- 'custom_strategies': False
- }
- }
-
- if os.path.exists(self.license_file):
- try:
- with open(self.license_file, 'r') as f:
- loaded = json.load(f)
- self.license_data.update(loaded)
- except:
- pass
-
- def save_license(self):
- """Save license information"""
- try:
- with open(self.license_file, 'w') as f:
- json.dump(self.license_data, f, indent=2)
- except Exception as e:
- print(f"Error saving license: {e}")
-
- def is_valid(self):
- """Check if license is valid"""
- if self.license_data['expires_at']:
- try:
- expires = datetime.fromisoformat(self.license_data['expires_at'])
- return datetime.now() < expires
- except:
- return False
- return True
-
- def has_feature(self, feature):
- """Check if user has access to a feature"""
- if not self.is_valid():
- return False
- return self.license_data['features'].get(feature, False)
-
- def get_tier(self):
- """Get current subscription tier"""
- if not self.is_valid():
- return 'free'
- return self.license_data['tier']
-
- def activate_license(self, license_key, tier='premium', days=30):
- """Activate a license (for demo/testing)"""
- # In production, validate license_key against a server
- self.license_data['tier'] = tier
- self.license_data['expires_at'] = (datetime.now() + timedelta(days=days)).isoformat()
-
- # Set features based on tier
- if tier == 'free':
- self.license_data['features'] = {
- 'telegram_notifications': False,
- 'api_access': False,
- 'advanced_analytics': False,
- 'multi_exchange': False,
- 'custom_strategies': False
- }
- elif tier == 'basic':
- self.license_data['features'] = {
- 'telegram_notifications': True,
- 'api_access': False,
- 'advanced_analytics': False,
- 'multi_exchange': False,
- 'custom_strategies': False
- }
- elif tier == 'premium':
- self.license_data['features'] = {
- 'telegram_notifications': True,
- 'api_access': True,
- 'advanced_analytics': True,
- 'multi_exchange': False,
- 'custom_strategies': True
- }
- elif tier == 'enterprise':
- self.license_data['features'] = {
- 'telegram_notifications': True,
- 'api_access': True,
- 'advanced_analytics': True,
- 'multi_exchange': True,
- 'custom_strategies': True
- }
-
- self.save_license()
- return True
-
- def check_premium_features(self):
- """Update settings based on license"""
- if self.has_feature('telegram_notifications'):
- settings.PREMIUM_FEATURES_ENABLED = True
- else:
- settings.PREMIUM_FEATURES_ENABLED = False
-
- def get_days_remaining(self):
- """Get number of days remaining on subscription"""
- if not self.license_data['expires_at']:
- return None
- try:
- expires = datetime.fromisoformat(self.license_data['expires_at'])
- remaining = (expires - datetime.now()).days
- return max(0, remaining)
- except:
- return None
-
- def upgrade_tier(self, new_tier: str, days: int = 30):
- """Upgrade to a new tier"""
- valid_tiers = ['free', 'basic', 'premium', 'enterprise']
- if new_tier not in valid_tiers:
- return False
-
- current_tier_idx = valid_tiers.index(self.license_data['tier'])
- new_tier_idx = valid_tiers.index(new_tier)
-
- if new_tier_idx <= current_tier_idx:
- return False # Can only upgrade, not downgrade
-
- return self.activate_license(f'upgrade-{new_tier}', new_tier, days)
-
- def get_subscription_details(self):
- """Get detailed subscription information"""
- return {
- 'tier': self.get_tier(),
- 'features': self.license_data['features'],
- 'expires_at': self.license_data['expires_at'],
- 'is_valid': self.is_valid(),
- 'days_remaining': self.get_days_remaining()
- }
diff --git a/src/telegram_notifier.py b/src/telegram_notifier.py
deleted file mode 100644
index dc63929..0000000
--- a/src/telegram_notifier.py
+++ /dev/null
@@ -1,76 +0,0 @@
-"""
-Telegram notification system for arbitrage opportunities and trades
-"""
-from datetime import datetime
-import requests
-from data import secrets, settings
-
-class TelegramNotifier:
- def __init__(self):
- self.bot_token = getattr(secrets, 'TELEGRAM_BOT_TOKEN', None)
- self.chat_id = getattr(secrets, 'TELEGRAM_CHAT_ID', None)
- self.enabled = self.bot_token and self.chat_id
-
- def send_message(self, message):
- """Send a message to Telegram"""
- if not self.enabled:
- return False
-
- try:
- url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
- payload = {
- 'chat_id': self.chat_id,
- 'text': message,
- 'parse_mode': 'HTML'
- }
- response = requests.post(url, json=payload, timeout=5)
- return response.status_code == 200
- except Exception as e:
- print(f"Error sending Telegram message: {e}")
- return False
-
- def notify_opportunity(self, exchange, asset, profit_pct, direction):
- """Notify about an arbitrage opportunity"""
- if not settings.PREMIUM_FEATURES_ENABLED:
- return
-
- message = (
- f"🚀 Arbitrage Opportunity Found!\n\n"
- f"Exchange: {exchange}\n"
- f"Asset: {asset}\n"
- f"Direction: {direction}\n"
- f"Profit: {profit_pct:.4f}%\n"
- f"Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
- )
- self.send_message(message)
-
- def notify_trade_executed(self, exchange, asset, profit_eth, profit_usd, success):
- """Notify about trade execution"""
- if not settings.PREMIUM_FEATURES_ENABLED:
- return
-
- emoji = "✅" if success else "❌"
- message = (
- f"{emoji} Trade Executed\n\n"
- f"Exchange: {exchange}\n"
- f"Asset: {asset}\n"
- f"Profit: {profit_eth:.6f} ETH (${profit_usd:.2f})\n"
- f"Status: {'Success' if success else 'Failed'}\n"
- f"Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
- )
- self.send_message(message)
-
- def notify_daily_summary(self, stats):
- """Send daily profit summary"""
- if not settings.PREMIUM_FEATURES_ENABLED:
- return
-
- message = (
- f"📊 Daily Trading Summary\n\n"
- f"Total Profit: {stats['total_profit_eth']:.6f} ETH (${stats['total_profit_usd']:.2f})\n"
- f"Trades Executed: {stats['trades_executed']}\n"
- f"Successful Trades: {stats['successful_trades']}\n"
- f"Success Rate: {stats['success_rate']:.1f}%\n"
- f"Date: {datetime.now().strftime('%Y-%m-%d')}"
- )
- self.send_message(message)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..4b42128
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""Tests for the triangular arbitrage engine."""
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
new file mode 100644
index 0000000..071ecd9
--- /dev/null
+++ b/tests/test_discovery.py
@@ -0,0 +1,96 @@
+"""
+Tests for triangle discovery.
+
+These tests verify that the graph-based discovery algorithm correctly
+identifies triangular paths and assigns the right sides (BUY/SELL)
+for each leg.
+"""
+
+
+from triangular_arb.strategy.discovery import discover_triangles
+from triangular_arb.types import Pair, Side
+
+
+class TestTriangleDiscovery:
+ """Tests for the discover_triangles function."""
+
+ def test_discovers_simple_triangle(self) -> None:
+ """Given three pairs forming a cycle, should find exactly one triangle."""
+ pairs = [
+ Pair("LTC/ETH"),
+ Pair("LTC/BTC"),
+ Pair("ETH/BTC"),
+ ]
+ triangles = discover_triangles(pairs, base_currencies=["ETH"])
+
+ assert len(triangles) == 1
+ t = triangles[0]
+ assert t.base == "ETH"
+ assert {t.intermediate_a, t.intermediate_b} == {"LTC", "BTC"}
+
+ def test_no_triangle_when_pair_missing(self) -> None:
+ """If any edge is missing, no triangle should be found."""
+ pairs = [
+ Pair("LTC/ETH"),
+ Pair("LTC/BTC"),
+ # Missing ETH/BTC
+ ]
+ triangles = discover_triangles(pairs, base_currencies=["ETH"])
+ assert len(triangles) == 0
+
+ def test_multiple_triangles(self) -> None:
+ """Multiple base currencies should find more triangles."""
+ pairs = [
+ Pair("LTC/ETH"),
+ Pair("LTC/BTC"),
+ Pair("ETH/BTC"),
+ Pair("DOGE/ETH"),
+ Pair("DOGE/BTC"),
+ ]
+ triangles = discover_triangles(pairs, base_currencies=["ETH", "BTC"])
+
+ # Should find at least: ETH-LTC-BTC and ETH-DOGE-BTC
+ assert len(triangles) >= 2
+
+ def test_deduplication(self) -> None:
+ """Same triangle from different base currencies should not be duplicated."""
+ pairs = [
+ Pair("LTC/ETH"),
+ Pair("LTC/BTC"),
+ Pair("ETH/BTC"),
+ ]
+ # Even though we specify both ETH and BTC as bases,
+ # the triangle ETH-LTC-BTC should appear only once
+ triangles = discover_triangles(pairs, base_currencies=["ETH", "BTC"])
+ assert len(triangles) == 1
+
+ def test_correct_sides_assigned(self) -> None:
+ """Verify BUY/SELL sides are assigned correctly for each leg."""
+ pairs = [
+ Pair("LTC/ETH"),
+ Pair("LTC/BTC"),
+ Pair("ETH/BTC"),
+ ]
+ triangles = discover_triangles(pairs, base_currencies=["ETH"])
+ assert len(triangles) == 1
+
+ t = triangles[0]
+ # Every leg should have a valid side
+ for side in [t.leg1_side, t.leg2_side, t.leg3_side]:
+ assert side in (Side.BUY, Side.SELL)
+
+ def test_empty_pairs(self) -> None:
+ """Empty pair list should return empty triangles."""
+ triangles = discover_triangles([], base_currencies=["ETH"])
+ assert len(triangles) == 0
+
+ def test_invalid_pair_format_ignored(self) -> None:
+ """Malformed pairs should be silently skipped."""
+ pairs = [
+ Pair("INVALID"),
+ Pair("LTC/ETH"),
+ Pair("LTC/BTC"),
+ Pair("ETH/BTC"),
+ ]
+ triangles = discover_triangles(pairs, base_currencies=["ETH"])
+ assert len(triangles) == 1
diff --git a/tests/test_evaluator.py b/tests/test_evaluator.py
new file mode 100644
index 0000000..5159584
--- /dev/null
+++ b/tests/test_evaluator.py
@@ -0,0 +1,179 @@
+"""
+Tests for opportunity evaluation.
+
+These tests verify that the evaluator correctly computes profit/loss
+using Decimal arithmetic and properly handles edge cases like
+empty order books and stale data.
+"""
+
+from __future__ import annotations
+
+import time
+from decimal import Decimal
+
+from triangular_arb.strategy.evaluator import evaluate_triangle
+from triangular_arb.types import (
+ OrderBook,
+ Pair,
+ PriceLevel,
+ Side,
+ Symbol,
+ Triangle,
+)
+
+
+def _make_book(
+ pair: str,
+ bid: str = "100",
+ ask: str = "101",
+ quantity: str = "10",
+ timestamp_ns: int | None = None,
+) -> OrderBook:
+ """Helper to create a simple order book."""
+ return OrderBook(
+ pair=Pair(pair),
+ bids=(PriceLevel(Decimal(bid), Decimal(quantity)),),
+ asks=(PriceLevel(Decimal(ask), Decimal(quantity)),),
+ timestamp_ns=timestamp_ns or time.time_ns(),
+ )
+
+
+def _make_triangle() -> Triangle:
+ """Helper to create a standard test triangle."""
+ return Triangle(
+ base=Symbol("ETH"),
+ leg1_pair=Pair("LTC/ETH"),
+ leg1_side=Side.BUY,
+ leg2_pair=Pair("LTC/BTC"),
+ leg2_side=Side.SELL,
+ leg3_pair=Pair("ETH/BTC"),
+ leg3_side=Side.BUY,
+ intermediate_a=Symbol("LTC"),
+ intermediate_b=Symbol("BTC"),
+ )
+
+
+class TestEvaluator:
+ """Tests for the evaluate_triangle function."""
+
+ def test_profitable_triangle_detected(self) -> None:
+ """A triangle with favorable rates should be detected as profitable."""
+ triangle = _make_triangle()
+
+ # Construct books where the cycle yields > 1.0
+ # Leg 1 (BUY LTC/ETH): ask = 0.05 ETH per LTC → get 1/0.05 = 20 LTC
+ # Leg 2 (SELL LTC/BTC): bid = 0.003 BTC per LTC → get 20 * 0.003 = 0.06 BTC
+ # Leg 3 (BUY ETH/BTC): ask = 0.05 BTC per ETH → get 1/0.05 = 20 ETH... wait
+ # Let's use rates that actually create an arb:
+ # 1 ETH → BUY LTC @ 0.05 → 20 LTC → SELL LTC/BTC @ 0.003 → 0.06 BTC
+ # → BUY ETH/BTC @ 0.055 → 0.06/0.055 = 1.0909 ETH → ~9% profit
+
+ books = (
+ _make_book("LTC/ETH", bid="0.049", ask="0.05", quantity="100"),
+ _make_book("LTC/BTC", bid="0.003", ask="0.0031", quantity="100"),
+ _make_book("ETH/BTC", bid="0.054", ask="0.055", quantity="100"),
+ )
+
+ result = evaluate_triangle(
+ triangle=triangle,
+ books=books,
+ fee_rate=Decimal("0.001"),
+ )
+
+ # With these rates: (1/0.05) * 0.003 * (1/0.055) ≈ 1.0909
+ # After 3 legs of 0.1% fees: 1.0909 * 0.999^3 ≈ 1.0876
+ # Net profit ≈ 876 bps
+ assert result is not None
+ assert result.is_profitable
+ assert result.net_profit_bps > Decimal("0")
+
+ def test_unprofitable_triangle_rejected(self) -> None:
+ """A triangle with unfavorable rates should return None."""
+ triangle = _make_triangle()
+
+ # Tight spreads, no arbitrage opportunity
+ books = (
+ _make_book("LTC/ETH", bid="0.05", ask="0.051", quantity="100"),
+ _make_book("LTC/BTC", bid="0.0025", ask="0.0026", quantity="100"),
+ _make_book("ETH/BTC", bid="0.050", ask="0.051", quantity="100"),
+ )
+
+ result = evaluate_triangle(
+ triangle=triangle,
+ books=books,
+ fee_rate=Decimal("0.001"),
+ )
+
+ assert result is None
+
+ def test_stale_books_rejected(self) -> None:
+ """Books older than the staleness threshold should be rejected."""
+ triangle = _make_triangle()
+
+ old_ts = time.time_ns() - 10_000_000_000 # 10 seconds ago
+ books = (
+ _make_book("LTC/ETH", timestamp_ns=old_ts),
+ _make_book("LTC/BTC"),
+ _make_book("ETH/BTC"),
+ )
+
+ result = evaluate_triangle(triangle=triangle, books=books)
+ assert result is None
+
+ def test_empty_book_rejected(self) -> None:
+ """Books with no levels should be rejected."""
+ triangle = _make_triangle()
+
+ empty_book = OrderBook(
+ pair=Pair("LTC/ETH"),
+ bids=(),
+ asks=(),
+ )
+ books = (
+ empty_book,
+ _make_book("LTC/BTC"),
+ _make_book("ETH/BTC"),
+ )
+
+ result = evaluate_triangle(triangle=triangle, books=books)
+ assert result is None
+
+ def test_fees_reduce_profit(self) -> None:
+ """Higher fees should reduce net profit."""
+ triangle = _make_triangle()
+
+ books = (
+ _make_book("LTC/ETH", bid="0.049", ask="0.05", quantity="100"),
+ _make_book("LTC/BTC", bid="0.003", ask="0.0031", quantity="100"),
+ _make_book("ETH/BTC", bid="0.054", ask="0.055", quantity="100"),
+ )
+
+ result_low_fee = evaluate_triangle(
+ triangle=triangle, books=books, fee_rate=Decimal("0.001"),
+ )
+ result_high_fee = evaluate_triangle(
+ triangle=triangle, books=books, fee_rate=Decimal("0.005"),
+ )
+
+ assert result_low_fee is not None
+ if result_high_fee is not None:
+ assert result_high_fee.net_profit_bps < result_low_fee.net_profit_bps
+
+ def test_decimal_precision(self) -> None:
+ """Verify we don't lose precision in calculations."""
+ triangle = _make_triangle()
+ books = (
+ _make_book("LTC/ETH", bid="0.049", ask="0.05", quantity="100"),
+ _make_book("LTC/BTC", bid="0.003", ask="0.0031", quantity="100"),
+ _make_book("ETH/BTC", bid="0.054", ask="0.055", quantity="100"),
+ )
+
+ result = evaluate_triangle(
+ triangle=triangle, books=books, fee_rate=Decimal("0.001"),
+ )
+
+ if result is not None:
+ # Verify types are Decimal, not float
+ assert isinstance(result.net_profit_bps, Decimal)
+ assert isinstance(result.gross_profit_bps, Decimal)
+ assert isinstance(result.estimated_size, Decimal)
diff --git a/tests/test_risk.py b/tests/test_risk.py
new file mode 100644
index 0000000..6cd178c
--- /dev/null
+++ b/tests/test_risk.py
@@ -0,0 +1,146 @@
+"""
+Tests for risk management.
+
+These tests verify that the risk manager correctly gates trades
+based on profitability, staleness, drawdown, and consecutive losses.
+"""
+
+from __future__ import annotations
+
+import time
+from decimal import Decimal
+
+import pytest
+
+from triangular_arb.config import RiskConfig
+from triangular_arb.risk.manager import RejectionReason, RiskManager
+from triangular_arb.types import (
+ ArbitrageResult,
+ Direction,
+ Opportunity,
+ OrderBook,
+ Pair,
+ PriceLevel,
+ Side,
+ Symbol,
+ Triangle,
+)
+
+
+def _make_opportunity(
+ net_profit_bps: str = "10",
+ book_age_ns: int | None = None,
+) -> Opportunity:
+ """Create a test opportunity."""
+ ts = book_age_ns or time.time_ns()
+ triangle = Triangle(
+ base=Symbol("ETH"),
+ leg1_pair=Pair("LTC/ETH"),
+ leg1_side=Side.BUY,
+ leg2_pair=Pair("LTC/BTC"),
+ leg2_side=Side.SELL,
+ leg3_pair=Pair("ETH/BTC"),
+ leg3_side=Side.BUY,
+ intermediate_a=Symbol("LTC"),
+ intermediate_b=Symbol("BTC"),
+ )
+ book = OrderBook(
+ pair=Pair("LTC/ETH"),
+ bids=(PriceLevel(Decimal("100"), Decimal("10")),),
+ asks=(PriceLevel(Decimal("101"), Decimal("10")),),
+ timestamp_ns=ts,
+ )
+ return Opportunity(
+ triangle=triangle,
+ direction=Direction.FORWARD,
+ gross_profit_bps=Decimal(net_profit_bps) + Decimal("3"),
+ net_profit_bps=Decimal(net_profit_bps),
+ estimated_size=Decimal("1"),
+ books=(book, book, book),
+ )
+
+
+def _make_result(success: bool, profit: str = "0.01") -> ArbitrageResult:
+ """Create a test result."""
+ opp = _make_opportunity()
+ return ArbitrageResult(
+ opportunity=opp,
+ fills=(),
+ net_profit=Decimal(profit) if success else Decimal(f"-{profit}"),
+ net_profit_bps=Decimal("5") if success else Decimal("-5"),
+ total_fees=Decimal("0.001"),
+ total_latency_ms=2.5,
+ success=success,
+ )
+
+
+class TestRiskManager:
+ """Tests for the RiskManager class."""
+
+ def test_accepts_profitable_opportunity(self) -> None:
+ """Opportunity above min threshold should be accepted."""
+ rm = RiskManager(RiskConfig(min_profit_bps=Decimal("5")))
+ opp = _make_opportunity(net_profit_bps="10")
+ assert rm.check(opp) is None
+
+ def test_rejects_below_min_profit(self) -> None:
+ """Opportunity below min threshold should be rejected."""
+ rm = RiskManager(RiskConfig(min_profit_bps=Decimal("20")))
+ opp = _make_opportunity(net_profit_bps="10")
+ assert rm.check(opp) == RejectionReason.BELOW_MIN_PROFIT
+
+ def test_rejects_stale_books(self) -> None:
+ """Books older than threshold should be rejected."""
+ rm = RiskManager(RiskConfig(stale_book_ms=1000))
+ old_ts = time.time_ns() - 5_000_000_000 # 5 seconds ago
+ opp = _make_opportunity(net_profit_bps="10", book_age_ns=old_ts)
+ assert rm.check(opp) == RejectionReason.STALE_BOOKS
+
+ def test_circuit_breaker_after_consecutive_losses(self) -> None:
+ """After N consecutive losses, circuit breaker should trip."""
+ rm = RiskManager(RiskConfig(max_consecutive_losses=3))
+
+ for _ in range(3):
+ rm.record_result(_make_result(success=False))
+
+ opp = _make_opportunity(net_profit_bps="50")
+ result = rm.check(opp)
+ assert result == RejectionReason.CONSECUTIVE_LOSSES
+
+ def test_consecutive_losses_reset_on_win(self) -> None:
+ """A winning trade should reset the consecutive loss counter."""
+ rm = RiskManager(RiskConfig(max_consecutive_losses=5))
+
+ # 2 losses then a win
+ rm.record_result(_make_result(success=False))
+ rm.record_result(_make_result(success=False))
+ rm.record_result(_make_result(success=True))
+
+ assert rm.state.consecutive_losses == 0
+
+ def test_daily_loss_limit(self) -> None:
+ """Exceeding daily loss limit should reject new trades."""
+ rm = RiskManager(RiskConfig(max_daily_loss_pct=Decimal("5")))
+ rm.set_starting_balance(Decimal("100"))
+
+ # Simulate a large loss
+ result = _make_result(success=False, profit="6")
+ rm.record_result(result)
+
+ opp = _make_opportunity(net_profit_bps="50")
+ assert rm.check(opp) == RejectionReason.DAILY_LOSS_LIMIT
+
+ def test_win_rate_calculation(self) -> None:
+ """Win rate should be calculated correctly."""
+ rm = RiskManager(RiskConfig())
+
+ rm.record_result(_make_result(success=True))
+ rm.record_result(_make_result(success=True))
+ rm.record_result(_make_result(success=False))
+
+ assert rm.win_rate == pytest.approx(66.67, rel=0.01)
+
+ def test_win_rate_zero_trades(self) -> None:
+ """Win rate with no trades should be 0."""
+ rm = RiskManager(RiskConfig())
+ assert rm.win_rate == 0.0
diff --git a/tests/test_types.py b/tests/test_types.py
new file mode 100644
index 0000000..bd68ae6
--- /dev/null
+++ b/tests/test_types.py
@@ -0,0 +1,45 @@
+"""Tests for domain types."""
+
+from decimal import Decimal
+
+from triangular_arb.types import OrderBook, Pair, PriceLevel, Side, Symbol, Triangle
+
+
+class TestOrderBook:
+ """Tests for OrderBook properties."""
+
+ def test_spread_bps_calculation(self) -> None:
+ """Spread should be correctly computed in basis points."""
+ book = OrderBook(
+ pair=Pair("ETH/BTC"),
+ bids=(PriceLevel(Decimal("100"), Decimal("10")),),
+ asks=(PriceLevel(Decimal("101"), Decimal("10")),),
+ )
+ # Spread = (101-100)/100.5 * 10000 ≈ 99.50 bps
+ assert book.spread_bps > Decimal("99")
+ assert book.spread_bps < Decimal("100")
+
+ def test_spread_bps_empty_book(self) -> None:
+ """Empty book should have infinite spread."""
+ book = OrderBook(pair=Pair("ETH/BTC"), bids=(), asks=())
+ assert book.spread_bps == Decimal("Infinity")
+
+
+class TestTriangle:
+ def test_str_representation(self) -> None:
+ """Triangle should have a readable string representation."""
+ t = Triangle(
+ base=Symbol("ETH"),
+ leg1_pair=Pair("LTC/ETH"),
+ leg1_side=Side.BUY,
+ leg2_pair=Pair("LTC/BTC"),
+ leg2_side=Side.SELL,
+ leg3_pair=Pair("ETH/BTC"),
+ leg3_side=Side.BUY,
+ intermediate_a=Symbol("LTC"),
+ intermediate_b=Symbol("BTC"),
+ )
+ s = str(t)
+ assert "ETH" in s
+ assert "LTC" in s
+ assert "BTC" in s
diff --git a/triangular_arb/__init__.py b/triangular_arb/__init__.py
new file mode 100644
index 0000000..26cbc1d
--- /dev/null
+++ b/triangular_arb/__init__.py
@@ -0,0 +1,3 @@
+"""Triangular Arbitrage Engine — High-frequency crypto arbitrage."""
+
+__version__ = "2.0.0"
diff --git a/triangular_arb/cli.py b/triangular_arb/cli.py
new file mode 100644
index 0000000..8b635a3
--- /dev/null
+++ b/triangular_arb/cli.py
@@ -0,0 +1,85 @@
+"""
+CLI entry point.
+
+Handles argument parsing, config loading, and signal handling
+for graceful shutdown. The engine is started via `asyncio.run()`.
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import signal
+import sys
+from pathlib import Path
+
+from triangular_arb import __version__
+from triangular_arb.config import load_config
+from triangular_arb.engine import Engine
+
+
+def main() -> None:
+ """Entry point for the triangular-arb CLI."""
+ parser = argparse.ArgumentParser(
+ prog="triangular-arb",
+ description="High-frequency triangular arbitrage engine for cryptocurrency exchanges",
+ )
+ parser.add_argument(
+ "--config",
+ type=Path,
+ default=Path("config.yaml"),
+ help="Path to config file (default: config.yaml)",
+ )
+ parser.add_argument(
+ "--version",
+ action="version",
+ version=f"%(prog)s {__version__}",
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ default=None,
+ help="Override config: enable paper trading mode",
+ )
+
+ args = parser.parse_args()
+
+ # Load and validate config
+ try:
+ config = load_config(args.config)
+ except FileNotFoundError as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+ except Exception as e:
+ print(f"Config validation error: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ # CLI overrides
+ if args.dry_run is not None:
+ config = config.model_copy(update={"dry_run": args.dry_run})
+
+ # Create engine
+ engine = Engine(config)
+
+ # Signal handling for graceful shutdown
+ loop = asyncio.new_event_loop()
+
+ background_tasks: set = set() # type: ignore[type-arg]
+
+ def _shutdown(sig: signal.Signals) -> None:
+ print(f"\nReceived {sig.name}, shutting down...")
+ task = loop.create_task(engine.stop())
+ background_tasks.add(task)
+ task.add_done_callback(background_tasks.discard)
+
+ for sig in (signal.SIGINT, signal.SIGTERM):
+ loop.add_signal_handler(sig, _shutdown, sig)
+
+ try:
+ loop.run_until_complete(engine.start())
+ finally:
+ loop.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/triangular_arb/config.py b/triangular_arb/config.py
new file mode 100644
index 0000000..a4e840e
--- /dev/null
+++ b/triangular_arb/config.py
@@ -0,0 +1,109 @@
+"""
+Configuration management via Pydantic validated YAML.
+
+Config is loaded once at startup and immutable thereafter.
+No more Python files with mutable globals acting as config.
+"""
+
+from __future__ import annotations
+
+from decimal import Decimal
+from pathlib import Path
+
+import yaml
+from pydantic import BaseModel, Field
+
+
+class ExchangeConfig(BaseModel):
+ """Connection settings for a single exchange."""
+ exchange_id: str
+ api_key: str = ""
+ api_secret: str = ""
+ passphrase: str = ""
+ testnet: bool = False
+ rate_limit: bool = True
+ timeout_ms: int = 30_000
+
+
+class RiskConfig(BaseModel):
+ """Risk management parameters."""
+ max_position_pct: Decimal = Field(
+ default=Decimal("0.5"),
+ description="Max fraction of balance to use per trade",
+ )
+ min_profit_bps: Decimal = Field(
+ default=Decimal("5"),
+ description="Minimum net profit in basis points to execute",
+ )
+ max_daily_loss_pct: Decimal = Field(
+ default=Decimal("5"),
+ description="Max daily drawdown as % of starting balance before circuit breaker",
+ )
+ max_consecutive_losses: int = 5
+ max_open_triangles: int = 3
+ stale_book_ms: int = 2_000
+
+
+class ExecutionConfig(BaseModel):
+ """Order execution parameters."""
+ use_limit_orders: bool = True
+ limit_order_timeout_s: float = 2.0
+ max_retries: int = 3
+ max_slippage_bps: Decimal = Field(
+ default=Decimal("10"),
+ description="Max acceptable slippage per leg in basis points",
+ )
+ order_book_depth: int = 10
+
+
+class ScannerConfig(BaseModel):
+ """Triangle discovery and scanning parameters."""
+ base_currencies: list[str] = Field(default_factory=lambda: ["ETH", "BTC", "USDT"])
+ scan_interval_ms: int = 500
+ min_book_levels: int = 3
+ quote_currencies: list[str] = Field(
+ default_factory=lambda: ["ETH", "BTC", "USDT", "BNB"],
+ )
+
+
+class LoggingConfig(BaseModel):
+ """Structured logging config."""
+ level: str = "INFO"
+ json_output: bool = True
+ log_dir: str = "logs"
+
+
+class Config(BaseModel):
+ """
+ Top-level configuration. Validated at startup — if the config is invalid,
+ the process refuses to start rather than silently misbehaving.
+ """
+ exchange: ExchangeConfig
+ risk: RiskConfig = Field(default_factory=RiskConfig)
+ execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
+ scanner: ScannerConfig = Field(default_factory=ScannerConfig)
+ logging: LoggingConfig = Field(default_factory=LoggingConfig)
+ dry_run: bool = Field(
+ default=True,
+ description="Paper trading mode. Set to false for live execution.",
+ )
+
+
+def load_config(path: Path | str = "config.yaml") -> Config:
+ """
+ Load and validate configuration from a YAML file.
+
+ Fails fast with a clear error message if the config is invalid,
+ rather than discovering misconfiguration mid-trade.
+ """
+ path = Path(path)
+ if not path.exists():
+ raise FileNotFoundError(
+ f"Config file not found: {path}\n"
+ f"Copy config.example.yaml to {path} and fill in your API keys."
+ )
+
+ with open(path) as f:
+ raw = yaml.safe_load(f)
+
+ return Config.model_validate(raw)
diff --git a/triangular_arb/engine.py b/triangular_arb/engine.py
new file mode 100644
index 0000000..f14a21f
--- /dev/null
+++ b/triangular_arb/engine.py
@@ -0,0 +1,218 @@
+"""
+Main engine — the event loop that ties discovery, evaluation, risk, and execution.
+
+This is the central orchestrator. It:
+1. Discovers all valid triangles on startup
+2. Continuously scans order books for each triangle
+3. Evaluates profitability
+4. Passes through risk checks
+5. Executes if approved
+
+The engine is designed to be the single entry point for running the system.
+Configuration, exchange adapters, and strategy components are injected.
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import structlog
+
+from triangular_arb.config import Config
+from triangular_arb.exchange.adapter import ExchangeAdapter
+from triangular_arb.exchange.binance import BinanceAdapter
+from triangular_arb.execution.executor import Executor
+from triangular_arb.risk.manager import RiskManager
+from triangular_arb.strategy.discovery import discover_triangles
+from triangular_arb.strategy.evaluator import evaluate_triangle
+from triangular_arb.types import Symbol, Triangle
+from triangular_arb.utils.logging import setup_logging
+
+log = structlog.get_logger()
+
+
+class Engine:
+ """
+ Main arbitrage engine.
+
+ Lifecycle:
+ engine = Engine(config)
+ await engine.start() # Runs until interrupted
+ await engine.stop() # Cleanup
+ """
+
+ def __init__(self, config: Config) -> None:
+ self._config = config
+ self._exchange: ExchangeAdapter | None = None
+ self._executor: Executor | None = None
+ self._risk: RiskManager | None = None
+ self._triangles: list[Triangle] = []
+ self._running = False
+ self._scan_count = 0
+ self._opportunity_count = 0
+
+ async def start(self) -> None:
+ """Initialize components and start the scan loop."""
+ setup_logging(
+ level=self._config.logging.level,
+ json_output=self._config.logging.json_output,
+ log_dir=self._config.logging.log_dir,
+ )
+
+ log.info(
+ "engine_starting",
+ exchange=self._config.exchange.exchange_id,
+ dry_run=self._config.dry_run,
+ )
+
+ # Initialize exchange adapter
+ self._exchange = BinanceAdapter(self._config.exchange)
+
+ # Initialize risk manager
+ self._risk = RiskManager(self._config.risk)
+
+ # Get initial balance for risk baseline
+ base_currencies = self._config.scanner.base_currencies
+ base = base_currencies[0] if base_currencies else "ETH"
+ balance = await self._exchange.fetch_balance(Symbol(base))
+ self._risk.set_starting_balance(balance)
+ log.info("initial_balance", currency=base, balance=float(balance))
+
+ # Initialize executor
+ self._executor = Executor(
+ exchange=self._exchange,
+ config=self._config.execution,
+ dry_run=self._config.dry_run,
+ )
+
+ # Discover triangles
+ pairs = await self._exchange.get_all_pairs()
+ self._triangles = discover_triangles(
+ pairs=pairs,
+ base_currencies=self._config.scanner.base_currencies,
+ )
+
+ if not self._triangles:
+ log.error("no_triangles_found")
+ await self.stop()
+ return
+
+ log.info(
+ "engine_ready",
+ triangles=len(self._triangles),
+ scan_interval_ms=self._config.scanner.scan_interval_ms,
+ )
+
+ # Start the scan loop
+ self._running = True
+ try:
+ await self._scan_loop()
+ except asyncio.CancelledError:
+ log.info("engine_cancelled")
+ finally:
+ await self.stop()
+
+ async def _scan_loop(self) -> None:
+ """Continuously scan triangles for opportunities."""
+ interval_s = self._config.scanner.scan_interval_ms / 1000
+
+ while self._running:
+ self._scan_count += 1
+
+ for triangle in self._triangles:
+ if not self._running:
+ break
+ await self._evaluate_and_execute(triangle)
+
+ if self._scan_count % 100 == 0:
+ log.info(
+ "scan_stats",
+ scans=self._scan_count,
+ opportunities_found=self._opportunity_count,
+ risk_state={
+ "total_trades": self._risk.state.total_trades if self._risk else 0,
+ "win_rate": self._risk.win_rate if self._risk else 0,
+ "daily_pnl": float(self._risk.state.daily_pnl) if self._risk else 0,
+ },
+ )
+
+ await asyncio.sleep(interval_s)
+
+ async def _evaluate_and_execute(self, triangle: Triangle) -> None:
+ """Fetch books, evaluate, check risk, and execute if profitable."""
+ assert self._exchange is not None
+ assert self._executor is not None
+ assert self._risk is not None
+
+ try:
+ # Fetch order books for all three legs concurrently
+ depth = self._config.execution.order_book_depth
+ books = await asyncio.gather(
+ self._exchange.fetch_order_book(triangle.leg1_pair, depth),
+ self._exchange.fetch_order_book(triangle.leg2_pair, depth),
+ self._exchange.fetch_order_book(triangle.leg3_pair, depth),
+ return_exceptions=True,
+ )
+
+ # Check for fetch errors
+ for book in books:
+ if isinstance(book, Exception):
+ return
+
+ # Get fee rate
+ _, taker_fee = await self._exchange.get_trading_fees(triangle.leg1_pair)
+
+ # Evaluate profitability
+ opportunity = evaluate_triangle(
+ triangle=triangle,
+ books=(books[0], books[1], books[2]), # type: ignore[arg-type]
+ fee_rate=taker_fee,
+ max_slippage_bps=self._config.execution.max_slippage_bps,
+ )
+
+ if opportunity is None:
+ return
+
+ self._opportunity_count += 1
+
+ # Risk check
+ rejection = self._risk.check(opportunity)
+ if rejection is not None:
+ log.debug(
+ "opportunity_rejected",
+ triangle=str(triangle),
+ reason=rejection.value,
+ )
+ return
+
+ # Execute!
+ log.info(
+ "opportunity_found",
+ triangle=str(triangle),
+ net_profit_bps=float(opportunity.net_profit_bps),
+ size=float(opportunity.estimated_size),
+ age_ms=opportunity.age_ms,
+ )
+
+ result = await self._executor.execute(opportunity)
+ self._risk.record_result(result)
+
+ except Exception:
+ log.exception("scan_error", triangle=str(triangle))
+
+ async def stop(self) -> None:
+ """Graceful shutdown."""
+ self._running = False
+
+ if self._risk:
+ state = self._risk.state
+ log.info(
+ "engine_stopped",
+ total_scans=self._scan_count,
+ total_trades=state.total_trades,
+ win_rate=self._risk.win_rate,
+ total_pnl=float(state.total_pnl),
+ )
+
+ if self._exchange:
+ await self._exchange.close()
diff --git a/triangular_arb/exchange/__init__.py b/triangular_arb/exchange/__init__.py
new file mode 100644
index 0000000..f21279b
--- /dev/null
+++ b/triangular_arb/exchange/__init__.py
@@ -0,0 +1 @@
+"""Exchange adapter layer — abstracts exchange-specific details."""
diff --git a/triangular_arb/exchange/adapter.py b/triangular_arb/exchange/adapter.py
new file mode 100644
index 0000000..d08ddc8
--- /dev/null
+++ b/triangular_arb/exchange/adapter.py
@@ -0,0 +1,100 @@
+"""
+Exchange adapter protocol.
+
+Any exchange backend must implement this interface. This decouples
+strategy logic from exchange specifics and makes testing trivial
+(swap in a mock exchange, no network calls needed).
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from decimal import Decimal
+
+from triangular_arb.types import (
+ ExchangeId,
+ Fill,
+ OrderBook,
+ Pair,
+ Side,
+ Symbol,
+)
+
+
+class ExchangeAdapter(ABC):
+ """
+ Abstract interface for exchange operations.
+
+ Implementations handle the specifics of each exchange's API,
+ rate limiting, and error handling. Strategy code never touches
+ raw exchange APIs directly.
+ """
+
+ @property
+ @abstractmethod
+ def exchange_id(self) -> ExchangeId:
+ """Unique identifier for this exchange."""
+
+ @abstractmethod
+ async def fetch_order_book(self, pair: Pair, depth: int = 10) -> OrderBook:
+ """
+ Fetch a snapshot of the order book.
+
+ Args:
+ pair: Trading pair (e.g., "ETH/BTC")
+ depth: Number of levels to fetch on each side
+
+ Returns:
+ OrderBook with bids descending and asks ascending by price.
+ """
+
+ @abstractmethod
+ async def fetch_balance(self, symbol: Symbol) -> Decimal:
+ """
+ Get available (non-locked) balance for a symbol.
+
+ Returns:
+ Available balance as Decimal. Zero if the symbol is not held.
+ """
+
+ @abstractmethod
+ async def place_order(
+ self,
+ pair: Pair,
+ side: Side,
+ quantity: Decimal,
+ price: Decimal | None = None,
+ ) -> Fill:
+ """
+ Place an order on the exchange.
+
+ Args:
+ pair: Trading pair
+ side: BUY or SELL
+ quantity: Amount of base currency
+ price: Limit price. None = market order.
+
+ Returns:
+ Fill result with execution details.
+ """
+
+ @abstractmethod
+ async def cancel_order(self, pair: Pair, order_id: str) -> bool:
+ """Cancel an open order. Returns True if successfully cancelled."""
+
+ @abstractmethod
+ async def get_trading_fees(self, pair: Pair) -> tuple[Decimal, Decimal]:
+ """
+ Get maker and taker fees for a pair.
+
+ Returns:
+ (maker_fee, taker_fee) as fractions (e.g., 0.001 for 0.1%).
+ """
+
+ @abstractmethod
+ async def get_all_pairs(self) -> list[Pair]:
+ """Get all actively trading pairs on the exchange."""
+
+ @abstractmethod
+ async def close(self) -> None:
+ """Clean up connections."""
diff --git a/triangular_arb/exchange/binance.py b/triangular_arb/exchange/binance.py
new file mode 100644
index 0000000..c6f55a7
--- /dev/null
+++ b/triangular_arb/exchange/binance.py
@@ -0,0 +1,217 @@
+"""
+Binance exchange adapter via ccxt.
+
+Wraps ccxt's async client with proper error handling, retry logic,
+and conversion to our domain types. All Decimal conversion happens
+at this boundary — internal code never sees raw floats from the API.
+"""
+
+from __future__ import annotations
+
+import time
+from decimal import Decimal
+
+import ccxt.async_support as ccxt_async
+import structlog
+from tenacity import (
+ retry,
+ retry_if_exception_type,
+ stop_after_attempt,
+ wait_exponential,
+)
+
+from triangular_arb.config import ExchangeConfig
+from triangular_arb.exchange.adapter import ExchangeAdapter
+from triangular_arb.types import (
+ ExchangeId,
+ Fill,
+ OrderBook,
+ OrderStatus,
+ Pair,
+ PriceLevel,
+ Side,
+ Symbol,
+)
+
+log = structlog.get_logger()
+
+# Transient errors worth retrying
+_RETRYABLE = (
+ ccxt_async.NetworkError,
+ ccxt_async.RequestTimeout,
+ ccxt_async.ExchangeNotAvailable,
+)
+
+
+class BinanceAdapter(ExchangeAdapter):
+ """
+ Production Binance adapter.
+
+ Converts between ccxt's float-based responses and our Decimal domain.
+ All retries and rate-limiting are handled here so callers don't need to.
+ """
+
+ def __init__(self, config: ExchangeConfig) -> None:
+ self._config = config
+ self._client = ccxt_async.binance({
+ "apiKey": config.api_key,
+ "secret": config.api_secret,
+ "timeout": config.timeout_ms,
+ "enableRateLimit": config.rate_limit,
+ "options": {"defaultType": "spot"},
+ })
+
+ if config.testnet:
+ self._client.set_sandbox_mode(True)
+
+ self._fee_cache: dict[Pair, tuple[Decimal, Decimal]] = {}
+
+ @property
+ def exchange_id(self) -> ExchangeId:
+ return ExchangeId("binance")
+
+ @retry(
+ retry=retry_if_exception_type(_RETRYABLE),
+ stop=stop_after_attempt(3),
+ wait=wait_exponential(multiplier=0.1, max=2),
+ reraise=True,
+ )
+ async def fetch_order_book(self, pair: Pair, depth: int = 10) -> OrderBook:
+ raw = await self._client.fetch_order_book(pair, limit=depth)
+ ts = time.time_ns()
+
+ bids = tuple(
+ PriceLevel(price=Decimal(str(p)), quantity=Decimal(str(q)))
+ for p, q in raw.get("bids", [])
+ )
+ asks = tuple(
+ PriceLevel(price=Decimal(str(p)), quantity=Decimal(str(q)))
+ for p, q in raw.get("asks", [])
+ )
+
+ return OrderBook(pair=pair, bids=bids, asks=asks, timestamp_ns=ts)
+
+ @retry(
+ retry=retry_if_exception_type(_RETRYABLE),
+ stop=stop_after_attempt(3),
+ wait=wait_exponential(multiplier=0.1, max=2),
+ reraise=True,
+ )
+ async def fetch_balance(self, symbol: Symbol) -> Decimal:
+ balance = await self._client.fetch_balance()
+ info = balance.get(symbol, {})
+ free = info.get("free", 0)
+ return Decimal(str(free)) if free else Decimal("0")
+
+ @retry(
+ retry=retry_if_exception_type(_RETRYABLE),
+ stop=stop_after_attempt(2),
+ wait=wait_exponential(multiplier=0.1, max=1),
+ reraise=True,
+ )
+ async def place_order(
+ self,
+ pair: Pair,
+ side: Side,
+ quantity: Decimal,
+ price: Decimal | None = None,
+ ) -> Fill:
+ t0 = time.monotonic()
+ order_type = "limit" if price is not None else "market"
+
+ try:
+ if order_type == "limit":
+ raw = await self._client.create_order(
+ symbol=pair,
+ type="limit",
+ side=side.value,
+ amount=float(quantity),
+ price=float(price), # type: ignore[arg-type]
+ )
+ else:
+ raw = await self._client.create_order(
+ symbol=pair,
+ type="market",
+ side=side.value,
+ amount=float(quantity),
+ )
+
+ latency = (time.monotonic() - t0) * 1000
+
+ status_map = {
+ "closed": OrderStatus.FILLED,
+ "open": OrderStatus.PENDING,
+ "canceled": OrderStatus.CANCELLED,
+ "partially_filled": OrderStatus.PARTIAL,
+ }
+
+ fill_price = Decimal(str(raw.get("average", raw.get("price", 0))))
+ fill_qty = Decimal(str(raw.get("filled", quantity)))
+ fee_info = raw.get("fee", {})
+ fee_cost = Decimal(str(fee_info.get("cost", 0)))
+ fee_currency = Symbol(fee_info.get("currency", ""))
+
+ return Fill(
+ pair=pair,
+ side=side,
+ price=fill_price,
+ quantity=fill_qty,
+ fee=fee_cost,
+ fee_currency=fee_currency,
+ status=status_map.get(raw.get("status", ""), OrderStatus.FAILED),
+ exchange_order_id=str(raw.get("id", "")),
+ latency_ms=latency,
+ )
+
+ except Exception as e:
+ latency = (time.monotonic() - t0) * 1000
+ log.error("order_failed", pair=pair, side=side.value, error=str(e))
+ return Fill(
+ pair=pair,
+ side=side,
+ price=Decimal("0"),
+ quantity=Decimal("0"),
+ fee=Decimal("0"),
+ fee_currency=Symbol(""),
+ status=OrderStatus.FAILED,
+ exchange_order_id="",
+ latency_ms=latency,
+ )
+
+ async def cancel_order(self, pair: Pair, order_id: str) -> bool:
+ try:
+ await self._client.cancel_order(order_id, pair)
+ return True
+ except ccxt_async.OrderNotFound:
+ log.warning("cancel_not_found", pair=pair, order_id=order_id)
+ return False
+ except Exception as e:
+ log.error("cancel_failed", pair=pair, order_id=order_id, error=str(e))
+ return False
+
+ async def get_trading_fees(self, pair: Pair) -> tuple[Decimal, Decimal]:
+ if pair in self._fee_cache:
+ return self._fee_cache[pair]
+
+ try:
+ fees = await self._client.fetch_trading_fee(pair)
+ maker = Decimal(str(fees.get("maker", "0.001")))
+ taker = Decimal(str(fees.get("taker", "0.001")))
+ self._fee_cache[pair] = (maker, taker)
+ return maker, taker
+ except Exception:
+ # Default Binance fees
+ default = (Decimal("0.001"), Decimal("0.001"))
+ self._fee_cache[pair] = default
+ return default
+
+ async def get_all_pairs(self) -> list[Pair]:
+ markets = await self._client.load_markets()
+ return [
+ Pair(symbol)
+ for symbol, market in markets.items()
+ if market.get("active", False) and market.get("spot", False)
+ ]
+
+ async def close(self) -> None:
+ await self._client.close()
diff --git a/triangular_arb/execution/__init__.py b/triangular_arb/execution/__init__.py
new file mode 100644
index 0000000..e0a99f0
--- /dev/null
+++ b/triangular_arb/execution/__init__.py
@@ -0,0 +1 @@
+"""Execution engine — atomic tri-leg execution with rollback."""
diff --git a/triangular_arb/execution/executor.py b/triangular_arb/execution/executor.py
new file mode 100644
index 0000000..87e190c
--- /dev/null
+++ b/triangular_arb/execution/executor.py
@@ -0,0 +1,264 @@
+"""
+Tri-leg execution engine.
+
+Handles the atomic execution of a three-leg arbitrage trade.
+If any leg fails, attempts best-effort rollback to minimize losses.
+
+Key design decisions:
+- Each leg is executed sequentially (not parallel) because leg N's
+ output determines leg N+1's input
+- Rollback on failure converts whatever was acquired back to the
+ original currency to limit exposure
+- All fills are recorded for audit trail regardless of success/failure
+"""
+
+from __future__ import annotations
+
+from decimal import Decimal
+
+import structlog
+
+from triangular_arb.config import ExecutionConfig
+from triangular_arb.exchange.adapter import ExchangeAdapter
+from triangular_arb.types import (
+ ArbitrageResult,
+ Fill,
+ Opportunity,
+ OrderStatus,
+ Side,
+ Symbol,
+)
+
+log = structlog.get_logger()
+
+
+class Executor:
+ """
+ Executes triangular arbitrage opportunities.
+
+ This is the most latency-sensitive component. Every millisecond
+ of delay increases the probability that the opportunity has been
+ captured by a competitor.
+ """
+
+ def __init__(
+ self,
+ exchange: ExchangeAdapter,
+ config: ExecutionConfig,
+ dry_run: bool = True,
+ ) -> None:
+ self._exchange = exchange
+ self._config = config
+ self._dry_run = dry_run
+
+ async def execute(self, opportunity: Opportunity) -> ArbitrageResult:
+ """
+ Execute a triangular arbitrage opportunity.
+
+ Three legs are executed sequentially. On any failure,
+ we attempt rollback to minimize loss exposure.
+
+ Args:
+ opportunity: The evaluated opportunity to execute
+
+ Returns:
+ ArbitrageResult with all fills and final P&L
+ """
+ t = opportunity.triangle
+ fills: list[Fill] = []
+ total_latency = 0.0
+
+ log.info(
+ "executing_arbitrage",
+ triangle=str(t),
+ net_profit_bps=float(opportunity.net_profit_bps),
+ dry_run=self._dry_run,
+ )
+
+ if self._dry_run:
+ return self._simulate_execution(opportunity)
+
+ # ── Leg 1 ────────────────────────────────────────────────────────
+ fill1 = await self._execute_leg(
+ pair=t.leg1_pair,
+ side=t.leg1_side,
+ quantity=opportunity.estimated_size,
+ leg_num=1,
+ )
+ fills.append(fill1)
+ total_latency += fill1.latency_ms
+
+ if fill1.status != OrderStatus.FILLED:
+ log.warning("leg1_failed", fill=fill1)
+ return self._build_result(
+ opportunity, tuple(fills), total_latency, error="Leg 1 failed",
+ )
+
+ # ── Leg 2 ────────────────────────────────────────────────────────
+ leg2_qty = fill1.quantity # Output of leg 1 is input to leg 2
+ if fill1.side == Side.BUY:
+ leg2_qty = fill1.quantity - fill1.fee # Subtract fee if paid in base
+
+ fill2 = await self._execute_leg(
+ pair=t.leg2_pair,
+ side=t.leg2_side,
+ quantity=leg2_qty,
+ leg_num=2,
+ )
+ fills.append(fill2)
+ total_latency += fill2.latency_ms
+
+ if fill2.status != OrderStatus.FILLED:
+ log.warning("leg2_failed_attempting_rollback", fill=fill2)
+ rollback = await self._rollback(t.leg1_pair, fill1)
+ if rollback:
+ fills.append(rollback)
+ return self._build_result(
+ opportunity, tuple(fills), total_latency, error="Leg 2 failed",
+ )
+
+ # ── Leg 3 ────────────────────────────────────────────────────────
+ leg3_qty = fill2.quantity
+ if fill2.side == Side.BUY:
+ leg3_qty = fill2.quantity - fill2.fee
+
+ fill3 = await self._execute_leg(
+ pair=t.leg3_pair,
+ side=t.leg3_side,
+ quantity=leg3_qty,
+ leg_num=3,
+ )
+ fills.append(fill3)
+ total_latency += fill3.latency_ms
+
+ if fill3.status != OrderStatus.FILLED:
+ log.warning("leg3_failed_attempting_rollback", fill=fill3)
+ return self._build_result(
+ opportunity, tuple(fills), total_latency, error="Leg 3 failed",
+ )
+
+ return self._build_result(opportunity, tuple(fills), total_latency)
+
+ async def _execute_leg(
+ self,
+ pair: str,
+ side: Side,
+ quantity: Decimal,
+ leg_num: int,
+ ) -> Fill:
+ """Execute a single leg with optional limit order and timeout."""
+ log.debug(
+ "executing_leg",
+ leg=leg_num,
+ pair=pair,
+ side=side.value,
+ quantity=str(quantity),
+ )
+
+ price: Decimal | None = None
+ if self._config.use_limit_orders:
+ book = await self._exchange.fetch_order_book(pair, depth=5)
+ levels = book.asks if side == Side.BUY else book.bids
+ if levels:
+ # Set limit price slightly through the book for immediate fill
+ price = levels[0].price
+
+ return await self._exchange.place_order(
+ pair=pair,
+ side=side,
+ quantity=quantity,
+ price=price,
+ )
+
+ async def _rollback(self, pair: str, original_fill: Fill) -> Fill | None:
+ """
+ Best-effort rollback: reverse the original trade.
+
+ This won't recover 100% (we'll eat the spread + fees again),
+ but it limits exposure to the original currency.
+ """
+ try:
+ reverse_side = Side.SELL if original_fill.side == Side.BUY else Side.BUY
+ return await self._exchange.place_order(
+ pair=pair,
+ side=reverse_side,
+ quantity=original_fill.quantity,
+ price=None, # Market order for speed
+ )
+ except Exception as e:
+ log.error("rollback_failed", pair=pair, error=str(e))
+ return None
+
+ def _simulate_execution(self, opportunity: Opportunity) -> ArbitrageResult:
+ """Simulate execution for dry-run mode using order book prices."""
+ fills: list[Fill] = []
+ qty = opportunity.estimated_size
+
+ tri = opportunity.triangle
+ sides = [tri.leg1_side, tri.leg2_side, tri.leg3_side]
+ for i, (book, side) in enumerate(zip(opportunity.books, sides)):
+ levels = book.asks if side == Side.BUY else book.bids
+ price = levels[0].price if levels else Decimal("0")
+ fee = qty * Decimal("0.001")
+
+ fills.append(Fill(
+ pair=book.pair,
+ side=side,
+ price=price,
+ quantity=qty,
+ fee=fee,
+ fee_currency=Symbol(""),
+ status=OrderStatus.FILLED,
+ exchange_order_id=f"sim-{i}",
+ latency_ms=0.5,
+ ))
+
+ # Simulate conversion
+ if side == Side.BUY:
+ qty = qty / price - fee
+ else:
+ qty = qty * price - fee
+
+ return self._build_result(opportunity, tuple(fills), 1.5)
+
+ def _build_result(
+ self,
+ opportunity: Opportunity,
+ fills: tuple[Fill, ...],
+ total_latency: float,
+ error: str | None = None,
+ ) -> ArbitrageResult:
+ """Build the final ArbitrageResult from fills."""
+ total_fees = sum(f.fee for f in fills)
+ success = error is None and all(f.status == OrderStatus.FILLED for f in fills)
+
+ # Calculate net profit from fills
+ if len(fills) >= 3 and success:
+ # Simplified: compare final output vs initial input
+ net_profit = fills[-1].quantity * fills[-1].price - fills[0].quantity * fills[0].price
+ net_profit_bps = opportunity.net_profit_bps
+ else:
+ net_profit = Decimal("0")
+ net_profit_bps = Decimal("0")
+
+ result = ArbitrageResult(
+ opportunity=opportunity,
+ fills=fills,
+ net_profit=net_profit,
+ net_profit_bps=net_profit_bps,
+ total_fees=total_fees,
+ total_latency_ms=total_latency,
+ success=success,
+ error=error,
+ )
+
+ log.info(
+ "arbitrage_complete",
+ success=success,
+ net_profit_bps=float(net_profit_bps),
+ total_fees=float(total_fees),
+ latency_ms=total_latency,
+ error=error,
+ )
+
+ return result
diff --git a/triangular_arb/risk/__init__.py b/triangular_arb/risk/__init__.py
new file mode 100644
index 0000000..7a62c14
--- /dev/null
+++ b/triangular_arb/risk/__init__.py
@@ -0,0 +1 @@
+"""Risk management — circuit breakers, position limits, drawdown guards."""
diff --git a/triangular_arb/risk/manager.py b/triangular_arb/risk/manager.py
new file mode 100644
index 0000000..74d260c
--- /dev/null
+++ b/triangular_arb/risk/manager.py
@@ -0,0 +1,149 @@
+"""
+Risk manager — the last line of defense before execution.
+
+Enforces position limits, drawdown circuit breakers, and staleness checks.
+Every opportunity must pass through here before execution.
+The risk manager can only reject — it never modifies opportunities.
+"""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field
+from decimal import Decimal
+from enum import Enum
+
+import structlog
+
+from triangular_arb.config import RiskConfig
+from triangular_arb.types import ArbitrageResult, Opportunity
+
+log = structlog.get_logger()
+
+
+class RejectionReason(Enum):
+ BELOW_MIN_PROFIT = "below_min_profit_threshold"
+ STALE_BOOKS = "order_books_too_stale"
+ DAILY_LOSS_LIMIT = "daily_loss_limit_reached"
+ CONSECUTIVE_LOSSES = "consecutive_loss_limit_reached"
+ CIRCUIT_BREAKER_OPEN = "circuit_breaker_is_open"
+ TOO_MANY_OPEN = "too_many_open_triangles"
+
+
+@dataclass
+class RiskState:
+ """Mutable risk state that tracks session statistics."""
+ daily_pnl: Decimal = Decimal("0")
+ starting_balance: Decimal = Decimal("0")
+ consecutive_losses: int = 0
+ circuit_breaker_open: bool = False
+ circuit_breaker_until_ns: int = 0
+ open_triangles: int = 0
+ total_trades: int = 0
+ total_wins: int = 0
+ total_pnl: Decimal = Decimal("0")
+ session_start_ns: int = field(default_factory=time.time_ns)
+
+
+class RiskManager:
+ """
+ Pre-trade risk gate.
+
+ This component exists to prevent the system from destroying itself.
+ A firm like Jane Street would have this as an independent process
+ with kill switches — here we implement the same logic inline.
+ """
+
+ def __init__(self, config: RiskConfig) -> None:
+ self._config = config
+ self._state = RiskState()
+
+ def check(self, opportunity: Opportunity) -> RejectionReason | None:
+ """
+ Evaluate whether an opportunity should be executed.
+
+ Returns None if the opportunity passes all checks,
+ or a RejectionReason if it should be rejected.
+ """
+ now_ns = time.time_ns()
+
+ # Circuit breaker check
+ if self._state.circuit_breaker_open:
+ if now_ns < self._state.circuit_breaker_until_ns:
+ return RejectionReason.CIRCUIT_BREAKER_OPEN
+ else:
+ log.info("circuit_breaker_reset")
+ self._state.circuit_breaker_open = False
+ self._state.consecutive_losses = 0
+
+ # Minimum profit threshold
+ if opportunity.net_profit_bps < self._config.min_profit_bps:
+ return RejectionReason.BELOW_MIN_PROFIT
+
+ # Order book staleness
+ now_ns = time.time_ns()
+ for book in opportunity.books:
+ age_ms = (now_ns - book.timestamp_ns) / 1_000_000
+ if age_ms > self._config.stale_book_ms:
+ return RejectionReason.STALE_BOOKS
+
+ # Daily loss limit
+ if self._state.starting_balance > 0:
+ loss_pct = (-self._state.daily_pnl / self._state.starting_balance) * 100
+ if loss_pct >= self._config.max_daily_loss_pct:
+ return RejectionReason.DAILY_LOSS_LIMIT
+
+ # Consecutive losses
+ if self._state.consecutive_losses >= self._config.max_consecutive_losses:
+ self._trip_circuit_breaker(duration_s=300) # 5-minute cooldown
+ return RejectionReason.CONSECUTIVE_LOSSES
+
+ # Open position limit
+ if self._state.open_triangles >= self._config.max_open_triangles:
+ return RejectionReason.TOO_MANY_OPEN
+
+ return None
+
+ def record_result(self, result: ArbitrageResult) -> None:
+ """Update risk state after a trade completes."""
+ self._state.total_trades += 1
+ self._state.total_pnl += result.net_profit
+ self._state.daily_pnl += result.net_profit
+
+ if result.success and result.net_profit > 0:
+ self._state.total_wins += 1
+ self._state.consecutive_losses = 0
+ elif not result.success or result.net_profit < 0:
+ self._state.consecutive_losses += 1
+
+ log.info(
+ "risk_state_updated",
+ total_trades=self._state.total_trades,
+ win_rate=self.win_rate,
+ daily_pnl=float(self._state.daily_pnl),
+ consecutive_losses=self._state.consecutive_losses,
+ )
+
+ def set_starting_balance(self, balance: Decimal) -> None:
+ """Set the reference balance for drawdown calculations."""
+ self._state.starting_balance = balance
+
+ def _trip_circuit_breaker(self, duration_s: int) -> None:
+ """Activate the circuit breaker for a specified duration."""
+ self._state.circuit_breaker_open = True
+ self._state.circuit_breaker_until_ns = time.time_ns() + duration_s * 1_000_000_000
+ log.warning(
+ "circuit_breaker_tripped",
+ consecutive_losses=self._state.consecutive_losses,
+ cooldown_s=duration_s,
+ )
+
+ @property
+ def win_rate(self) -> float:
+ if self._state.total_trades == 0:
+ return 0.0
+ return self._state.total_wins / self._state.total_trades * 100
+
+ @property
+ def state(self) -> RiskState:
+ return self._state
diff --git a/triangular_arb/strategy/__init__.py b/triangular_arb/strategy/__init__.py
new file mode 100644
index 0000000..a7342dc
--- /dev/null
+++ b/triangular_arb/strategy/__init__.py
@@ -0,0 +1 @@
+"""Strategy layer — triangle discovery and opportunity evaluation."""
diff --git a/triangular_arb/strategy/discovery.py b/triangular_arb/strategy/discovery.py
new file mode 100644
index 0000000..fe97236
--- /dev/null
+++ b/triangular_arb/strategy/discovery.py
@@ -0,0 +1,164 @@
+"""
+Triangle discovery via graph search.
+
+Instead of hardcoding token lists, we build a graph of all trading pairs
+and enumerate valid triangles. This automatically adapts to new listings
+and delistings without config changes.
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+
+import structlog
+
+from triangular_arb.types import Pair, Side, Symbol, Triangle
+
+log = structlog.get_logger()
+
+
+def _parse_pair(pair: Pair) -> tuple[Symbol, Symbol]:
+ """Split 'ETH/BTC' into (Symbol('ETH'), Symbol('BTC'))."""
+ parts = pair.split("/")
+ if len(parts) != 2:
+ raise ValueError(f"Invalid pair format: {pair}")
+ return Symbol(parts[0]), Symbol(parts[1])
+
+
+def discover_triangles(
+ pairs: list[Pair],
+ base_currencies: list[str],
+) -> list[Triangle]:
+ """
+ Discover all valid triangular arbitrage paths.
+
+ Algorithm:
+ 1. Build adjacency graph from all trading pairs
+ 2. For each base currency, find all 3-node cycles that
+ start and end at that currency
+ 3. Deduplicate (A→B→C→A is the same triangle as C→A→B→C)
+
+ Args:
+ pairs: All available trading pairs on the exchange
+ base_currencies: Currencies to use as the start/end of triangles
+
+ Returns:
+ List of Triangle objects representing valid arbitrage paths
+ """
+ # Build adjacency: symbol → set of symbols it can trade against
+ adjacency: dict[str, set[str]] = defaultdict(set)
+ pair_set: set[str] = set()
+
+ for pair in pairs:
+ try:
+ base, quote = _parse_pair(pair)
+ adjacency[base].add(quote)
+ adjacency[quote].add(base)
+ pair_set.add(pair)
+ except ValueError:
+ continue
+
+ triangles: list[Triangle] = []
+ seen: set[frozenset[str]] = set()
+
+ for start in base_currencies:
+ if start not in adjacency:
+ continue
+
+ # Find all 2-hop paths from start that return to start
+ for mid_a in adjacency[start]:
+ for mid_b in adjacency[mid_a]:
+ if mid_b == start or mid_b == mid_a:
+ continue
+ if start not in adjacency[mid_b]:
+ continue
+
+ # Dedup: {ETH, LTC, BTC} is the same triangle regardless of direction
+ key = frozenset([start, mid_a, mid_b])
+ if key in seen:
+ continue
+ seen.add(key)
+
+ # Determine the pairs and sides for the forward direction
+ # Forward: start → mid_a → mid_b → start
+ triangle = _build_triangle(
+ base=Symbol(start),
+ mid_a=Symbol(mid_a),
+ mid_b=Symbol(mid_b),
+ pair_set=pair_set,
+ )
+ if triangle is not None:
+ triangles.append(triangle)
+
+ log.info(
+ "triangle_discovery_complete",
+ total_pairs=len(pairs),
+ triangles_found=len(triangles),
+ base_currencies=base_currencies,
+ )
+ return triangles
+
+
+def _build_triangle(
+ base: Symbol,
+ mid_a: Symbol,
+ mid_b: Symbol,
+ pair_set: set[str],
+) -> Triangle | None:
+ """
+ Build a Triangle with correct pair directions and sides.
+
+ For each leg, we need to determine:
+ - Which pair exists (e.g., LTC/ETH vs ETH/LTC)
+ - Whether we BUY or SELL on that pair
+ """
+ # Leg 1: base → mid_a
+ leg1 = _resolve_leg(base, mid_a, pair_set)
+ if leg1 is None:
+ return None
+
+ # Leg 2: mid_a → mid_b
+ leg2 = _resolve_leg(mid_a, mid_b, pair_set)
+ if leg2 is None:
+ return None
+
+ # Leg 3: mid_b → base
+ leg3 = _resolve_leg(mid_b, base, pair_set)
+ if leg3 is None:
+ return None
+
+ return Triangle(
+ base=base,
+ leg1_pair=leg1[0],
+ leg1_side=leg1[1],
+ leg2_pair=leg2[0],
+ leg2_side=leg2[1],
+ leg3_pair=leg3[0],
+ leg3_side=leg3[1],
+ intermediate_a=mid_a,
+ intermediate_b=mid_b,
+ )
+
+
+def _resolve_leg(
+ from_sym: Symbol,
+ to_sym: Symbol,
+ pair_set: set[str],
+) -> tuple[Pair, Side] | None:
+ """
+ Determine the correct pair and side to go from `from_sym` to `to_sym`.
+
+ If pair is FROM/TO: we SELL (sell FROM to get TO)
+ If pair is TO/FROM: we BUY (buy TO with FROM)
+ """
+ forward = f"{from_sym}/{to_sym}"
+ backward = f"{to_sym}/{from_sym}"
+
+ if forward in pair_set:
+ # Pair is FROM/TO — selling FROM gives us TO
+ return Pair(forward), Side.SELL
+ elif backward in pair_set:
+ # Pair is TO/FROM — buying TO costs FROM
+ return Pair(backward), Side.BUY
+ else:
+ return None
diff --git a/triangular_arb/strategy/evaluator.py b/triangular_arb/strategy/evaluator.py
new file mode 100644
index 0000000..6ed38b9
--- /dev/null
+++ b/triangular_arb/strategy/evaluator.py
@@ -0,0 +1,148 @@
+"""
+Opportunity evaluator — determines if a triangle is profitable.
+
+All arithmetic uses Decimal. The key insight: we compute the effective
+exchange rate around the triangle. If starting with 1 unit and ending
+with >1 unit after fees and slippage, it's profitable.
+"""
+
+from __future__ import annotations
+
+import time
+from decimal import ROUND_DOWN, Decimal
+
+import structlog
+
+from triangular_arb.types import (
+ Direction,
+ Opportunity,
+ OrderBook,
+ Side,
+ Triangle,
+)
+
+log = structlog.get_logger()
+
+# Minimum profitability to consider (avoids noise from rounding)
+_MIN_PROFIT_BPS = Decimal("0.1")
+
+
+def evaluate_triangle(
+ triangle: Triangle,
+ books: tuple[OrderBook, OrderBook, OrderBook],
+ fee_rate: Decimal = Decimal("0.001"),
+ max_slippage_bps: Decimal = Decimal("10"),
+) -> Opportunity | None:
+ """
+ Evaluate whether a triangle presents a profitable opportunity.
+
+ Simulates executing each leg against the order book to account
+ for actual liquidity and slippage, not just top-of-book prices.
+
+ Args:
+ triangle: The triangle path to evaluate
+ books: Order books for each leg (in order)
+ fee_rate: Trading fee per leg (as fraction, e.g., 0.001 = 0.1%)
+ max_slippage_bps: Maximum acceptable slippage per leg
+
+ Returns:
+ Opportunity if profitable, None otherwise
+ """
+ book1, book2, book3 = books
+
+ # Validate books aren't stale
+ now_ns = time.time_ns()
+ for book in books:
+ age_ms = (now_ns - book.timestamp_ns) / 1_000_000
+ if age_ms > 5_000: # 5 second staleness threshold
+ log.debug("stale_book", pair=book.pair, age_ms=age_ms)
+ return None
+
+ # Validate books have sufficient depth
+ for book in books:
+ if len(book.bids) < 1 or len(book.asks) < 1:
+ return None
+
+ # Compute effective rate for each leg
+ # Starting with 1 unit of the base currency
+ fee_multiplier = Decimal("1") - fee_rate
+
+ # Leg 1
+ rate1 = _effective_rate(book1, triangle.leg1_side)
+ if rate1 is None:
+ return None
+
+ # Leg 2
+ rate2 = _effective_rate(book2, triangle.leg2_side)
+ if rate2 is None:
+ return None
+
+ # Leg 3
+ rate3 = _effective_rate(book3, triangle.leg3_side)
+ if rate3 is None:
+ return None
+
+ # Gross profit: rate around the triangle
+ gross_rate = rate1 * rate2 * rate3
+ gross_profit_bps = (gross_rate - Decimal("1")) * Decimal("10000")
+
+ # Net profit: after 3 legs of fees
+ net_rate = rate1 * fee_multiplier * rate2 * fee_multiplier * rate3 * fee_multiplier
+ net_profit_bps = (net_rate - Decimal("1")) * Decimal("10000")
+
+ if net_profit_bps < _MIN_PROFIT_BPS:
+ return None
+
+ # Estimate max executable size (bottleneck liquidity)
+ size = _estimate_max_size(books, triangle)
+
+ return Opportunity(
+ triangle=triangle,
+ direction=Direction.FORWARD,
+ gross_profit_bps=gross_profit_bps.quantize(Decimal("0.01")),
+ net_profit_bps=net_profit_bps.quantize(Decimal("0.01")),
+ estimated_size=size,
+ books=books,
+ )
+
+
+def _effective_rate(book: OrderBook, side: Side) -> Decimal | None:
+ """
+ Get the effective exchange rate for one leg.
+
+ BUY: We're buying base with quote → rate = 1/ask
+ SELL: We're selling base for quote → rate = bid
+ """
+ if side == Side.BUY:
+ if not book.asks:
+ return None
+ return Decimal("1") / book.asks[0].price
+ else:
+ if not book.bids:
+ return None
+ return book.bids[0].price
+
+
+def _estimate_max_size(
+ books: tuple[OrderBook, OrderBook, OrderBook],
+ triangle: Triangle,
+) -> Decimal:
+ """
+ Estimate the maximum tradeable size through the triangle.
+
+ The bottleneck is the leg with the least available liquidity.
+ We take the minimum across all three legs, converted to base currency.
+ """
+ sizes: list[Decimal] = []
+
+ sides = [triangle.leg1_side, triangle.leg2_side, triangle.leg3_side]
+ for book, side in zip(books, sides):
+ levels = book.asks if side == Side.BUY else book.bids
+ # Sum available liquidity across top levels
+ total_qty = sum(level.quantity for level in levels[:5])
+ sizes.append(total_qty)
+
+ if not sizes:
+ return Decimal("0")
+
+ return min(sizes).quantize(Decimal("0.00000001"), rounding=ROUND_DOWN)
diff --git a/triangular_arb/types.py b/triangular_arb/types.py
new file mode 100644
index 0000000..3c4211b
--- /dev/null
+++ b/triangular_arb/types.py
@@ -0,0 +1,142 @@
+"""
+Domain types for the arbitrage engine.
+
+All financial values use Decimal to avoid floating-point errors that would
+silently eat basis points of profit — the exact margin this system operates on.
+"""
+
+from __future__ import annotations
+
+import enum
+import time
+from dataclasses import dataclass, field
+from decimal import Decimal
+from typing import NewType, Optional, Tuple
+
+# ─── Branded newtypes prevent passing a bid where an ask is expected ──────────
+Symbol = NewType("Symbol", str) # e.g. "ETH"
+Pair = NewType("Pair", str) # e.g. "ETH/BTC"
+ExchangeId = NewType("ExchangeId", str)
+
+
+class Side(enum.Enum):
+ BUY = "buy"
+ SELL = "sell"
+
+
+class OrderStatus(enum.Enum):
+ PENDING = "pending"
+ PARTIAL = "partial"
+ FILLED = "filled"
+ CANCELLED = "cancelled"
+ FAILED = "failed"
+
+
+class Direction(enum.Enum):
+ """Which way around the triangle we're going."""
+ FORWARD = "forward" # A → B → C → A
+ BACKWARD = "backward" # A → C → B → A
+
+
+@dataclass(frozen=True)
+class PriceLevel:
+ """Single level in an order book."""
+ price: Decimal
+ quantity: Decimal
+
+
+@dataclass(frozen=True)
+class OrderBook:
+ """Snapshot of an order book at a point in time."""
+ pair: Pair
+ bids: Tuple[PriceLevel, ...] # Best bid first (descending price)
+ asks: Tuple[PriceLevel, ...] # Best ask first (ascending price)
+ timestamp_ns: int = field(default_factory=time.time_ns)
+
+ @property
+ def spread_bps(self) -> Decimal:
+ """Bid-ask spread in basis points."""
+ if not self.bids or not self.asks:
+ return Decimal("Infinity")
+ mid = (self.bids[0].price + self.asks[0].price) / 2
+ if mid == 0:
+ return Decimal("Infinity")
+ return (self.asks[0].price - self.bids[0].price) / mid * Decimal("10000")
+
+
+@dataclass(frozen=True)
+class Triangle:
+ """
+ A triangular path through three pairs.
+
+ Example: ETH → LTC/ETH → LTC/BTC → ETH/BTC
+ The legs define which pairs to trade and in what order.
+ """
+ base: Symbol # The currency we start and end with (e.g., ETH)
+ leg1_pair: Pair # e.g. LTC/ETH
+ leg1_side: Side # BUY or SELL
+ leg2_pair: Pair # e.g. LTC/BTC
+ leg2_side: Side
+ leg3_pair: Pair # e.g. ETH/BTC
+ leg3_side: Side
+ intermediate_a: Symbol # e.g. LTC
+ intermediate_b: Symbol # e.g. BTC
+
+ def __str__(self) -> str:
+ return (
+ f"{self.base} →({self.leg1_side.value} {self.leg1_pair})→ "
+ f"{self.intermediate_a} →({self.leg2_side.value} {self.leg2_pair})→ "
+ f"{self.intermediate_b} →({self.leg3_side.value} {self.leg3_pair})→ {self.base}"
+ )
+
+
+@dataclass(frozen=True)
+class Opportunity:
+ """A detected arbitrage opportunity with estimated profit."""
+ triangle: Triangle
+ direction: Direction
+ gross_profit_bps: Decimal # Before fees/slippage
+ net_profit_bps: Decimal # After fees/slippage
+ estimated_size: Decimal # Max executable size (bottleneck liquidity)
+ books: Tuple[OrderBook, OrderBook, OrderBook]
+ detected_at_ns: int = field(default_factory=time.time_ns)
+
+ @property
+ def is_profitable(self) -> bool:
+ return self.net_profit_bps > 0
+
+ @property
+ def age_ms(self) -> float:
+ return (time.time_ns() - self.detected_at_ns) / 1_000_000
+
+
+@dataclass(frozen=True)
+class Fill:
+ """Result of a single leg execution."""
+ pair: Pair
+ side: Side
+ price: Decimal
+ quantity: Decimal
+ fee: Decimal
+ fee_currency: Symbol
+ status: OrderStatus
+ exchange_order_id: str
+ latency_ms: float
+
+
+@dataclass(frozen=True)
+class ArbitrageResult:
+ """
+ Complete result of a triangular arbitrage attempt.
+
+ Immutable record for audit trail. Every trade ever executed
+ can be reconstructed from a sequence of these.
+ """
+ opportunity: Opportunity
+ fills: Tuple[Fill, ...]
+ net_profit: Decimal
+ net_profit_bps: Decimal
+ total_fees: Decimal
+ total_latency_ms: float
+ success: bool
+ error: Optional[str] = None
diff --git a/triangular_arb/utils/__init__.py b/triangular_arb/utils/__init__.py
new file mode 100644
index 0000000..60e3a27
--- /dev/null
+++ b/triangular_arb/utils/__init__.py
@@ -0,0 +1 @@
+"""Utils — logging setup and helpers."""
diff --git a/triangular_arb/utils/logging.py b/triangular_arb/utils/logging.py
new file mode 100644
index 0000000..6281103
--- /dev/null
+++ b/triangular_arb/utils/logging.py
@@ -0,0 +1,77 @@
+"""
+Structured logging configuration.
+
+JSON-structured logs that can be piped to any log aggregation system.
+Every log entry includes the timestamp, level, and event name as
+required fields — no more parsing `logs.txt` with regex.
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+from pathlib import Path
+
+import structlog
+
+
+def setup_logging(level: str = "INFO", json_output: bool = True, log_dir: str = "logs") -> None:
+ """
+ Configure structured logging for the application.
+
+ Args:
+ level: Log level (DEBUG, INFO, WARNING, ERROR)
+ json_output: If True, render logs as JSON. If False, use colored console output.
+ log_dir: Directory for log files
+ """
+ Path(log_dir).mkdir(exist_ok=True)
+
+ # Shared processors for both stdlib and structlog
+ shared_processors: list[structlog.types.Processor] = [
+ structlog.contextvars.merge_contextvars,
+ structlog.stdlib.add_log_level,
+ structlog.stdlib.add_logger_name,
+ structlog.processors.TimeStamper(fmt="iso"),
+ structlog.processors.StackInfoRenderer(),
+ structlog.processors.UnicodeDecoder(),
+ ]
+
+ if json_output:
+ renderer: structlog.types.Processor = structlog.processors.JSONRenderer()
+ else:
+ renderer = structlog.dev.ConsoleRenderer(colors=True)
+
+ structlog.configure(
+ processors=[
+ *shared_processors,
+ structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
+ ],
+ logger_factory=structlog.stdlib.LoggerFactory(),
+ wrapper_class=structlog.stdlib.BoundLogger,
+ cache_logger_on_first_use=True,
+ )
+
+ formatter = structlog.stdlib.ProcessorFormatter(
+ processors=[
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
+ renderer,
+ ],
+ )
+
+ # Console handler
+ console_handler = logging.StreamHandler(sys.stdout)
+ console_handler.setFormatter(formatter)
+
+ # File handler
+ file_handler = logging.FileHandler(f"{log_dir}/triangular_arb.log")
+ file_handler.setFormatter(formatter)
+
+ root_logger = logging.getLogger()
+ root_logger.handlers.clear()
+ root_logger.addHandler(console_handler)
+ root_logger.addHandler(file_handler)
+ root_logger.setLevel(getattr(logging, level.upper(), logging.INFO))
+
+ # Quiet noisy libraries
+ logging.getLogger("ccxt").setLevel(logging.WARNING)
+ logging.getLogger("urllib3").setLevel(logging.WARNING)