docs: add DOCKER_GUIDE.md and fix .env parsing; chore: update docker and script configurations
This commit is contained in:
89
DOCKER_GUIDE.md
Normal file
89
DOCKER_GUIDE.md
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Docker Management & Troubleshooting Guide
|
||||||
|
|
||||||
|
This guide provides the necessary commands to build, manage, and troubleshoot the BTC Bot Docker environment.
|
||||||
|
|
||||||
|
## 1. Manual Build Commands
|
||||||
|
Always execute these commands from the **project root** directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the Data Collector
|
||||||
|
docker build --network host -f docker/Dockerfile.collector -t btc_collector .
|
||||||
|
|
||||||
|
# Build the API Server
|
||||||
|
docker build --network host -f docker/Dockerfile.api -t btc_api .
|
||||||
|
|
||||||
|
# Build the Bot (Ensure the tag matches docker-compose.yml)
|
||||||
|
docker build --no-cache --network host -f docker/Dockerfile.bot -t btc_ping_pong_bot .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Managing Containers
|
||||||
|
Run these commands from the **docker/** directory (`~/btc_bot/docker`).
|
||||||
|
|
||||||
|
### Restart All Services
|
||||||
|
```bash
|
||||||
|
# Full reset: Stop, remove, and recreate all containers
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Partial Restart (Specific Service)
|
||||||
|
```bash
|
||||||
|
# Rebuild and restart only the bot (ignores dependencies like DB)
|
||||||
|
docker-compose up -d --no-deps ping_pong_bot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop/Start Services
|
||||||
|
```bash
|
||||||
|
docker-compose stop <service_name> # Temporarily stop
|
||||||
|
docker-compose start <service_name> # Start a stopped container
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Checking Logs
|
||||||
|
Use these commands to diagnose why a service might be crashing or restarting.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Follow live logs for the Bot (last 100 lines)
|
||||||
|
docker-compose logs -f --tail 100 ping_pong_bot
|
||||||
|
|
||||||
|
# Follow live logs for the Collector
|
||||||
|
docker-compose logs -f btc_collector
|
||||||
|
|
||||||
|
# Follow live logs for the API Server
|
||||||
|
docker-compose logs -f api_server
|
||||||
|
|
||||||
|
# View logs for ALL services combined
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Troubleshooting Checklist
|
||||||
|
|
||||||
|
| Symptom | Common Cause & Solution |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **`.env` Parsing Warning** | Check for `//` comments (use `#` instead) or hidden characters at the start of the file. |
|
||||||
|
| **Container "Restarting" Loop** | Check logs! Usually missing `API_KEY`/`API_SECRET` or DB connection failure. |
|
||||||
|
| **"No containers to restart"** | Use `docker-compose up -d` first. `restart` only works for existing containers. |
|
||||||
|
| **Database Connection Refused** | Ensure `DB_PORT=5433` is used for `host` network mode. Check if port is open with `netstat`. |
|
||||||
|
| **Code Changes Not Applying** | Rebuild the image (`--no-cache`) if you changed `requirements.txt` or the `Dockerfile`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Useful Debugging Commands
|
||||||
|
```bash
|
||||||
|
# Check status of all containers
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# List all local docker images
|
||||||
|
docker images
|
||||||
|
|
||||||
|
# Check if the database port is listening on the host
|
||||||
|
netstat -tulnp | grep 5433
|
||||||
|
|
||||||
|
# Access the shell inside a running container
|
||||||
|
docker exec -it btc_ping_pong_bot /bin/bash
|
||||||
|
```
|
||||||
33
config/ping_pong_config.yaml
Normal file
33
config/ping_pong_config.yaml
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Ping-Pong Strategy Configuration
|
||||||
|
|
||||||
|
# Trading Pair & Timeframe
|
||||||
|
symbol: BTCUSDT
|
||||||
|
interval: "1" # Minutes (1, 3, 5, 15, 30, 60, 120, 240, 360, 720, D, W, M)
|
||||||
|
|
||||||
|
# Indicator Settings
|
||||||
|
rsi:
|
||||||
|
period: 14
|
||||||
|
overbought: 70
|
||||||
|
oversold: 30
|
||||||
|
enabled_for_open: true
|
||||||
|
enabled_for_close: false
|
||||||
|
|
||||||
|
hurst:
|
||||||
|
period: 30
|
||||||
|
multiplier: 1.8
|
||||||
|
enabled_for_open: true
|
||||||
|
enabled_for_close: false
|
||||||
|
|
||||||
|
# Strategy Settings
|
||||||
|
direction: "long" # "long" or "short"
|
||||||
|
capital: 1000.0 # Initial capital for calculations (informational)
|
||||||
|
exchange_leverage: 1.0 # Multiplier for each 'ping' size
|
||||||
|
max_effective_leverage: 5.0 # Cap on total position size relative to equity
|
||||||
|
pos_size_margin: 10.0 # Margin per 'ping' (USD)
|
||||||
|
take_profit_pct: 1.5 # Target profit percentage per exit (1.5 = 1.5%)
|
||||||
|
partial_exit_pct: 0.15 # 15% of position closed on each TP hit
|
||||||
|
min_position_value_usd: 15.0 # Minimum remaining value to keep position open
|
||||||
|
|
||||||
|
# Execution Settings
|
||||||
|
loop_interval_seconds: 5 # How often to check for new data
|
||||||
|
debug_mode: false
|
||||||
23
docker/Dockerfile.api
Normal file
23
docker/Dockerfile.api
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy requirements first (for better caching)
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY config/ ./config/
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
|
|
||||||
|
# Set Python path
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
# Expose API port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run the API server
|
||||||
|
CMD ["uvicorn", "src.api.server:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
20
docker/Dockerfile.bot
Normal file
20
docker/Dockerfile.bot
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy requirements first
|
||||||
|
COPY requirements_bot.txt .
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements_bot.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY config/ ./config/
|
||||||
|
COPY .env .
|
||||||
|
|
||||||
|
# Set Python path
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
# Run the bot
|
||||||
|
CMD ["python", "src/strategies/ping_pong_bot.py"]
|
||||||
21
docker/Dockerfile.collector
Normal file
21
docker/Dockerfile.collector
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy requirements first (for better caching)
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
# --no-cache-dir reduces image size
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY config/ ./config/
|
||||||
|
COPY scripts/ ./scripts/
|
||||||
|
|
||||||
|
# Set Python path
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
# Run the collector
|
||||||
|
CMD ["python", "-m", "src.data_collector.main"]
|
||||||
1
docker/Dockerfile.timescaledb
Normal file
1
docker/Dockerfile.timescaledb
Normal file
@ -0,0 +1 @@
|
|||||||
|
timescale/timescaledb:2.11.2-pg15
|
||||||
110
docker/docker-compose.yml
Normal file
110
docker/docker-compose.yml
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# Update docker-compose.yml to mount source code as volume
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
timescaledb:
|
||||||
|
image: timescale/timescaledb:2.11.2-pg15
|
||||||
|
container_name: btc_timescale
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: btc_bot
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
POSTGRES_DB: btc_data
|
||||||
|
TZ: Europe/Warsaw
|
||||||
|
volumes:
|
||||||
|
- /volume1/btc_bot/data:/var/lib/postgresql/data
|
||||||
|
- /volume1/btc_bot/backups:/backups
|
||||||
|
- ./timescaledb.conf:/etc/postgresql/postgresql.conf
|
||||||
|
- ./init-scripts:/docker-entrypoint-initdb.d
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
command: postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1.5G
|
||||||
|
reservations:
|
||||||
|
memory: 512M
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U btc_bot -d btc_data"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
data_collector:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.collector
|
||||||
|
image: btc_collector
|
||||||
|
container_name: btc_collector
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- DB_HOST=localhost
|
||||||
|
- DB_PORT=5433
|
||||||
|
- DB_NAME=btc_data
|
||||||
|
- DB_USER=btc_bot
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
- LOG_LEVEL=INFO
|
||||||
|
volumes:
|
||||||
|
- ../src:/app/src
|
||||||
|
- /volume1/btc_bot/logs:/app/logs
|
||||||
|
- ../config:/app/config:ro
|
||||||
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
reservations:
|
||||||
|
memory: 128M
|
||||||
|
|
||||||
|
api_server:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.api
|
||||||
|
image: btc_api
|
||||||
|
container_name: btc_api
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- DB_HOST=localhost
|
||||||
|
- DB_PORT=5433
|
||||||
|
- DB_NAME=btc_data
|
||||||
|
- DB_USER=btc_bot
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ../src:/app/src
|
||||||
|
- /volume1/btc_bot/exports:/app/exports
|
||||||
|
- ../config:/app/config:ro
|
||||||
|
depends_on:
|
||||||
|
- timescaledb
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
|
||||||
|
ping_pong_bot:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile.bot
|
||||||
|
image: btc_ping_pong_bot
|
||||||
|
container_name: btc_ping_pong_bot
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- API_KEY=${API_KEY}
|
||||||
|
- API_SECRET=${API_SECRET}
|
||||||
|
- LOG_LEVEL=INFO
|
||||||
|
volumes:
|
||||||
|
- ../src:/app/src
|
||||||
|
- /volume1/btc_bot/logs:/app/logs
|
||||||
|
- ../config:/app/config:ro
|
||||||
|
- ../.env:/app/.env:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
reservations:
|
||||||
|
memory: 128M
|
||||||
199
docker/init-scripts/01-schema.sql
Normal file
199
docker/init-scripts/01-schema.sql
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
-- 1. Enable TimescaleDB extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS timescaledb;
|
||||||
|
|
||||||
|
-- 2. Create candles table (main data storage)
|
||||||
|
CREATE TABLE IF NOT EXISTS candles (
|
||||||
|
time TIMESTAMPTZ NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
interval TEXT NOT NULL,
|
||||||
|
open DECIMAL(18,8) NOT NULL,
|
||||||
|
high DECIMAL(18,8) NOT NULL,
|
||||||
|
low DECIMAL(18,8) NOT NULL,
|
||||||
|
close DECIMAL(18,8) NOT NULL,
|
||||||
|
volume DECIMAL(18,8) NOT NULL,
|
||||||
|
validated BOOLEAN DEFAULT FALSE,
|
||||||
|
source TEXT DEFAULT 'hyperliquid',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Convert to hypertable (partitioned by time)
|
||||||
|
SELECT create_hypertable('candles', 'time',
|
||||||
|
chunk_time_interval => INTERVAL '7 days',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Create unique constraint for upserts (required by ON CONFLICT)
|
||||||
|
ALTER TABLE candles
|
||||||
|
ADD CONSTRAINT candles_unique_candle
|
||||||
|
UNIQUE (time, symbol, interval);
|
||||||
|
|
||||||
|
-- 5. Create indexes for efficient queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_candles_symbol_time
|
||||||
|
ON candles (symbol, interval, time DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_candles_validated
|
||||||
|
ON candles (validated) WHERE validated = FALSE;
|
||||||
|
|
||||||
|
-- 5. Create indicators table (computed values)
|
||||||
|
CREATE TABLE IF NOT EXISTS indicators (
|
||||||
|
time TIMESTAMPTZ NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
interval TEXT NOT NULL,
|
||||||
|
indicator_name TEXT NOT NULL,
|
||||||
|
value DECIMAL(18,8) NOT NULL,
|
||||||
|
parameters JSONB,
|
||||||
|
computed_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 6. Convert indicators to hypertable
|
||||||
|
SELECT create_hypertable('indicators', 'time',
|
||||||
|
chunk_time_interval => INTERVAL '7 days',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 7. Create unique constraint + index for indicators (required for upserts)
|
||||||
|
ALTER TABLE indicators
|
||||||
|
ADD CONSTRAINT indicators_unique
|
||||||
|
UNIQUE (time, symbol, interval, indicator_name);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_indicators_lookup
|
||||||
|
ON indicators (symbol, interval, indicator_name, time DESC);
|
||||||
|
|
||||||
|
-- 8. Create data quality log table
|
||||||
|
CREATE TABLE IF NOT EXISTS data_quality (
|
||||||
|
time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
check_type TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL,
|
||||||
|
symbol TEXT,
|
||||||
|
details JSONB,
|
||||||
|
resolved BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quality_unresolved
|
||||||
|
ON data_quality (resolved) WHERE resolved = FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quality_time
|
||||||
|
ON data_quality (time DESC);
|
||||||
|
|
||||||
|
-- 9. Create collector state tracking table
|
||||||
|
CREATE TABLE IF NOT EXISTS collector_state (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
symbol TEXT NOT NULL UNIQUE,
|
||||||
|
last_candle_time TIMESTAMPTZ,
|
||||||
|
last_validation_time TIMESTAMPTZ,
|
||||||
|
total_candles BIGINT DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 10. Insert initial state for cbBTC
|
||||||
|
INSERT INTO collector_state (symbol, last_candle_time)
|
||||||
|
VALUES ('cbBTC', NULL)
|
||||||
|
ON CONFLICT (symbol) DO NOTHING;
|
||||||
|
|
||||||
|
-- 11. Enable compression for old data (after 7 days)
|
||||||
|
ALTER TABLE candles SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'symbol,interval'
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE indicators SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'symbol,interval,indicator_name'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 12. Add compression policies
|
||||||
|
SELECT add_compression_policy('candles', INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
SELECT add_compression_policy('indicators', INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
|
||||||
|
-- 13. Create function to update collector state
|
||||||
|
CREATE OR REPLACE FUNCTION update_collector_state()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO collector_state (symbol, last_candle_time, total_candles)
|
||||||
|
VALUES (NEW.symbol, NEW.time, 1)
|
||||||
|
ON CONFLICT (symbol)
|
||||||
|
DO UPDATE SET
|
||||||
|
last_candle_time = NEW.time,
|
||||||
|
total_candles = collector_state.total_candles + 1,
|
||||||
|
updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- 14. Create trigger to auto-update state
|
||||||
|
DROP TRIGGER IF EXISTS trigger_update_state ON candles;
|
||||||
|
CREATE TRIGGER trigger_update_state
|
||||||
|
AFTER INSERT ON candles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_collector_state();
|
||||||
|
|
||||||
|
-- 15. Create view for data health check
|
||||||
|
CREATE OR REPLACE VIEW data_health AS
|
||||||
|
SELECT
|
||||||
|
symbol,
|
||||||
|
COUNT(*) as total_candles,
|
||||||
|
COUNT(*) FILTER (WHERE validated) as validated_candles,
|
||||||
|
MAX(time) as latest_candle,
|
||||||
|
MIN(time) as earliest_candle,
|
||||||
|
NOW() - MAX(time) as time_since_last
|
||||||
|
FROM candles
|
||||||
|
GROUP BY symbol;
|
||||||
|
|
||||||
|
-- 16. Create decisions table (brain outputs - buy/sell/hold with full context)
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
time TIMESTAMPTZ NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
interval TEXT NOT NULL,
|
||||||
|
decision_type TEXT NOT NULL,
|
||||||
|
strategy TEXT NOT NULL,
|
||||||
|
confidence DECIMAL(5,4),
|
||||||
|
price_at_decision DECIMAL(18,8),
|
||||||
|
indicator_snapshot JSONB NOT NULL,
|
||||||
|
candle_snapshot JSONB NOT NULL,
|
||||||
|
reasoning TEXT,
|
||||||
|
backtest_id TEXT,
|
||||||
|
executed BOOLEAN DEFAULT FALSE,
|
||||||
|
execution_price DECIMAL(18,8),
|
||||||
|
execution_time TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 17. Convert decisions to hypertable
|
||||||
|
SELECT create_hypertable('decisions', 'time',
|
||||||
|
chunk_time_interval => INTERVAL '7 days',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 18. Indexes for decisions - separate live from backtest queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_live
|
||||||
|
ON decisions (symbol, interval, time DESC) WHERE backtest_id IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_backtest
|
||||||
|
ON decisions (backtest_id, symbol, time DESC) WHERE backtest_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type
|
||||||
|
ON decisions (symbol, decision_type, time DESC);
|
||||||
|
|
||||||
|
-- 19. Create backtest_runs metadata table
|
||||||
|
CREATE TABLE IF NOT EXISTS backtest_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
strategy TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL DEFAULT 'BTC',
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ NOT NULL,
|
||||||
|
intervals TEXT[] NOT NULL,
|
||||||
|
config JSONB,
|
||||||
|
results JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 20. Compression for decisions
|
||||||
|
ALTER TABLE decisions SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'symbol,interval,strategy'
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT add_compression_policy('decisions', INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
|
||||||
|
-- Success message
|
||||||
|
SELECT 'Database schema initialized successfully' as status;
|
||||||
43
docker/init-scripts/02-optimization.sql
Normal file
43
docker/init-scripts/02-optimization.sql
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
-- Create a read-only user for API access (optional security)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'btc_api') THEN
|
||||||
|
CREATE USER btc_api WITH PASSWORD 'api_password_change_me';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Grant read-only permissions
|
||||||
|
GRANT CONNECT ON DATABASE btc_data TO btc_api;
|
||||||
|
GRANT USAGE ON SCHEMA public TO btc_api;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA public TO btc_api;
|
||||||
|
|
||||||
|
-- Grant sequence access for ID columns
|
||||||
|
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO btc_api;
|
||||||
|
|
||||||
|
-- Apply to future tables
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO btc_api;
|
||||||
|
|
||||||
|
-- Create continuous aggregate for hourly stats (optional optimization)
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS hourly_stats
|
||||||
|
WITH (timescaledb.continuous) AS
|
||||||
|
SELECT
|
||||||
|
time_bucket('1 hour', time) as bucket,
|
||||||
|
symbol,
|
||||||
|
interval,
|
||||||
|
FIRST(open, time) as first_open,
|
||||||
|
MAX(high) as max_high,
|
||||||
|
MIN(low) as min_low,
|
||||||
|
LAST(close, time) as last_close,
|
||||||
|
SUM(volume) as total_volume,
|
||||||
|
COUNT(*) as candle_count
|
||||||
|
FROM candles
|
||||||
|
GROUP BY bucket, symbol, interval;
|
||||||
|
|
||||||
|
-- Add refresh policy for continuous aggregate
|
||||||
|
SELECT add_continuous_aggregate_policy('hourly_stats',
|
||||||
|
start_offset => INTERVAL '1 month',
|
||||||
|
end_offset => INTERVAL '1 hour',
|
||||||
|
schedule_interval => INTERVAL '1 hour',
|
||||||
|
if_not_exists => TRUE
|
||||||
|
);
|
||||||
41
docker/timescaledb.conf
Normal file
41
docker/timescaledb.conf
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Optimized for Synology DS218+ (2GB RAM, dual-core CPU)
|
||||||
|
|
||||||
|
# Required for TimescaleDB
|
||||||
|
shared_preload_libraries = 'timescaledb'
|
||||||
|
|
||||||
|
# Memory settings
|
||||||
|
shared_buffers = 256MB
|
||||||
|
effective_cache_size = 768MB
|
||||||
|
work_mem = 16MB
|
||||||
|
maintenance_work_mem = 128MB
|
||||||
|
|
||||||
|
# Connection settings
|
||||||
|
listen_addresses = '*'
|
||||||
|
max_connections = 50
|
||||||
|
max_locks_per_transaction = 256
|
||||||
|
max_worker_processes = 2
|
||||||
|
max_parallel_workers_per_gather = 1
|
||||||
|
max_parallel_workers = 2
|
||||||
|
max_parallel_maintenance_workers = 1
|
||||||
|
|
||||||
|
# Write performance
|
||||||
|
wal_buffers = 16MB
|
||||||
|
checkpoint_completion_target = 0.9
|
||||||
|
random_page_cost = 1.1
|
||||||
|
effective_io_concurrency = 200
|
||||||
|
|
||||||
|
# TimescaleDB settings
|
||||||
|
timescaledb.max_background_workers = 4
|
||||||
|
|
||||||
|
# Logging (use default pg_log directory inside PGDATA)
|
||||||
|
logging_collector = on
|
||||||
|
log_directory = 'log'
|
||||||
|
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'
|
||||||
|
log_rotation_age = 1d
|
||||||
|
log_rotation_size = 100MB
|
||||||
|
log_min_messages = warning
|
||||||
|
log_min_error_statement = error
|
||||||
|
|
||||||
|
# Auto-vacuum for hypertables
|
||||||
|
autovacuum_max_workers = 2
|
||||||
|
autovacuum_naptime = 10s
|
||||||
6
requirements_bot.txt
Normal file
6
requirements_bot.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
pybit
|
||||||
|
pandas
|
||||||
|
numpy
|
||||||
|
pyyaml
|
||||||
|
python-dotenv
|
||||||
|
rich
|
||||||
36
scripts/backfill.sh
Normal file
36
scripts/backfill.sh
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Backfill script for Hyperliquid historical data
|
||||||
|
# Usage: ./backfill.sh [coin] [days|max] [intervals...]
|
||||||
|
# Examples:
|
||||||
|
# ./backfill.sh BTC 7 "1m" # Last 7 days of 1m candles
|
||||||
|
# ./backfill.sh BTC max "1m 1h 1d" # Maximum available data for all intervals
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
COIN=${1:-BTC}
|
||||||
|
DAYS=${2:-7}
|
||||||
|
INTERVALS=${3:-"1m"}
|
||||||
|
|
||||||
|
echo "=== Hyperliquid Historical Data Backfill ==="
|
||||||
|
echo "Coin: $COIN"
|
||||||
|
if [ "$DAYS" == "max" ]; then
|
||||||
|
echo "Mode: MAXIMUM (up to 5000 candles per interval)"
|
||||||
|
else
|
||||||
|
echo "Days: $DAYS"
|
||||||
|
fi
|
||||||
|
echo "Intervals: $INTERVALS"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Change to project root
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# Run backfill inside Docker container
|
||||||
|
docker exec btc_collector python -m src.data_collector.backfill \
|
||||||
|
--coin "$COIN" \
|
||||||
|
--days "$DAYS" \
|
||||||
|
--intervals $INTERVALS \
|
||||||
|
--db-host localhost \
|
||||||
|
--db-port 5433
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Backfill Complete ==="
|
||||||
37
scripts/backup.sh
Normal file
37
scripts/backup.sh
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Backup script for Synology DS218+
|
||||||
|
# Run via Task Scheduler every 6 hours
|
||||||
|
|
||||||
|
BACKUP_DIR="/volume1/btc_bot/backups"
|
||||||
|
DB_NAME="btc_data"
|
||||||
|
DB_USER="btc_bot"
|
||||||
|
RETENTION_DAYS=30
|
||||||
|
DATE=$(date +%Y%m%d_%H%M)
|
||||||
|
|
||||||
|
echo "Starting backup at $(date)"
|
||||||
|
|
||||||
|
# Create backup directory if not exists
|
||||||
|
mkdir -p $BACKUP_DIR
|
||||||
|
|
||||||
|
# Create backup
|
||||||
|
docker exec btc_timescale pg_dump -U $DB_USER -Fc $DB_NAME > $BACKUP_DIR/btc_data_$DATE.dump
|
||||||
|
|
||||||
|
# Compress
|
||||||
|
if [ -f "$BACKUP_DIR/btc_data_$DATE.dump" ]; then
|
||||||
|
gzip $BACKUP_DIR/btc_data_$DATE.dump
|
||||||
|
echo "Backup created: btc_data_$DATE.dump.gz"
|
||||||
|
|
||||||
|
# Calculate size
|
||||||
|
SIZE=$(du -h $BACKUP_DIR/btc_data_$DATE.dump.gz | cut -f1)
|
||||||
|
echo "Backup size: $SIZE"
|
||||||
|
else
|
||||||
|
echo "Error: Backup file not created"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete old backups
|
||||||
|
DELETED=$(find $BACKUP_DIR -name "*.dump.gz" -mtime +$RETENTION_DAYS | wc -l)
|
||||||
|
find $BACKUP_DIR -name "*.dump.gz" -mtime +$RETENTION_DAYS -delete
|
||||||
|
|
||||||
|
echo "Deleted $DELETED old backup(s)"
|
||||||
|
echo "Backup completed at $(date)"
|
||||||
107
scripts/check_db_stats.py
Normal file
107
scripts/check_db_stats.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Quick database statistics checker
|
||||||
|
Shows oldest date, newest date, and count for each interval
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import asyncpg
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
async def check_database_stats():
|
||||||
|
# Database connection (uses same env vars as your app)
|
||||||
|
conn = await asyncpg.connect(
|
||||||
|
host=os.getenv('DB_HOST', 'localhost'),
|
||||||
|
port=int(os.getenv('DB_PORT', 5432)),
|
||||||
|
database=os.getenv('DB_NAME', 'btc_data'),
|
||||||
|
user=os.getenv('DB_USER', 'btc_bot'),
|
||||||
|
password=os.getenv('DB_PASSWORD', '')
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("=" * 70)
|
||||||
|
print("DATABASE STATISTICS")
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Check for each interval
|
||||||
|
intervals = ['1m', '3m', '5m', '15m', '30m', '37m', '1h', '2h', '4h', '8h', '12h', '1d']
|
||||||
|
|
||||||
|
for interval in intervals:
|
||||||
|
stats = await conn.fetchrow("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as count,
|
||||||
|
MIN(time) as oldest,
|
||||||
|
MAX(time) as newest
|
||||||
|
FROM candles
|
||||||
|
WHERE symbol = 'BTC' AND interval = $1
|
||||||
|
""", interval)
|
||||||
|
|
||||||
|
if stats['count'] > 0:
|
||||||
|
oldest = stats['oldest'].strftime('%Y-%m-%d %H:%M') if stats['oldest'] else 'N/A'
|
||||||
|
newest = stats['newest'].strftime('%Y-%m-%d %H:%M') if stats['newest'] else 'N/A'
|
||||||
|
count = stats['count']
|
||||||
|
|
||||||
|
# Calculate days of data
|
||||||
|
if stats['oldest'] and stats['newest']:
|
||||||
|
days = (stats['newest'] - stats['oldest']).days
|
||||||
|
print(f"{interval:6} | {count:>8,} candles | {days:>4} days | {oldest} to {newest}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Check indicators
|
||||||
|
print("\nINDICATORS AVAILABLE:")
|
||||||
|
indicators = await conn.fetch("""
|
||||||
|
SELECT DISTINCT indicator_name, interval, COUNT(*) as count
|
||||||
|
FROM indicators
|
||||||
|
WHERE symbol = 'BTC'
|
||||||
|
GROUP BY indicator_name, interval
|
||||||
|
ORDER BY interval, indicator_name
|
||||||
|
""")
|
||||||
|
|
||||||
|
if indicators:
|
||||||
|
for ind in indicators:
|
||||||
|
print(f" {ind['indicator_name']:10} on {ind['interval']:6} | {ind['count']:>8,} values")
|
||||||
|
else:
|
||||||
|
print(" No indicators found in database")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Check 1m specifically with more detail
|
||||||
|
print("\n1-MINUTE DATA DETAIL:")
|
||||||
|
one_min_stats = await conn.fetchrow("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as count,
|
||||||
|
MIN(time) as oldest,
|
||||||
|
MAX(time) as newest,
|
||||||
|
COUNT(*) FILTER (WHERE time > NOW() - INTERVAL '24 hours') as last_24h
|
||||||
|
FROM candles
|
||||||
|
WHERE symbol = 'BTC' AND interval = '1m'
|
||||||
|
""")
|
||||||
|
|
||||||
|
if one_min_stats['count'] > 0:
|
||||||
|
total_days = (one_min_stats['newest'] - one_min_stats['oldest']).days
|
||||||
|
expected_candles = total_days * 24 * 60 # 1 candle per minute
|
||||||
|
actual_candles = one_min_stats['count']
|
||||||
|
coverage = (actual_candles / expected_candles) * 100 if expected_candles > 0 else 0
|
||||||
|
|
||||||
|
print(f" Total candles: {actual_candles:,}")
|
||||||
|
print(f" Date range: {one_min_stats['oldest'].strftime('%Y-%m-%d')} to {one_min_stats['newest'].strftime('%Y-%m-%d')}")
|
||||||
|
print(f" Total days: {total_days}")
|
||||||
|
print(f" Expected candles: {expected_candles:,} (if complete)")
|
||||||
|
print(f" Coverage: {coverage:.1f}%")
|
||||||
|
print(f" Last 24 hours: {one_min_stats['last_24h']:,} candles")
|
||||||
|
else:
|
||||||
|
print(" No 1m data found")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(check_database_stats())
|
||||||
18
scripts/check_status.sh
Normal file
18
scripts/check_status.sh
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Check the status of the indicators table (constraints and compression)
|
||||||
|
|
||||||
|
docker exec -i btc_timescale psql -U btc_bot -d btc_data <<EOF
|
||||||
|
\x
|
||||||
|
SELECT 'Checking constraints...' as step;
|
||||||
|
SELECT conname, pg_get_constraintdef(oid)
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = 'indicators'::regclass;
|
||||||
|
|
||||||
|
SELECT 'Checking compression settings...' as step;
|
||||||
|
SELECT * FROM timescaledb_information.hypertables
|
||||||
|
WHERE hypertable_name = 'indicators';
|
||||||
|
|
||||||
|
SELECT 'Checking compression jobs...' as step;
|
||||||
|
SELECT * FROM timescaledb_information.jobs
|
||||||
|
WHERE hypertable_name = 'indicators';
|
||||||
|
EOF
|
||||||
59
scripts/deploy.sh
Normal file
59
scripts/deploy.sh
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deployment script for Synology DS218+
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== BTC Bot Data Collector Deployment ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if running on Synology
|
||||||
|
if [ ! -d "/volume1" ]; then
|
||||||
|
echo "Warning: This script is designed for Synology NAS"
|
||||||
|
echo "Continuing anyway..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
echo "Creating directories..."
|
||||||
|
mkdir -p /volume1/btc_bot/{data,backups,logs,exports}
|
||||||
|
|
||||||
|
# Check if Docker is installed
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo "Error: Docker not found. Please install Docker package from Synology Package Center"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy configuration
|
||||||
|
echo "Setting up configuration..."
|
||||||
|
if [ ! -f "/volume1/btc_bot/.env" ]; then
|
||||||
|
cp .env.example /volume1/btc_bot/.env
|
||||||
|
echo "Created .env file. Please edit /volume1/btc_bot/.env with your settings"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build and start services
|
||||||
|
echo "Building and starting services..."
|
||||||
|
cd docker
|
||||||
|
docker-compose pull
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Wait for database
|
||||||
|
echo "Waiting for database to be ready..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
echo ""
|
||||||
|
echo "=== Status ==="
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Logs (last 20 lines) ==="
|
||||||
|
docker-compose logs --tail=20
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Deployment Complete ==="
|
||||||
|
echo "Database available at: localhost:5432"
|
||||||
|
echo "API available at: http://localhost:8000"
|
||||||
|
echo ""
|
||||||
|
echo "To view logs: docker-compose logs -f"
|
||||||
|
echo "To stop: docker-compose down"
|
||||||
|
echo "To backup: ./scripts/backup.sh"
|
||||||
54
scripts/fix_indicators_v2.sh
Normal file
54
scripts/fix_indicators_v2.sh
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Fix indicators table schema - Version 2 (Final)
|
||||||
|
# Handles TimescaleDB compression constraints properly
|
||||||
|
|
||||||
|
echo "Fixing indicators table schema (v2)..."
|
||||||
|
|
||||||
|
# 1. Decompress chunks individually (safest method)
|
||||||
|
# We fetch the list of compressed chunks and process them one by one
|
||||||
|
echo "Checking for compressed chunks..."
|
||||||
|
CHUNKS=$(docker exec -i btc_timescale psql -U btc_bot -d btc_data -t -c "SELECT chunk_schema || '.' || chunk_name FROM timescaledb_information.chunks WHERE hypertable_name = 'indicators' AND is_compressed = true;")
|
||||||
|
|
||||||
|
for chunk in $CHUNKS; do
|
||||||
|
# Trim whitespace
|
||||||
|
chunk=$(echo "$chunk" | xargs)
|
||||||
|
if [[ ! -z "$chunk" ]]; then
|
||||||
|
echo "Decompressing chunk: $chunk"
|
||||||
|
docker exec -i btc_timescale psql -U btc_bot -d btc_data -c "SELECT decompress_chunk('$chunk');"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. Execute the schema changes
|
||||||
|
docker exec -i btc_timescale psql -U btc_bot -d btc_data <<EOF
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Remove policy first
|
||||||
|
SELECT remove_compression_policy('indicators', if_exists => true);
|
||||||
|
|
||||||
|
-- Disable compression setting (REQUIRED to add unique constraint)
|
||||||
|
ALTER TABLE indicators SET (timescaledb.compress = false);
|
||||||
|
|
||||||
|
-- Deduplicate data (just in case duplicates exist)
|
||||||
|
DELETE FROM indicators a USING indicators b
|
||||||
|
WHERE a.ctid < b.ctid
|
||||||
|
AND a.time = b.time
|
||||||
|
AND a.symbol = b.symbol
|
||||||
|
AND a.interval = b.interval
|
||||||
|
AND a.indicator_name = b.indicator_name;
|
||||||
|
|
||||||
|
-- Add the unique constraint
|
||||||
|
ALTER TABLE indicators ADD CONSTRAINT indicators_unique UNIQUE (time, symbol, interval, indicator_name);
|
||||||
|
|
||||||
|
-- Re-enable compression configuration
|
||||||
|
ALTER TABLE indicators SET (
|
||||||
|
timescaledb.compress,
|
||||||
|
timescaledb.compress_segmentby = 'symbol,interval,indicator_name'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Re-add compression policy (7 days)
|
||||||
|
SELECT add_compression_policy('indicators', INTERVAL '7 days', if_not_exists => true);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
SELECT 'Indicators schema fix v2 completed successfully' as status;
|
||||||
|
EOF
|
||||||
65
scripts/generate_custom_history.py
Normal file
65
scripts/generate_custom_history.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
from src.data_collector.database import DatabaseManager
|
||||||
|
from src.data_collector.custom_timeframe_generator import CustomTimeframeGenerator
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logger.info("Starting custom timeframe generation...")
|
||||||
|
|
||||||
|
# DB connection settings from env or defaults
|
||||||
|
db_host = os.getenv('DB_HOST', 'localhost')
|
||||||
|
db_port = int(os.getenv('DB_PORT', 5432))
|
||||||
|
db_name = os.getenv('DB_NAME', 'btc_data')
|
||||||
|
db_user = os.getenv('DB_USER', 'btc_bot')
|
||||||
|
db_password = os.getenv('DB_PASSWORD', '')
|
||||||
|
|
||||||
|
db = DatabaseManager(
|
||||||
|
host=db_host,
|
||||||
|
port=db_port,
|
||||||
|
database=db_name,
|
||||||
|
user=db_user,
|
||||||
|
password=db_password
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
generator = CustomTimeframeGenerator(db)
|
||||||
|
await generator.initialize()
|
||||||
|
|
||||||
|
# Generate 37m from 1m
|
||||||
|
logger.info("Generating 37m candles from 1m data...")
|
||||||
|
count_37m = await generator.generate_historical('37m')
|
||||||
|
logger.info(f"Generated {count_37m} candles for 37m")
|
||||||
|
|
||||||
|
# Generate 148m from 37m
|
||||||
|
# Note: 148m generation relies on 37m data existing
|
||||||
|
logger.info("Generating 148m candles from 37m data...")
|
||||||
|
count_148m = await generator.generate_historical('148m')
|
||||||
|
logger.info(f"Generated {count_148m} candles for 148m")
|
||||||
|
|
||||||
|
logger.info("Done!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating custom timeframes: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
await db.disconnect()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
87
scripts/generate_custom_timeframes.py
Normal file
87
scripts/generate_custom_timeframes.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate custom timeframes (37m, 148m) from historical 1m data
|
||||||
|
Run once to backfill all historical data
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add parent to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / 'src'))
|
||||||
|
|
||||||
|
from data_collector.database import DatabaseManager
|
||||||
|
from data_collector.custom_timeframe_generator import CustomTimeframeGenerator
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Generate custom timeframe candles')
|
||||||
|
parser.add_argument('--interval',
|
||||||
|
default='all',
|
||||||
|
help='Which interval to generate (default: all, choices: 3m, 5m, 1h, 37m, etc.)')
|
||||||
|
parser.add_argument('--batch-size', type=int, default=5000,
|
||||||
|
help='Number of source candles per batch')
|
||||||
|
parser.add_argument('--verify', action='store_true',
|
||||||
|
help='Verify integrity after generation')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
db = DatabaseManager()
|
||||||
|
await db.connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
generator = CustomTimeframeGenerator(db)
|
||||||
|
await generator.initialize()
|
||||||
|
|
||||||
|
if not generator.first_1m_time:
|
||||||
|
logger.error("No 1m data found in database. Cannot generate custom timeframes.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.interval == 'all':
|
||||||
|
intervals = list(generator.STANDARD_INTERVALS.keys()) + list(generator.CUSTOM_INTERVALS.keys())
|
||||||
|
else:
|
||||||
|
intervals = [args.interval]
|
||||||
|
|
||||||
|
for interval in intervals:
|
||||||
|
logger.info(f"=" * 60)
|
||||||
|
logger.info(f"Generating {interval} candles")
|
||||||
|
logger.info(f"=" * 60)
|
||||||
|
|
||||||
|
# Generate historical data
|
||||||
|
count = await generator.generate_historical(
|
||||||
|
interval=interval,
|
||||||
|
batch_size=args.batch_size
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Generated {count} {interval} candles")
|
||||||
|
|
||||||
|
# Verify if requested
|
||||||
|
if args.verify:
|
||||||
|
logger.info(f"Verifying {interval} integrity...")
|
||||||
|
stats = await generator.verify_integrity(interval)
|
||||||
|
logger.info(f"Stats: {stats}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error: {e}", exc_info=True)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
await db.disconnect()
|
||||||
|
|
||||||
|
logger.info("Custom timeframe generation complete!")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
exit_code = asyncio.run(main())
|
||||||
|
sys.exit(exit_code)
|
||||||
31
scripts/health_check.sh
Normal file
31
scripts/health_check.sh
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Health check script for cron/scheduler
|
||||||
|
|
||||||
|
# Check if containers are running
|
||||||
|
if ! docker ps | grep -q "btc_timescale"; then
|
||||||
|
echo "ERROR: TimescaleDB container not running"
|
||||||
|
# Send notification (if configured)
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker ps | grep -q "btc_collector"; then
|
||||||
|
echo "ERROR: Data collector container not running"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check database connectivity
|
||||||
|
docker exec btc_timescale pg_isready -U btc_bot -d btc_data > /dev/null 2>&1
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "ERROR: Cannot connect to database"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if recent data exists
|
||||||
|
LATEST=$(docker exec btc_timescale psql -U btc_bot -d btc_data -t -c "SELECT MAX(time) FROM candles WHERE time > NOW() - INTERVAL '5 minutes';" 2>/dev/null)
|
||||||
|
if [ -z "$LATEST" ]; then
|
||||||
|
echo "WARNING: No recent data in database"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "OK: All systems operational"
|
||||||
|
exit 0
|
||||||
11
scripts/run_test.sh
Normal file
11
scripts/run_test.sh
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run performance test inside Docker container
|
||||||
|
# Usage: ./run_test.sh [days] [interval]
|
||||||
|
|
||||||
|
DAYS=${1:-7}
|
||||||
|
INTERVAL=${2:-1m}
|
||||||
|
|
||||||
|
echo "Running MA44 performance test: ${DAYS} days of ${INTERVAL} data"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
docker exec btc_collector python scripts/test_ma44_performance.py --days $DAYS --interval $INTERVAL
|
||||||
187
scripts/test_ma44_performance.py
Normal file
187
scripts/test_ma44_performance.py
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Performance Test Script for MA44 Strategy
|
||||||
|
Tests backtesting performance on Synology DS218+ with 6GB RAM
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python test_ma44_performance.py [--days DAYS] [--interval INTERVAL]
|
||||||
|
|
||||||
|
Example:
|
||||||
|
python test_ma44_performance.py --days 7 --interval 1m
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
# Add src to path
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||||
|
|
||||||
|
from data_collector.database import DatabaseManager
|
||||||
|
from data_collector.indicator_engine import IndicatorEngine, IndicatorConfig
|
||||||
|
from data_collector.brain import Brain
|
||||||
|
from data_collector.backtester import Backtester
|
||||||
|
|
||||||
|
|
||||||
|
async def run_performance_test(days: int = 7, interval: str = "1m"):
|
||||||
|
"""Run MA44 backtest and measure performance"""
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"PERFORMANCE TEST: MA44 Strategy")
|
||||||
|
print(f"Timeframe: {interval}")
|
||||||
|
print(f"Period: Last {days} days")
|
||||||
|
print(f"Hardware: Synology DS218+ (6GB RAM)")
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Database connection (adjust these if needed)
|
||||||
|
db = DatabaseManager(
|
||||||
|
host=os.getenv('DB_HOST', 'localhost'),
|
||||||
|
port=int(os.getenv('DB_PORT', 5432)),
|
||||||
|
database=os.getenv('DB_NAME', 'btc_data'),
|
||||||
|
user=os.getenv('DB_USER', 'btc_bot'),
|
||||||
|
password=os.getenv('DB_PASSWORD', '')
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db.connect()
|
||||||
|
print("✓ Database connected")
|
||||||
|
|
||||||
|
# Calculate date range
|
||||||
|
end_date = datetime.now(timezone.utc)
|
||||||
|
start_date = end_date - timedelta(days=days)
|
||||||
|
|
||||||
|
print(f"✓ Date range: {start_date.date()} to {end_date.date()}")
|
||||||
|
print(f"✓ Symbol: BTC")
|
||||||
|
print(f"✓ Strategy: MA44 (44-period SMA)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Check data availability
|
||||||
|
async with db.acquire() as conn:
|
||||||
|
count = await conn.fetchval("""
|
||||||
|
SELECT COUNT(*) FROM candles
|
||||||
|
WHERE symbol = 'BTC'
|
||||||
|
AND interval = $1
|
||||||
|
AND time >= $2
|
||||||
|
AND time <= $3
|
||||||
|
""", interval, start_date, end_date)
|
||||||
|
|
||||||
|
print(f"📊 Data points: {count:,} {interval} candles")
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
print("❌ ERROR: No data found for this period!")
|
||||||
|
print(f" Run: python -m data_collector.backfill --days {days} --intervals {interval}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f" (Expected: ~{count * int(interval.replace('m','').replace('h','').replace('d',''))} minutes of data)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Setup indicator configuration
|
||||||
|
indicator_configs = [
|
||||||
|
IndicatorConfig("ma44", "sma", 44, [interval])
|
||||||
|
]
|
||||||
|
|
||||||
|
engine = IndicatorEngine(db, indicator_configs)
|
||||||
|
brain = Brain(db, engine)
|
||||||
|
backtester = Backtester(db, engine, brain)
|
||||||
|
|
||||||
|
print("⚙️ Running backtest...")
|
||||||
|
print("-" * 70)
|
||||||
|
|
||||||
|
# Measure execution time
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
await backtester.run("BTC", [interval], start_date, end_date)
|
||||||
|
|
||||||
|
end_time = time.time()
|
||||||
|
execution_time = end_time - start_time
|
||||||
|
|
||||||
|
print("-" * 70)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Fetch results from database
|
||||||
|
async with db.acquire() as conn:
|
||||||
|
latest_backtest = await conn.fetchrow("""
|
||||||
|
SELECT id, strategy, start_time, end_time, intervals, results, created_at
|
||||||
|
FROM backtest_runs
|
||||||
|
WHERE strategy LIKE '%ma44%'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""")
|
||||||
|
|
||||||
|
if latest_backtest and latest_backtest['results']:
|
||||||
|
import json
|
||||||
|
results = json.loads(latest_backtest['results'])
|
||||||
|
|
||||||
|
print("📈 RESULTS:")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f" Total Trades: {results.get('total_trades', 'N/A')}")
|
||||||
|
print(f" Win Rate: {results.get('win_rate', 0):.1f}%")
|
||||||
|
print(f" Win Count: {results.get('win_count', 0)}")
|
||||||
|
print(f" Loss Count: {results.get('loss_count', 0)}")
|
||||||
|
print(f" Total P&L: ${results.get('total_pnl', 0):.2f}")
|
||||||
|
print(f" P&L Percent: {results.get('total_pnl_pct', 0):.2f}%")
|
||||||
|
print(f" Initial Balance: ${results.get('initial_balance', 1000):.2f}")
|
||||||
|
print(f" Final Balance: ${results.get('final_balance', 1000):.2f}")
|
||||||
|
print(f" Max Drawdown: {results.get('max_drawdown', 0):.2f}%")
|
||||||
|
print()
|
||||||
|
print("⏱️ PERFORMANCE:")
|
||||||
|
print(f" Execution Time: {execution_time:.2f} seconds")
|
||||||
|
print(f" Candles/Second: {count / execution_time:.0f}")
|
||||||
|
print(f" Backtest ID: {latest_backtest['id']}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Performance assessment
|
||||||
|
if execution_time < 30:
|
||||||
|
print("✅ PERFORMANCE: Excellent (< 30s)")
|
||||||
|
elif execution_time < 60:
|
||||||
|
print("✅ PERFORMANCE: Good (< 60s)")
|
||||||
|
elif execution_time < 300:
|
||||||
|
print("⚠️ PERFORMANCE: Acceptable (1-5 min)")
|
||||||
|
else:
|
||||||
|
print("❌ PERFORMANCE: Slow (> 5 min) - Consider shorter periods or higher TFs")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("💡 RECOMMENDATIONS:")
|
||||||
|
if execution_time > 60:
|
||||||
|
print(" • For faster results, use higher timeframes (15m, 1h, 4h)")
|
||||||
|
print(" • Or reduce date range (< 7 days)")
|
||||||
|
else:
|
||||||
|
print(" • Hardware is sufficient for this workload")
|
||||||
|
print(" • Can handle larger date ranges or multiple timeframes")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("❌ ERROR: No results found in database!")
|
||||||
|
print(" The backtest may have failed. Check server logs.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ ERROR: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await db.disconnect()
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("Test completed")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Test MA44 backtest performance')
|
||||||
|
parser.add_argument('--days', type=int, default=7,
|
||||||
|
help='Number of days to backtest (default: 7)')
|
||||||
|
parser.add_argument('--interval', type=str, default='1m',
|
||||||
|
help='Candle interval (default: 1m)')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Run the async test
|
||||||
|
asyncio.run(run_performance_test(args.days, args.interval))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
87
scripts/update_schema.sh
Normal file
87
scripts/update_schema.sh
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Apply schema updates to a running TimescaleDB container without wiping data
|
||||||
|
|
||||||
|
echo "Applying schema updates to btc_timescale container..."
|
||||||
|
|
||||||
|
# Execute the schema SQL inside the container
|
||||||
|
# We use psql with the environment variables set in docker-compose
|
||||||
|
docker exec -i btc_timescale psql -U btc_bot -d btc_data <<EOF
|
||||||
|
-- 1. Unique constraint for indicators (if not exists)
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'indicators_unique') THEN
|
||||||
|
ALTER TABLE indicators ADD CONSTRAINT indicators_unique UNIQUE (time, symbol, interval, indicator_name);
|
||||||
|
END IF;
|
||||||
|
END \$\$;
|
||||||
|
|
||||||
|
-- 2. Index for indicators
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_indicators_lookup ON indicators (symbol, interval, indicator_name, time DESC);
|
||||||
|
|
||||||
|
-- 3. Data health view update
|
||||||
|
CREATE OR REPLACE VIEW data_health AS
|
||||||
|
SELECT
|
||||||
|
symbol,
|
||||||
|
COUNT(*) as total_candles,
|
||||||
|
COUNT(*) FILTER (WHERE validated) as validated_candles,
|
||||||
|
MAX(time) as latest_candle,
|
||||||
|
MIN(time) as earliest_candle,
|
||||||
|
NOW() - MAX(time) as time_since_last
|
||||||
|
FROM candles
|
||||||
|
GROUP BY symbol;
|
||||||
|
|
||||||
|
-- 4. Decisions table
|
||||||
|
CREATE TABLE IF NOT EXISTS decisions (
|
||||||
|
time TIMESTAMPTZ NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
interval TEXT NOT NULL,
|
||||||
|
decision_type TEXT NOT NULL,
|
||||||
|
strategy TEXT NOT NULL,
|
||||||
|
confidence DECIMAL(5,4),
|
||||||
|
price_at_decision DECIMAL(18,8),
|
||||||
|
indicator_snapshot JSONB NOT NULL,
|
||||||
|
candle_snapshot JSONB NOT NULL,
|
||||||
|
reasoning TEXT,
|
||||||
|
backtest_id TEXT,
|
||||||
|
executed BOOLEAN DEFAULT FALSE,
|
||||||
|
execution_price DECIMAL(18,8),
|
||||||
|
execution_time TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. Decisions hypertable (ignore error if already exists)
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
PERFORM create_hypertable('decisions', 'time', chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
NULL; -- Ignore if already hypertable
|
||||||
|
END \$\$;
|
||||||
|
|
||||||
|
-- 6. Decisions indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_live ON decisions (symbol, interval, time DESC) WHERE backtest_id IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_backtest ON decisions (backtest_id, symbol, time DESC) WHERE backtest_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions (symbol, decision_type, time DESC);
|
||||||
|
|
||||||
|
-- 7. Backtest runs table
|
||||||
|
CREATE TABLE IF NOT EXISTS backtest_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
strategy TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL DEFAULT 'BTC',
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ NOT NULL,
|
||||||
|
intervals TEXT[] NOT NULL,
|
||||||
|
config JSONB,
|
||||||
|
results JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 8. Compression policies
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE decisions SET (timescaledb.compress, timescaledb.compress_segmentby = 'symbol,interval,strategy');
|
||||||
|
PERFORM add_compression_policy('decisions', INTERVAL '7 days', if_not_exists => TRUE);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
NULL; -- Ignore compression errors if already set
|
||||||
|
END \$\$;
|
||||||
|
|
||||||
|
SELECT 'Schema update completed successfully' as status;
|
||||||
|
EOF
|
||||||
33
scripts/verify_files.sh
Normal file
33
scripts/verify_files.sh
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# BTC Bot Dashboard Setup Script
|
||||||
|
# Run this from ~/btc_bot to verify all files exist
|
||||||
|
|
||||||
|
echo "=== BTC Bot File Verification ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
FILES=(
|
||||||
|
"src/api/server.py"
|
||||||
|
"src/api/websocket_manager.py"
|
||||||
|
"src/api/dashboard/static/index.html"
|
||||||
|
"docker/Dockerfile.api"
|
||||||
|
"docker/Dockerfile.collector"
|
||||||
|
)
|
||||||
|
|
||||||
|
for file in "${FILES[@]}"; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null || echo "unknown")
|
||||||
|
echo "✓ $file (${size} bytes)"
|
||||||
|
else
|
||||||
|
echo "✗ $file (MISSING)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Next Steps ==="
|
||||||
|
echo "1. If all files exist, rebuild:"
|
||||||
|
echo " cd ~/btc_bot"
|
||||||
|
echo " docker build --network host --no-cache -f docker/Dockerfile.api -t btc_api ."
|
||||||
|
echo " cd docker && docker-compose up -d"
|
||||||
|
echo ""
|
||||||
|
echo "2. Check logs:"
|
||||||
|
echo " docker logs btc_api --tail 20"
|
||||||
408
src/strategies/ping_pong_bot.py
Normal file
408
src/strategies/ping_pong_bot.py
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import yaml
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.live import Live
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.layout import Layout
|
||||||
|
from rich import box
|
||||||
|
|
||||||
|
# Try to import pybit, if not available, we'll suggest installing it
|
||||||
|
try:
|
||||||
|
from pybit.unified_trading import HTTP
|
||||||
|
except ImportError:
|
||||||
|
print("Error: 'pybit' library not found. Please install it with: pip install pybit")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Setup Logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
filename='logs/ping_pong_bot.log'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("PingPongBot")
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
class PingPongBot:
|
||||||
|
def __init__(self, config_path="config/ping_pong_config.yaml"):
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
self.config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
self.api_key = os.getenv("API_KEY")
|
||||||
|
self.api_secret = os.getenv("API_SECRET")
|
||||||
|
|
||||||
|
if not self.api_key or not self.api_secret:
|
||||||
|
raise ValueError("API_KEY and API_SECRET must be set in .env file")
|
||||||
|
|
||||||
|
self.session = HTTP(
|
||||||
|
testnet=False,
|
||||||
|
api_key=self.api_key,
|
||||||
|
api_secret=self.api_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.symbol = self.config['symbol']
|
||||||
|
self.interval = self.config['interval']
|
||||||
|
self.direction = self.config['direction'].lower()
|
||||||
|
|
||||||
|
# State
|
||||||
|
self.last_candle_time = None
|
||||||
|
self.current_indicators = {}
|
||||||
|
self.position = None
|
||||||
|
self.wallet_balance = 0
|
||||||
|
self.status_msg = "Initializing..."
|
||||||
|
self.last_signal = None
|
||||||
|
self.start_time = datetime.now()
|
||||||
|
|
||||||
|
# Grid parameters from config
|
||||||
|
self.tp_pct = self.config['take_profit_pct'] / 100.0
|
||||||
|
self.partial_exit_pct = self.config['partial_exit_pct']
|
||||||
|
self.min_val_usd = self.config['min_position_value_usd']
|
||||||
|
self.pos_size_margin = self.config['pos_size_margin']
|
||||||
|
self.leverage = self.config['exchange_leverage']
|
||||||
|
self.max_eff_lev = self.config['max_effective_leverage']
|
||||||
|
|
||||||
|
def rma(self, series, length):
|
||||||
|
"""Rolling Moving Average (Wilder's Smoothing) - matches Pine Script ta.rma"""
|
||||||
|
alpha = 1 / length
|
||||||
|
return series.ewm(alpha=alpha, adjust=False).mean()
|
||||||
|
|
||||||
|
def calculate_indicators(self, df):
|
||||||
|
"""Calculate RSI and Hurst Bands matching the JS/Dashboard implementation"""
|
||||||
|
# 1. RSI
|
||||||
|
rsi_cfg = self.config['rsi']
|
||||||
|
delta = df['close'].diff()
|
||||||
|
gain = (delta.where(delta > 0, 0))
|
||||||
|
loss = (-delta.where(delta < 0, 0))
|
||||||
|
|
||||||
|
avg_gain = self.rma(gain, rsi_cfg['period'])
|
||||||
|
avg_loss = self.rma(loss, rsi_cfg['period'])
|
||||||
|
|
||||||
|
rs = avg_gain / avg_loss
|
||||||
|
df['rsi'] = 100 - (100 / (1 + rs))
|
||||||
|
|
||||||
|
# 2. Hurst Bands
|
||||||
|
hurst_cfg = self.config['hurst']
|
||||||
|
mcl_t = hurst_cfg['period']
|
||||||
|
mcm = hurst_cfg['multiplier']
|
||||||
|
|
||||||
|
mcl = mcl_t / 2
|
||||||
|
mcl_2 = int(round(mcl / 2))
|
||||||
|
|
||||||
|
# True Range
|
||||||
|
df['h_l'] = df['high'] - df['low']
|
||||||
|
df['h_pc'] = abs(df['high'] - df['close'].shift(1))
|
||||||
|
df['l_pc'] = abs(df['low'] - df['close'].shift(1))
|
||||||
|
df['tr'] = df[['h_l', 'h_pc', 'l_pc']].max(axis=1)
|
||||||
|
|
||||||
|
# RMA of Close and ATR
|
||||||
|
df['ma_mcl'] = self.rma(df['close'], mcl)
|
||||||
|
df['atr_mcl'] = self.rma(df['tr'], mcl)
|
||||||
|
|
||||||
|
# Historical Offset
|
||||||
|
df['center'] = df['ma_mcl'].shift(mcl_2)
|
||||||
|
# Fill first values where shift produces NaN with the MA itself (as done in JS: historical_ma || src)
|
||||||
|
df['center'] = df['center'].fillna(df['ma_mcl'])
|
||||||
|
|
||||||
|
mcm_off = mcm * df['atr_mcl']
|
||||||
|
df['hurst_upper'] = df['center'] + mcm_off
|
||||||
|
df['hurst_lower'] = df['center'] - mcm_off
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
async def fetch_data(self):
|
||||||
|
"""Fetch latest Klines from Bybit V5"""
|
||||||
|
try:
|
||||||
|
# We fetch 200 candles to ensure indicators stabilize
|
||||||
|
response = self.session.get_kline(
|
||||||
|
category="linear",
|
||||||
|
symbol=self.symbol,
|
||||||
|
interval=self.interval,
|
||||||
|
limit=200
|
||||||
|
)
|
||||||
|
|
||||||
|
if response['retCode'] != 0:
|
||||||
|
self.status_msg = f"API Error: {response['retMsg']}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
klines = response['result']['list']
|
||||||
|
# Bybit returns newest first, we need oldest first
|
||||||
|
df = pd.DataFrame(klines, columns=['start_time', 'open', 'high', 'low', 'close', 'volume', 'turnover'])
|
||||||
|
df = df.astype(float)
|
||||||
|
df = df.iloc[::-1].reset_index(drop=True)
|
||||||
|
|
||||||
|
return self.calculate_indicators(df)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching data: {e}")
|
||||||
|
self.status_msg = f"Fetch Error: {str(e)}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def update_account_info(self):
|
||||||
|
"""Update position and balance information"""
|
||||||
|
try:
|
||||||
|
# Get Position
|
||||||
|
pos_response = self.session.get_positions(
|
||||||
|
category="linear",
|
||||||
|
symbol=self.symbol
|
||||||
|
)
|
||||||
|
|
||||||
|
if pos_response['retCode'] == 0:
|
||||||
|
positions = pos_response['result']['list']
|
||||||
|
# Filter by side or just take the one with size > 0
|
||||||
|
active_pos = [p for p in positions if float(p['size']) > 0]
|
||||||
|
if active_pos:
|
||||||
|
self.position = active_pos[0]
|
||||||
|
else:
|
||||||
|
self.position = None
|
||||||
|
|
||||||
|
# Get Balance
|
||||||
|
wallet_response = self.session.get_wallet_balance(
|
||||||
|
category="linear",
|
||||||
|
coin="USDT"
|
||||||
|
)
|
||||||
|
|
||||||
|
if wallet_response['retCode'] == 0:
|
||||||
|
self.wallet_balance = float(wallet_response['result']['list'][0]['coin'][0]['walletBalance'])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating account info: {e}")
|
||||||
|
|
||||||
|
def check_signals(self, df):
|
||||||
|
"""Determine if we should Open or Close based on indicators"""
|
||||||
|
if len(df) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
last = df.iloc[-1]
|
||||||
|
prev = df.iloc[-2]
|
||||||
|
|
||||||
|
rsi_cfg = self.config['rsi']
|
||||||
|
hurst_cfg = self.config['hurst']
|
||||||
|
|
||||||
|
open_signal = False
|
||||||
|
close_signal = False
|
||||||
|
|
||||||
|
# 1. RSI Signals
|
||||||
|
rsi_buy = prev['rsi'] < rsi_cfg['oversold'] and last['rsi'] >= rsi_cfg['oversold']
|
||||||
|
rsi_sell = prev['rsi'] > rsi_cfg['overbought'] and last['rsi'] <= rsi_cfg['overbought']
|
||||||
|
|
||||||
|
# 2. Hurst Signals
|
||||||
|
hurst_buy = prev['close'] > prev['hurst_lower'] and last['close'] <= last['hurst_lower']
|
||||||
|
hurst_sell = prev['close'] > prev['hurst_upper'] and last['close'] <= last['hurst_upper']
|
||||||
|
|
||||||
|
# Logic for LONG
|
||||||
|
if self.direction == 'long':
|
||||||
|
if (rsi_cfg['enabled_for_open'] and rsi_buy) or (hurst_cfg['enabled_for_open'] and hurst_buy):
|
||||||
|
open_signal = True
|
||||||
|
if (rsi_cfg['enabled_for_close'] and rsi_sell) or (hurst_cfg['enabled_for_close'] and hurst_sell):
|
||||||
|
close_signal = True
|
||||||
|
# Logic for SHORT
|
||||||
|
else:
|
||||||
|
if (rsi_cfg['enabled_for_open'] and rsi_sell) or (hurst_cfg['enabled_for_open'] and hurst_sell):
|
||||||
|
open_signal = True
|
||||||
|
if (rsi_cfg['enabled_for_close'] and rsi_buy) or (hurst_cfg['enabled_for_close'] and hurst_buy):
|
||||||
|
close_signal = True
|
||||||
|
|
||||||
|
return "open" if open_signal else ("close" if close_signal else None)
|
||||||
|
|
||||||
|
async def execute_trade_logic(self, df, signal):
|
||||||
|
"""Apply the Ping-Pong strategy logic (Accumulation + TP)"""
|
||||||
|
last_price = float(df.iloc[-1]['close'])
|
||||||
|
|
||||||
|
# 1. Check Take Profit (TP)
|
||||||
|
if self.position:
|
||||||
|
avg_price = float(self.position['avgPrice'])
|
||||||
|
current_qty = float(self.position['size'])
|
||||||
|
|
||||||
|
is_tp = False
|
||||||
|
if self.direction == 'long':
|
||||||
|
if last_price >= avg_price * (1 + self.tp_pct):
|
||||||
|
is_tp = True
|
||||||
|
else:
|
||||||
|
if last_price <= avg_price * (1 - self.tp_pct):
|
||||||
|
is_tp = True
|
||||||
|
|
||||||
|
if is_tp:
|
||||||
|
qty_to_close = current_qty * self.partial_exit_pct
|
||||||
|
remaining_qty = current_qty - qty_to_close
|
||||||
|
|
||||||
|
# Min size check
|
||||||
|
if (remaining_qty * last_price) < self.min_val_usd:
|
||||||
|
qty_to_close = current_qty
|
||||||
|
self.status_msg = "TP: Closing Full Position (Min Size reached)"
|
||||||
|
else:
|
||||||
|
self.status_msg = f"TP: Closing Partial {self.partial_exit_pct*100}%"
|
||||||
|
|
||||||
|
self.place_order(qty_to_close, last_price, is_close=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Check Close Signal
|
||||||
|
if signal == "close" and self.position:
|
||||||
|
current_qty = float(self.position['size'])
|
||||||
|
qty_to_close = current_qty * self.partial_exit_pct
|
||||||
|
if (current_qty - qty_to_close) * last_price < self.min_val_usd:
|
||||||
|
qty_to_close = current_qty
|
||||||
|
|
||||||
|
self.status_msg = "Signal: Closing Position (Partial/Full)"
|
||||||
|
self.place_order(qty_to_close, last_price, is_close=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. Check Open/Accumulate Signal
|
||||||
|
if signal == "open":
|
||||||
|
# Check Max Effective Leverage
|
||||||
|
current_qty = float(self.position['size']) if self.position else 0
|
||||||
|
current_notional = current_qty * last_price
|
||||||
|
|
||||||
|
entry_notional = self.pos_size_margin * self.leverage
|
||||||
|
projected_notional = current_notional + entry_notional
|
||||||
|
|
||||||
|
effective_leverage = projected_notional / max(self.wallet_balance, 1.0)
|
||||||
|
|
||||||
|
if effective_leverage <= self.max_eff_lev:
|
||||||
|
qty_to_open = entry_notional / last_price
|
||||||
|
# Round qty based on symbol precision (simplified)
|
||||||
|
qty_to_open = round(qty_to_open, 3)
|
||||||
|
|
||||||
|
self.status_msg = f"Signal: Opening/Accumulating {qty_to_open} units"
|
||||||
|
self.place_order(qty_to_open, last_price, is_close=False)
|
||||||
|
else:
|
||||||
|
self.status_msg = f"Signal Ignored: Max Leverage {effective_leverage:.2f} > {self.max_eff_lev}"
|
||||||
|
|
||||||
|
def place_order(self, qty, price, is_close=False):
|
||||||
|
"""Send order to Bybit V5"""
|
||||||
|
side = ""
|
||||||
|
if self.direction == "long":
|
||||||
|
side = "Sell" if is_close else "Buy"
|
||||||
|
else:
|
||||||
|
side = "Buy" if is_close else "Sell"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.place_order(
|
||||||
|
category="linear",
|
||||||
|
symbol=self.symbol,
|
||||||
|
side=side,
|
||||||
|
orderType="Market",
|
||||||
|
qty=str(qty),
|
||||||
|
timeInForce="GTC",
|
||||||
|
reduceOnly=is_close
|
||||||
|
)
|
||||||
|
|
||||||
|
if response['retCode'] == 0:
|
||||||
|
logger.info(f"Order Placed: {side} {qty} {self.symbol}")
|
||||||
|
self.last_signal = f"{side} {qty} @ Market"
|
||||||
|
else:
|
||||||
|
logger.error(f"Order Failed: {response['retMsg']}")
|
||||||
|
self.status_msg = f"Order Error: {response['retMsg']}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Execution Error: {e}")
|
||||||
|
self.status_msg = f"Exec Error: {str(e)}"
|
||||||
|
|
||||||
|
def create_dashboard(self, df):
|
||||||
|
"""Create a Rich layout for status display"""
|
||||||
|
layout = Layout()
|
||||||
|
layout.split_column(
|
||||||
|
Layout(name="header", size=3),
|
||||||
|
Layout(name="main", ratio=1),
|
||||||
|
Layout(name="footer", size=3)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header_table = Table.grid(expand=True)
|
||||||
|
header_table.add_column(justify="left", ratio=1)
|
||||||
|
header_table.add_column(justify="right", ratio=1)
|
||||||
|
|
||||||
|
runtime = str(datetime.now() - self.start_time).split('.')[0]
|
||||||
|
header_table.add_row(
|
||||||
|
f"[bold cyan]Ping-Pong Bot v1.0[/bold cyan] | Symbol: [yellow]{self.symbol}[/yellow] | TF: [yellow]{self.interval}m[/yellow]",
|
||||||
|
f"Runtime: [green]{runtime}[/green] | Time: {datetime.now().strftime('%H:%M:%S')}"
|
||||||
|
)
|
||||||
|
layout["header"].update(Panel(header_table, style="white on blue"))
|
||||||
|
|
||||||
|
# Main Content
|
||||||
|
main_table = Table(box=box.SIMPLE, expand=True)
|
||||||
|
main_table.add_column("Category", style="cyan")
|
||||||
|
main_table.add_column("Value", style="white")
|
||||||
|
|
||||||
|
# Indicators
|
||||||
|
last = df.iloc[-1]
|
||||||
|
rsi_val = f"{last['rsi']:.2f}"
|
||||||
|
rsi_status = "[green]Oversold[/green]" if last['rsi'] < self.config['rsi']['oversold'] else ("[red]Overbought[/red]" if last['rsi'] > self.config['rsi']['overbought'] else "Neutral")
|
||||||
|
|
||||||
|
main_table.add_row("Price", f"{last['close']:.2f}")
|
||||||
|
main_table.add_row("RSI", f"{rsi_val} ({rsi_status})")
|
||||||
|
main_table.add_row("Hurst Upper", f"{last['hurst_upper']:.2f}")
|
||||||
|
main_table.add_row("Hurst Lower", f"{last['hurst_lower']:.2f}")
|
||||||
|
|
||||||
|
main_table.add_section()
|
||||||
|
|
||||||
|
# Position Info
|
||||||
|
if self.position:
|
||||||
|
size = self.position['size']
|
||||||
|
avg_p = self.position['avgPrice']
|
||||||
|
upnl = float(self.position['unrealisedPnl'])
|
||||||
|
upnl_style = "green" if upnl >= 0 else "red"
|
||||||
|
main_table.add_row("Position Size", f"{size}")
|
||||||
|
main_table.add_row("Avg Entry", f"{avg_p}")
|
||||||
|
main_table.add_row("Unrealized PnL", f"[{upnl_style}]{upnl:.2f} USDT[/{upnl_style}]")
|
||||||
|
else:
|
||||||
|
main_table.add_row("Position", "None")
|
||||||
|
|
||||||
|
main_table.add_row("Wallet Balance", f"{self.wallet_balance:.2f} USDT")
|
||||||
|
|
||||||
|
layout["main"].update(Panel(main_table, title="Current Status", border_style="cyan"))
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
footer_text = f"Status: [bold white]{self.status_msg}[/bold white]"
|
||||||
|
if self.last_signal:
|
||||||
|
footer_text += f" | Last Action: [yellow]{self.last_signal}[/yellow]"
|
||||||
|
|
||||||
|
layout["footer"].update(Panel(footer_text, border_style="yellow"))
|
||||||
|
|
||||||
|
return layout
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""Main loop"""
|
||||||
|
with Live(console=console, refresh_per_second=1) as live:
|
||||||
|
while True:
|
||||||
|
# 1. Update Account
|
||||||
|
await self.update_account_info()
|
||||||
|
|
||||||
|
# 2. Fetch Data & Calculate Indicators
|
||||||
|
df = await self.fetch_data()
|
||||||
|
|
||||||
|
if df is not None:
|
||||||
|
# 3. Check for New Candle (for signal processing)
|
||||||
|
current_time = df.iloc[-1]['start_time']
|
||||||
|
|
||||||
|
# 4. Strategy Logic
|
||||||
|
signal = self.check_signals(df)
|
||||||
|
await self.execute_trade_logic(df, signal)
|
||||||
|
|
||||||
|
# 5. Update UI
|
||||||
|
live.update(self.create_dashboard(df))
|
||||||
|
|
||||||
|
await asyncio.sleep(self.config.get('loop_interval_seconds', 5))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
bot = PingPongBot()
|
||||||
|
asyncio.run(bot.run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("\n[bold red]Bot Stopped by User[/bold red]")
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"\n[bold red]Critical Error: {e}[/bold red]")
|
||||||
|
logger.exception("Critical Error in main loop")
|
||||||
Reference in New Issue
Block a user