diff --git a/DOCKER_GUIDE.md b/DOCKER_GUIDE.md deleted file mode 100644 index 15973e0..0000000 --- a/DOCKER_GUIDE.md +++ /dev/null @@ -1,89 +0,0 @@ -# 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 # Temporarily stop -docker-compose start # 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 -``` diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 42c87e1..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,65 +0,0 @@ -# Gemini Context: BTC Trading Dashboard - -This project is a Bitcoin trading platform and automated bot system. It features a FastAPI backend, a real-time data collector, a PostgreSQL (TimescaleDB) database, and an interactive HTML/JS dashboard for technical analysis and strategy visualization. - -## Project Overview - -- **Purpose**: Real-time BTC data collection, technical indicator computation, and trading strategy execution/backtesting. -- **Core Technologies**: - - **Backend**: Python 3.9+ with FastAPI. - - **Frontend**: Vanilla HTML/JS with `lightweight-charts`. - - **Database**: PostgreSQL with TimescaleDB extension for time-series optimization. - - **Infrastructure**: Docker & Docker Compose. -- **Architecture**: - - `data_collector`: Handles WebSocket data ingestion and custom timeframe generation. - - `api_server`: Serves the dashboard and REST API for candle/indicator data. - - `indicator_engine`: Computes SMA, EMA, and specialized HTS indicators. - - `strategies`: Contains trading logic (e.g., Ping Pong bot, HTS strategy). - -## Building and Running - -### Local Setup -1. **Environment**: - ```bash - python -m venv venv - source venv/bin/activate # venv\Scripts\activate on Windows - pip install -r requirements.txt - ``` -2. **Configuration**: Create a `.env` file based on the project's requirements (see `README.md`). -3. **Database Test**: `python test_db.py` -4. **Run API Server**: `uvicorn src.api.server:app --reload --host 0.0.0.0 --port 8000` - -### Docker Deployment -- **Commands**: - - `docker-compose up -d` (from the `docker/` directory or root depending on setup). -- **Services**: `timescaledb`, `data_collector`, `api_server`, `ping_pong_bot`. - -## Key Files and Directories - -- `src/api/server.py`: FastAPI entry point and REST endpoints. -- `src/data_collector/main.py`: Data collection service logic. -- `src/data_collector/indicator_engine.py`: Technical indicator calculations (stateless math). -- `src/api/dashboard/static/`: Frontend assets (HTML, CSS, JS). -- `src/strategies/`: Directory for trading strategy implementations. -- `HTS_STRATEGY.md`: Detailed documentation for the "Higher Timeframe Trend System" strategy. -- `AGENTS.md`: Specific coding guidelines and standards for AI agents. - -## Development Conventions - -### Python Standards -- **Style**: Follow PEP 8; use Type Hints consistently. -- **Documentation**: Use Google-style docstrings for all public functions and classes. -- **Asynchrony**: Use `async`/`await` for all database (via `asyncpg`) and network operations. -- **Validation**: Use Pydantic models for data validation and settings. - -### Frontend Standards -- **Tech**: Vanilla CSS (Avoid Tailwind unless requested) and Vanilla JS. -- **Location**: Static files reside in `src/api/dashboard/static/`. - -### AI Coding Guidelines (from `AGENTS.md`) -- **Organization**: Place new code in corresponding modules (`api`, `data_collector`, `strategies`). -- **Error Handling**: Use explicit exceptions; log errors with context; never suppress silently. -- **Security**: Protect credentials; use environment variables; validate all inputs. - -## Strategy: HTS (Higher Timeframe Trend System) -The project emphasizes the **HTS strategy**, which uses fast (33) and slow (144) RMA channels to identify trends. Key rules include price position relative to Red (Slow) and Aqua (Fast) channels, and a 1H Red Zone filter for long trades. Refer to `HTS_STRATEGY.md` for full logic. diff --git a/HTS_STRATEGY.md b/HTS_STRATEGY.md deleted file mode 100644 index 1a8360b..0000000 --- a/HTS_STRATEGY.md +++ /dev/null @@ -1,79 +0,0 @@ -# HTS (Higher Timeframe Trend System) Strategy - -A trend-following strategy based on channel breakouts using fast and slow moving averages of High/Low prices. - -## Strategy Rules - -### 1. Core Trend Signal -- **Bullish Trend**: Price trading above the Red (Slow) Channel and Aqua (Fast) Channel is above Red Channel -- **Bearish Trend**: Price trading below the Red (Slow) Channel and Aqua (Fast) Channel is below Red Channel - -### 2. Entry Rules -- **Long Entry**: Wait for price to break above Slow Red Channel. Candle close above shorth (Fast Low line) while fast lines are above slow lines. -- **Short Entry**: Wait for price to break below Slow Red Channel. Look for close below shortl (Fast Low line) while fast lines are below slow lines. - -### 3. 1H Red Zone Filter -- Only take Longs if the price is above the 1H Red Zone (Slow Channel), regardless of fast line direction -- Can be disabled in configuration - -### 4. Stop Loss & Trailing Stop -- **Stop Loss**: Place on opposite side of Red (Slow) Channel - - Long stop: longl (Slow Low) line - - Short stop: slowh (Slow High) line -- **Trailing Stop**: As Red Channel moves, move stop loss accordingly - -### 5. RMA Default -- Uses RMA (Running Moving Average) by default - slower and smoother than EMA -- Designed for long-term trends, late to react to sudden crashes (feature, not bug) - -## Configuration Parameters - -| Parameter | Default | Range | Description | -|-----------|---------|-------|-------------| -| `shortPeriod` | 33 | 5-200 | Fast period for HTS | -| `longPeriod` | 144 | 10-500 | Slow period for HTS | -| `maType` | RMA | - | Moving average type (RMA/SMA/EMA/WMA/VWMA) | -| `useAutoHTS` | false | - | Compute HTS on timeframe/4 from 1m data | -| `use1HFilter` | true | - | Enable 1H Red Zone filter | - -## Usage - -1. Select "HTS Trend Strategy" from the strategies dropdown -2. Configure parameters: - - Periods: typically 33/144 for 15min-1hour charts - - Enable Auto HTS for multi-timeframe analysis - - Enable/disable 1H filter as needed -3. Run simulation to see backtesting results -4. View entry/exit markers on the chart - -## Visualization - -- **Cyan Lines**: Fast channels (33-period) -- **Red Lines**: Slow channels (144-period) -- **Green Arrows**: Buy signals (fast low crossover) -- **Red Arrows**: Sell signals (fast high crossover) -- **Background Shading**: Trend zones (green=bullish, red=bearish) - -## Signal Strength - -Pure HTS signals don't mix with other indicators. Signals are based solely on: -- Crossover detection -- Channel alignment -- Price position relative to channels -- Higher timeframe confirmation (1H filter if enabled) - -## Example Setup - -For a 15-minute chart: -- Fast Period: 33 -- Slow Period: 144 -- MA Type: RMA (default) -- Auto HTS: Disabled (or enable to see HTS on ~4-minute perspective) -- 1H Filter: Enabled (for better trade filtering) - -## Notes - -- This strategy is designed for trend-following, not ranging markets -- RMA is slower than EMA, giving smoother signals but later entries -- 1H filter significantly reduces false signals for long trades -- Works best in volatile but trending assets like BTC \ No newline at end of file diff --git a/RUN_SERVER.bat b/RUN_SERVER.bat deleted file mode 100644 index 5c39398..0000000 --- a/RUN_SERVER.bat +++ /dev/null @@ -1,17 +0,0 @@ -@echo off -title BTC Dashboard Server -cd /d "%~dp0" -echo =================================== -echo Starting BTC Dashboard Server -echo =================================== -echo. -echo Dashboard: http://localhost:8000/dashboard -echo API Docs: http://localhost:8000/docs -echo. -echo Press Ctrl+C to stop -echo =================================== -echo. - -call venv\Scripts\uvicorn src.api.server:app --host 0.0.0.0 --port 8000 --reload - -pause \ No newline at end of file diff --git a/config/data_config.yaml b/config/data_config.yaml deleted file mode 100644 index 5550f15..0000000 --- a/config/data_config.yaml +++ /dev/null @@ -1,94 +0,0 @@ -# Data Collection Configuration -data_collection: - # Primary data source - primary_exchange: "hyperliquid" - - # Assets to collect - assets: - cbBTC: - symbol: "cbBTC-PERP" - enabled: true - base_asset: "cbBTC" - quote_asset: "USD" - - # Validation settings - validation: - enabled: true - tolerance_percent: 1.0 # 1% price divergence allowed - check_interval_minutes: 5 - - # Reference sources for cross-validation - references: - uniswap_v3: - enabled: true - chain: "base" - pool_address: "0x4f1480ba4F40f2A41a718c8699E64976b222b56d" # cbBTC/USDC - rpc_url: "https://base-mainnet.g.alchemy.com/v2/YOUR_API_KEY" - - coinbase: - enabled: true - api_url: "https://api.exchange.coinbase.com" - - # Intervals to collect (1m is base, others computed) - intervals: - - "1m" # Base collection - indicators: - ma44: - type: "sma" - period: 44 - intervals: ["1d"] - ma125: - type: "sma" - period: 125 - intervals: ["1d"] - - # WebSocket settings - websocket: - url: "wss://api.hyperliquid.xyz/ws" - reconnect_attempts: 10 - reconnect_delays: [1, 2, 5, 10, 30, 60, 120, 300, 600, 900] # seconds - ping_interval: 30 - ping_timeout: 10 - - # Buffer settings - buffer: - max_size: 1000 # candles in memory - flush_interval_seconds: 30 - batch_size: 100 - - # Database settings - database: - host: "${DB_HOST}" - port: ${DB_PORT} - name: "${DB_NAME}" - user: "${DB_USER}" - password: "${DB_PASSWORD}" - pool_size: 5 - max_overflow: 10 - - # Backfill settings - backfill: - enabled: true - max_gap_minutes: 60 - rest_api_url: "https://api.hyperliquid.xyz/info" - - # Quality monitoring - quality_monitor: - enabled: true - check_interval_seconds: 300 # 5 minutes - anomaly_detection: - price_change_threshold: 0.10 # 10% - volume_spike_std: 5.0 # 5 sigma - -# Logging -logging: - level: "INFO" - format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - file: "/app/logs/collector.log" - max_size_mb: 100 - backup_count: 10 - -# Performance -performance: - max_cpu_percent: 80 - max_memory_mb: 256 \ No newline at end of file diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api deleted file mode 100644 index 8432790..0000000 --- a/docker/Dockerfile.api +++ /dev/null @@ -1,23 +0,0 @@ -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"] \ No newline at end of file diff --git a/docker/Dockerfile.bot b/docker/Dockerfile.bot index aa38b87..80f55e0 100644 --- a/docker/Dockerfile.bot +++ b/docker/Dockerfile.bot @@ -3,10 +3,10 @@ FROM python:3.11-slim WORKDIR /app # Copy requirements first -COPY requirements_bot.txt . +COPY requirements.txt . # Install dependencies -RUN pip install --no-cache-dir -r requirements_bot.txt +RUN pip install --no-cache-dir -r requirements.txt # Copy application code COPY src/ ./src/ diff --git a/docker/Dockerfile.collector b/docker/Dockerfile.collector deleted file mode 100644 index b165c20..0000000 --- a/docker/Dockerfile.collector +++ /dev/null @@ -1,21 +0,0 @@ -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"] \ No newline at end of file diff --git a/docker/Dockerfile.timescaledb b/docker/Dockerfile.timescaledb deleted file mode 100644 index b9d6f67..0000000 --- a/docker/Dockerfile.timescaledb +++ /dev/null @@ -1 +0,0 @@ -timescale/timescaledb:2.11.2-pg15 \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f00d61d..4566916 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,85 +1,6 @@ -# 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=20.20.20.20 - - 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 - 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=20.20.20.20 - - 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 - restart: unless-stopped - deploy: - resources: - limits: - memory: 512M - ping_pong_bot: build: context: .. diff --git a/docker/init-scripts/01-schema.sql b/docker/init-scripts/01-schema.sql deleted file mode 100644 index 3b2c466..0000000 --- a/docker/init-scripts/01-schema.sql +++ /dev/null @@ -1,199 +0,0 @@ --- 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; \ No newline at end of file diff --git a/docker/init-scripts/02-optimization.sql b/docker/init-scripts/02-optimization.sql deleted file mode 100644 index 4f81995..0000000 --- a/docker/init-scripts/02-optimization.sql +++ /dev/null @@ -1,43 +0,0 @@ --- 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 -); \ No newline at end of file diff --git a/docker/timescaledb.conf b/docker/timescaledb.conf deleted file mode 100644 index 00e9c83..0000000 --- a/docker/timescaledb.conf +++ /dev/null @@ -1,41 +0,0 @@ -# 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 \ No newline at end of file diff --git a/kill_port_8000.bat b/kill_port_8000.bat deleted file mode 100644 index a8e74fc..0000000 --- a/kill_port_8000.bat +++ /dev/null @@ -1,34 +0,0 @@ -@echo off -setlocal enabledelayedexpansion - -echo =================================== -echo Kill Process on Port 8000 -echo ===================================echo. - -REM Find PID using port 8000 -for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8000" ^| findstr "LISTENING"') do ( - set "PID=%%a" -) - -if "%PID%"=="" ( - echo No process found on port 8000 -) else ( - echo Found process PID: %PID% on port 8000 - taskkill /F /PID %PID% 2>nul - if %errorlevel% equ 0 ( - echo Process killed successfully - ) else ( - echo Failed to kill process - ) -) - -echo. -sleep 2 -netstat -ano | findstr ":8000" | findstr "LISTENING" -if %errorlevel% neq 0 ( - echo Port 8000 is now free -) else ( - echo Port 8000 still in use -) - -pause \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 50e3a71..3c4849c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,6 @@ -fastapi>=0.104.0 -uvicorn[standard]>=0.24.0 -asyncpg>=0.29.0 -aiohttp>=3.9.0 -websockets>=12.0 -pydantic>=2.5.0 -pydantic-settings>=2.1.0 -pyyaml>=6.0 -python-dotenv>=1.0.0 -python-multipart>=0.0.6 \ No newline at end of file +pybit +pandas +numpy +pyyaml +python-dotenv +rich diff --git a/requirements_bot.txt b/requirements_bot.txt deleted file mode 100644 index 3c4849c..0000000 --- a/requirements_bot.txt +++ /dev/null @@ -1,6 +0,0 @@ -pybit -pandas -numpy -pyyaml -python-dotenv -rich diff --git a/scripts/backfill.sh b/scripts/backfill.sh deleted file mode 100644 index 151f6f2..0000000 --- a/scripts/backfill.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/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 ===" diff --git a/scripts/backup.sh b/scripts/backup.sh deleted file mode 100644 index fddef8d..0000000 --- a/scripts/backup.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/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)" \ No newline at end of file diff --git a/scripts/check_db_stats.py b/scripts/check_db_stats.py deleted file mode 100644 index 9972280..0000000 --- a/scripts/check_db_stats.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/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()) diff --git a/scripts/check_status.sh b/scripts/check_status.sh deleted file mode 100644 index 17f0c68..0000000 --- a/scripts/check_status.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# Check the status of the indicators table (constraints and compression) - -docker exec -i btc_timescale psql -U btc_bot -d btc_data < /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" \ No newline at end of file diff --git a/scripts/fix_indicators_v2.sh b/scripts/fix_indicators_v2.sh deleted file mode 100644 index feaf7a8..0000000 --- a/scripts/fix_indicators_v2.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/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 < 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 diff --git a/scripts/generate_custom_history.py b/scripts/generate_custom_history.py deleted file mode 100644 index 141c7ef..0000000 --- a/scripts/generate_custom_history.py +++ /dev/null @@ -1,65 +0,0 @@ - -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()) diff --git a/scripts/generate_custom_timeframes.py b/scripts/generate_custom_timeframes.py deleted file mode 100644 index 8ad0ae9..0000000 --- a/scripts/generate_custom_timeframes.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/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) diff --git a/scripts/health_check.sh b/scripts/health_check.sh deleted file mode 100644 index b61d503..0000000 --- a/scripts/health_check.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/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 \ No newline at end of file diff --git a/scripts/run_test.sh b/scripts/run_test.sh deleted file mode 100644 index d529bd6..0000000 --- a/scripts/run_test.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/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 diff --git a/scripts/test_ma44_performance.py b/scripts/test_ma44_performance.py deleted file mode 100644 index 6bb968b..0000000 --- a/scripts/test_ma44_performance.py +++ /dev/null @@ -1,187 +0,0 @@ -#!/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() diff --git a/scripts/update_schema.sh b/scripts/update_schema.sh deleted file mode 100644 index 479a888..0000000 --- a/scripts/update_schema.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/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 < 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 diff --git a/scripts/verify_files.sh b/scripts/verify_files.sh deleted file mode 100644 index 5e906a9..0000000 --- a/scripts/verify_files.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/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" diff --git a/src/TV/HTS.pine b/src/TV/HTS.pine deleted file mode 100644 index 0470626..0000000 --- a/src/TV/HTS.pine +++ /dev/null @@ -1,86 +0,0 @@ -//@version=5 -indicator(title='HTS p1otek (Fixed)', overlay=true ) - -// Helper function to return the correct timeframe string for request.security -// Note: We let Pine Script infer the return type to avoid syntax errors -getAutoTFString(chartTFInMinutes) => - float autoTFMinutes = chartTFInMinutes / 4.0 - - // Use an existing time resolution string if possible (D, W, M) - if timeframe.isdaily - // 'D' timeframe is 1440 minutes. 1440 / 4 = 360 minutes (6 hours) - // We return "360" which Pine Script accepts as a resolution - str.tostring(math.round(autoTFMinutes)) - else if timeframe.isweekly or timeframe.ismonthly - // Cannot divide W or M timeframes reliably, return current timeframe string - timeframe.period - else - // For standard minute timeframes, use the calculated minutes - str.tostring(math.round(autoTFMinutes)) - -// Inputs -// FIXED: Changed input.integer to input.int -short = input.int(33, "fast") -long = input.int(144, "slow") -auto = input.bool(false, title = "auto HTS (timeframe/4)") -draw_1h = input.bool(false, title = "draw 1h slow HTS") - -metoda = input.string(title = "type average", defval = "RMA", options=["RMA", "EMA", "SMA", "WMA", "VWMA"]) - -// Calculate chart TF in minutes -float chartTFInMinutes = timeframe.in_seconds() / 60 -// Get the auto-calculated timeframe string -string autoTFString = getAutoTFString(chartTFInMinutes) - - -srednia(src, length, type) => - switch type - "RMA" => ta.rma(src, length) - "EMA" => ta.ema(src, length) - "SMA" => ta.sma(src, length) - "WMA" => ta.wma(src, length) - "VWMA" => ta.vwma(src, length) - -// === Non-Auto (Current Timeframe) Calculations === -string currentTFString = timeframe.period - -shortl = request.security(syminfo.tickerid, currentTFString, srednia(low, short, metoda)) -shorth = request.security(syminfo.tickerid, currentTFString, srednia(high, short, metoda)) -longl = request.security(syminfo.tickerid, currentTFString, srednia(low, long, metoda)) -longh = request.security(syminfo.tickerid, currentTFString, srednia(high, long, metoda)) - -// === Auto Timeframe Calculations === -shortl_auto = request.security(syminfo.tickerid, autoTFString, srednia(low, short, metoda)) -shorth_auto = request.security(syminfo.tickerid, autoTFString, srednia(high, short, metoda)) -longl_auto = request.security(syminfo.tickerid, autoTFString, srednia(low, long, metoda)) -longh_auto = request.security(syminfo.tickerid, autoTFString, srednia(high, long, metoda)) - -// === 1H Timeframe Calculations === -// Use a fixed '60' for 1 hour -longl_1h = request.security(syminfo.tickerid, "60", srednia(low, long, metoda)) -longh_1h = request.security(syminfo.tickerid, "60", srednia(high, long, metoda)) - - -// === Plotting === - -// Auto HTS -plot(auto ? shortl_auto: na, color=color.new(color.aqua, 0), linewidth=1, title="fast low auto") -plot(auto ? shorth_auto: na, color=color.new(color.aqua, 0), linewidth=1, title="fast high auto") -plot(auto ? longl_auto: na, color=color.new(color.red, 0), linewidth=1, title="slow low auto") -plot(auto ? longh_auto: na, color=color.new(color.red, 0), linewidth=1, title="slow high auto") - -// Current TF (only when Auto is enabled, for reference) -ll = plot( auto ? longl: na, color=color.new(color.red, 80), linewidth=1, title="current slow low") -oo = plot( auto ? longh: na, color=color.new(color.red, 80), linewidth=1, title="current slow high") -fill(ll,oo, color=color.new(color.red, 90)) - -// 1H Zone -zone_1hl = plot( draw_1h ? longl_1h: na, color=color.new(color.red, 80), linewidth=1, title="1h slow low") -zone_1hh = plot( draw_1h ? longh_1h: na, color=color.new(color.red, 80), linewidth=1, title="1h slow high") -fill(zone_1hl,zone_1hh, color=color.new(color.red, 90)) - -// Non-Auto HTS -plot(not auto ? shortl: na, color=color.new(color.aqua, 0), linewidth=1, title="fast low") -plot(not auto ? shorth: na, color=color.new(color.aqua, 0), linewidth=1, title="fast high") -plot(not auto ? longl: na, color=color.new(color.red, 0), linewidth=1, title="slow low") -plot(not auto ? longh: na, color=color.new(color.red, 0), linewidth=1, title="slow high") \ No newline at end of file diff --git a/src/api/dashboard/static/css/indicators-new.css b/src/api/dashboard/static/css/indicators-new.css deleted file mode 100644 index a67d85d..0000000 --- a/src/api/dashboard/static/css/indicators-new.css +++ /dev/null @@ -1,726 +0,0 @@ -/* ============================================================================ - NEW INDICATOR PANEL STYLES - Single Panel, TradingView-inspired - ============================================================================ */ - -.indicator-panel { - display: flex; - flex-direction: column; - height: 100%; - overflow-y: auto; - overflow-x: hidden; -} - -.subrbar::-webkit-scrollbar { - width: 6px; -} -.indicator-panel::-webkit-scrollbar-thumb { - background: #363a44; - border-radius: 3px; -} -.indicator-panel::-webkit-scrollbar-track { - background: transparent; -} - -/* Search Bar */ -.indicator-search { - display: flex; - align-items: center; - background: var(--tv-bg); - border: 1px solid var(--tv-border); - border-radius: 6px; - padding: 8px 12px; - margin: 8px 12px; - gap: 8px; - transition: border-color 0.2s; -} -.indicator-search:focus-within { - border-color: var(--tv-blue); -} -.search-icon { - color: var(--tv-text-secondary); - font-size: 14px; -} -.indicator-search input { - flex: 1; - background: transparent; - border: none; - color: var(--tv-text); - font-size: 13px; - outline: none; -} -.indicator-search input::placeholder { - color: var(--tv-text-secondary); -} -.search-clear { - background: transparent; - border: none; - color: var(--tv-text-secondary); - cursor: pointer; - padding: 2px 6px; - font-size: 16px; - line-height: 1; -} -.search-clear:hover { - color: var(--tv-text); -} - -/* Category Tabs */ -.category-tabs { - display: flex; - gap: 4px; - padding: 4px 12px; - overflow-x: auto; - scrollbar-width: none; -} -.category-tabs::-webkit-scrollbar { - display: none; -} -.category-tab { - background: transparent; - border: none; - color: var(--tv-text-secondary); - font-size: 11px; - padding: 6px 10px; - border-radius: 4px; - cursor: pointer; - white-space: nowrap; - transition: all 0.2s; -} -.category-tab:hover { - background: var(--tv-hover); - color: var(--tv-text); -} -.category-tab.active { - background: rgba(41, 98, 255, 0.1); - color: var(--tv-blue); - font-weight: 600; -} - -/* Indicator Sections */ -.indicator-section { - margin: 8px 12px 12px; -} -.indicator-section.favorites { - background: rgba(41, 98, 255, 0.05); - border-radius: 6px; - padding: 8px; - margin-top: 4px; -} -.section-title { - font-size: 10px; - color: var(--tv-text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - padding: 8px 0; - display: flex; - align-items: center; - gap: 5px; -} -.section-title button.clear-all, -.section-title button.visibility-toggle { - display: none; -} -.section-title:hover button.clear-all, -.section-title:hover button.visibility-toggle { - display: inline-block; -} -.visibility-toggle, -.clear-all { - background: var(--tv-red); - border: none; - color: white; - font-size: 9px; - padding: 2px 8px; - border-radius: 3px; - cursor: pointer; -} -.visibility-toggle { - background: var(--tv-blue); -} -.visibility-toggle:hover, -.clear-all:hover { - opacity: 0.9; -} - -/* Indicator Items */ -.indicator-item { - background: var(--tv-panel-bg); - border: 1px solid var(--tv-border); - border-radius: 6px; - margin-bottom: 2px; - transition: all 0.2s; - overflow: hidden; -} -.indicator-item:hover { - border-color: var(--tv-blue); -} -.indicator-item.favorite { - border-color: rgba(41, 98, 255, 0.3); -} - -.indicator-item-main { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 10px; - cursor: pointer; -} - -.indicator-name { - flex: 1; - font-size: 12px; - color: var(--tv-text); - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.indicator-desc { - font-size: 11px; - color: var(--tv-text-secondary); - margin-left: 8px; -} - -.indicator-actions { - display: flex; - gap: 4px; - margin-left: auto; -} - -.indicator-btn { - background: transparent; - border: 1px solid transparent; - color: var(--tv-text-secondary); - cursor: pointer; - width: 24px; - height: 24px; - border-radius: 4px; - font-size: 13px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.15s; - flex-shrink: 0; -} -.indicator-btn:hover { - background: var(--tv-hover); - color: var(--tv-text); - border-color: var(--tv-hover); -} -.indicator-btn.add:hover { - background: var(--tv-blue); - color: white; - border-color: var(--tv-blue); -} - -.indicator-presets { - display: none; -} -@media (min-width: 768px) { - .indicator-presets { - display: block; - } - .indicator-desc { - display: inline; - font-size: 11px; - color: var(--tv-text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 120px; - } -} - -/* Active Indicator Item */ -.indicator-item.active { - border-color: var(--tv-blue); -} - -.indicator-item.active .indicator-name { - color: var(--tv-blue); - font-weight: 600; -} - -.indicator-item.active.expanded { - border-color: var(--tv-blue); - background: rgba(41, 98, 255, 0.05); -} - -.drag-handle { - cursor: grab; - color: var(--tv-text-secondary); - font-size: 12px; - user-select: none; - padding: 0 2px; -} -.drag-handle:hover { - color: var(--tv-text); -} - -.indicator-btn.visible, -.indicator-btn.expand, -.indicator-btn.favorite { - width: 20px; - height: 20px; - font-size: 11px; -} -.indicator-btn.expand.rotated { - transform: rotate(180deg); -} - -/* Indicator Config (Expanded) */ -.indicator-config { - border-top: 1px solid var(--tv-border); - background: rgba(0, 0, 0, 0.2); - animation: slideDown 0.2s ease; -} - -@keyframes slideDown { - from { - opacity: 0; - max-height: 0; - } - to { - opacity: 1; - max-height: 1000px; - } -} - -.config-sections { - padding: 12px; -} - -.config-section { - margin-bottom: 16px; -} -.config-section:last-child { - margin-bottom: 0; -} - -.section-subtitle { - font-size: 10px; - color: var(--tv-text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 8px; - display: flex; - align-items: center; - gap: 8px; -} - -.preset-action-btn { - background: var(--tv-blue); - border: none; - color: white; - font-size: 9px; - padding: 2px 8px; - border-radius: 3px; - cursor: pointer; - margin-left: auto; -} -.preset-action-btn:hover { - opacity: 0.9; -} - -/* Config Row */ -.config-row { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; -} -.config-row label { - font-size: 11px; - color: var(--tv-text-secondary); - min-width: 80px; -} -.config-row select, -.config-row input[type="text"], -.config-row input[type="number"] { - flex: 1; - background: var(--tv-bg); - border: 1px solid var(--tv-border); - border-radius: 4px; - color: var(--tv-text); - font-size: 12px; - padding: 4px 8px; - min-width: 0; -} -.config-row select:focus, -.config-row input:focus { - outline: none; - border-color: var(--tv-blue); -} - -.input-with-preset { - display: flex; - align-items: center; - gap: 4px; - flex: 1; -} -.input-with-preset input { - flex: 1; -} -.presets-btn { - background: transparent; - border: 1px solid var(--tv-border); - color: var(--tv-text-secondary); - cursor: pointer; - padding: 4px 8px; - font-size: 10px; - border-radius: 3px; -} -.presets-btn:hover { - background: var(--tv-hover); -} - -/* Color Picker */ -.color-picker { - display: flex; - align-items: center; - gap: 8px; - flex: 1; -} -.color-picker input[type="color"] { - width: 32px; - height: 28px; - border: 1px solid var(--tv-border); - border-radius: 4px; - cursor: pointer; - padding: 0; - background: transparent; -} -.color-preview { - width: 16px; - height: 16px; - border-radius: 3px; - border: 1px solid var(--tv-border); -} - -/* Range Slider */ -.config-row input[type="range"] { - flex: 1; - accent-color: var(--tv-blue); -} - -/* Actions */ -.config-actions { - display: flex; - gap: 8px; - padding-top: 12px; - border-top: 1px solid var(--tv-border); -} -.btn-secondary { - flex: 1; - background: var(--tv-bg); - border: 1px solid var(--tv-border); - color: var(--tv-text); - padding: 6px 12px; - border-radius: 4px; - cursor: pointer; - font-size: 12px; -} -.btn-secondary:hover { - background: var(--tv-hover); -} -.btn-danger { - flex: 1; - background: var(--tv-red); - border: none; - color: white; - padding: 6px 12px; - border-radius: 4px; - cursor: pointer; - font-size: 12px; -} -.btn-danger:hover { - opacity: 0.9; -} - -/* No Results */ -.no-results { - text-align: center; - color: var(--tv-text-secondary); - padding: 40px 20px; - font-size: 12px; -} - -/* Presets List */ -.presets-list { - max-height: 200px; - overflow-y: auto; -} -.preset-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 8px; - border-radius: 4px; - cursor: pointer; - transition: background 0.15s; -} -.preset-item:hover { - background: var(--tv-hover); -} -.preset-item.applied { - background: rgba(38, 166, 154, 0.1); - border-radius: 4px; -} -.preset-label { - font-size: 11px; - color: var(--tv-text); -} -.preset-delete { - background: transparent; - border: none; - color: var(--tv-text-secondary); - cursor: pointer; - padding: 2px 6px; - font-size: 14px; - line-height: 1; -} -.preset-delete:hover { - color: var(--tv-red); -} - -.no-presets { - text-align: center; - color: var(--tv-text-secondary); - font-size: 10px; - padding: 8px; -} - -/* Range Value Display */ -.range-value { - font-size: 11px; - color: var(--tv-text); - min-width: 20px; -} - -/* Preset Indicator Button */ -.preset-indicator { - background: transparent; - border: 1px solid var(--tv-border); - color: var(--tv-text-secondary); - cursor: pointer; - padding: 2px 6px; - font-size: 10px; - border-radius: 3px; -} -.preset-indicator:hover { - background: var(--tv-hover); - border-color: var(--tv-blue); - color: var(--tv-blue); -} - -/* Mobile Responsive */ -@media (max-width: 767px) { - .category-tabs { - font-size: 10px; - padding: 4px 8px; - } - .category-tab { - padding: 4px 8px; - } - - .indicator-item-main { - padding: 6px 8px; - } - - .indicator-btn { - width: 20px; - height: 20px; - } - - .config-actions { - flex-direction: column; - } - - .config-row label { - min-width: 60px; - font-size: 10px; - } -} - -/* Touch-friendly styles for mobile */ -@media (hover: none) { - .indicator-btn { - min-width: 40px; - min-height: 40px; - } - - .category-tab { - padding: 10px 14px; - } - - .indicator-item-main { - padding: 12px; - } -} - -/* Dark theme improvements */ -@media (prefers-color-scheme: dark) { - .indicator-search { - background: #1e222d; - } - .indicator-item { - background: #1e222d; - } - .indicator-config { - background: rgba(0, 0, 0, 0.3); - } -} - -/* Animations */ -.indicator-item { - transition: all 0.2s ease; -} - -.indicator-config > * { - animation: fadeIn 0.2s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(-5px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Scrollbar styling for presets list */ -.presets-list::-webkit-scrollbar { - width: 4px; -} -.presets-list::-webkit-scrollbar-thumb { - background: var(--tv-border); - border-radius: 2px; -} - -/* Sidebar Tabs */ -.sidebar-tabs { - display: flex; - gap: 4px; - flex: 1; - margin-right: 8px; -} - -.sidebar-tab { - flex: 1; - background: transparent; - border: none; - color: var(--tv-text-secondary); - font-size: 11px; - padding: 6px 8px; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s; - white-space: nowrap; -} - -.sidebar-tab:hover { - background: var(--tv-hover); - color: var(--tv-text); -} - -.sidebar-tab.active { - background: rgba(41, 98, 255, 0.15); - color: var(--tv-blue); - font-weight: 600; -} - -/* Sidebar Tab Panels */ -.sidebar-tab-panel { - display: none; - animation: fadeIn 0.2s ease; -} - -.sidebar-tab-panel.active { - display: block; -} - -/* Collapsed sidebar adjustments */ -.right-sidebar.collapsed .sidebar-tabs { - display: none; -} - -/* Strategy Panel Styles */ -.indicator-checklist { - max-height: 120px; - overflow-y: auto; - background: var(--tv-bg); - border: 1px solid var(--tv-border); - border-radius: 4px; - padding: 4px; - margin-top: 4px; -} -.indicator-checklist::-webkit-scrollbar { - width: 4px; -} -.indicator-checklist::-webkit-scrollbar-thumb { - background: var(--tv-border); - border-radius: 2px; -} - -.checklist-item { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 8px; - font-size: 12px; - cursor: pointer; - border-radius: 3px; -} -.checklist-item:hover { - background: var(--tv-hover); -} -.checklist-item input { - cursor: pointer; -} - -.equity-chart-container { - width: 100%; - height: 150px; - margin-top: 12px; - border-radius: 4px; - overflow: hidden; - border: 1px solid var(--tv-border); - background: var(--tv-bg); -} - -.results-actions { - display: flex; - gap: 8px; - margin-top: 12px; -} - -.chart-toggle-group { - display: flex; - background: var(--tv-hover); - border-radius: 4px; - padding: 2px; -} - -.chart-toggle-group .toggle-btn { - padding: 2px 8px; - font-size: 10px; - border: none; - background: transparent; - color: var(--tv-text-secondary); - cursor: pointer; - border-radius: 3px; - transition: all 0.2s ease; -} - -.chart-toggle-group .toggle-btn.active { - background: var(--tv-border); - color: var(--tv-text); -} - -.chart-toggle-group .toggle-btn:hover:not(.active) { - color: var(--tv-text); -} \ No newline at end of file diff --git a/src/api/dashboard/static/index.html b/src/api/dashboard/static/index.html deleted file mode 100644 index db4d05b..0000000 --- a/src/api/dashboard/static/index.html +++ /dev/null @@ -1,1526 +0,0 @@ - - - - - - BTC Trading Dashboard - - - - - - -
-
- BTC/USD - -
- -
-
- -
-
- Live -
-
- -
-
- Price - -- -
-
- Change - -- -
-
- High - -- -
-
- Low - -- -
-
- -
-
-
-
-
- - - -
-
- - -
-
-
- -
- -
-
-
- Technical Analysis - 1D -
-
- -- - - -
-
-
-
Waiting for candle data...
-
-
-
- - - -
- - - - - - - diff --git a/src/api/dashboard/static/js/app.js b/src/api/dashboard/static/js/app.js deleted file mode 100644 index 839bb38..0000000 --- a/src/api/dashboard/static/js/app.js +++ /dev/null @@ -1,82 +0,0 @@ -import { TradingDashboard, refreshTA, openAIAnalysis } from './ui/chart.js'; -import { restoreSidebarState, toggleSidebar, initSidebarTabs, restoreSidebarTabState } from './ui/sidebar.js'; -import { - initIndicatorPanel, - getActiveIndicators, - setActiveIndicators, - drawIndicatorsOnChart, - addIndicator, - removeIndicatorById -} from './ui/indicators-panel-new.js'; -import { initStrategyPanel } from './ui/strategy-panel.js'; -import { IndicatorRegistry } from './indicators/index.js'; -import { TimezoneConfig } from './config/timezone.js'; - -window.dashboard = null; - -window.toggleSidebar = toggleSidebar; -window.refreshTA = refreshTA; -window.openAIAnalysis = openAIAnalysis; -window.TimezoneConfig = TimezoneConfig; -window.renderIndicatorList = function() { - // This function is no longer needed for sidebar indicators -}; - -// Export init function for global access -window.initIndicatorPanel = initIndicatorPanel; -window.addIndicator = addIndicator; -window.toggleIndicator = addIndicator; -window.drawIndicatorsOnChart = drawIndicatorsOnChart; -window.updateIndicatorCandles = drawIndicatorsOnChart; - -window.IndicatorRegistry = IndicatorRegistry; - -document.addEventListener('DOMContentLoaded', async () => { - // Attach toggle sidebar event listener - const toggleBtn = document.getElementById('sidebarToggleBtn'); - if (toggleBtn) { - toggleBtn.addEventListener('click', toggleSidebar); - } - - // Initialize timezone selector - const timezoneSelect = document.getElementById('timezoneSelect'); - const settingsPopup = document.getElementById('settingsPopup'); - const settingsBtn = document.getElementById('btnSettings'); - - if (timezoneSelect) { - timezoneSelect.value = TimezoneConfig.getTimezone(); - timezoneSelect.addEventListener('change', (e) => { - TimezoneConfig.setTimezone(e.target.value); - settingsPopup.classList.remove('show'); - // Redraw chart and indicators - if (window.dashboard) { - window.drawIndicatorsOnChart?.(); - } - }); - } - - // Toggle settings popup - if (settingsBtn && settingsPopup) { - settingsBtn.addEventListener('click', (e) => { - e.stopPropagation(); - settingsPopup.classList.toggle('show'); - }); - - settingsPopup.addEventListener('click', (e) => { - e.stopPropagation(); - }); - - document.addEventListener('click', () => { - settingsPopup.classList.remove('show'); - }); - } - - window.dashboard = new TradingDashboard(); - restoreSidebarState(); - restoreSidebarTabState(); - initSidebarTabs(); - - // Initialize panels - window.initIndicatorPanel(); - initStrategyPanel(); -}); diff --git a/src/api/dashboard/static/js/config/timezone.js b/src/api/dashboard/static/js/config/timezone.js deleted file mode 100644 index 6755f3c..0000000 --- a/src/api/dashboard/static/js/config/timezone.js +++ /dev/null @@ -1,76 +0,0 @@ -const TimezoneConfig = { - timezone: localStorage.getItem('timezone') || 'Europe/Warsaw', - - availableTimezones: [ - { value: 'UTC', label: 'UTC', offset: 0 }, - { value: 'Europe/London', label: 'London (GMT/BST)', offset: 0 }, - { value: 'Europe/Paris', label: 'Central Europe (CET/CEST)', offset: 1 }, - { value: 'Europe/Warsaw', label: 'Warsaw (CET/CEST)', offset: 1 }, - { value: 'America/New_York', label: 'New York (EST/EDT)', offset: -5 }, - { value: 'America/Chicago', label: 'Chicago (CST/CDT)', offset: -6 }, - { value: 'America/Los_Angeles', label: 'Los Angeles (PST/PDT)', offset: -8 }, - { value: 'Asia/Tokyo', label: 'Tokyo (JST)', offset: 9 }, - { value: 'Asia/Shanghai', label: 'Shanghai (CST)', offset: 8 }, - { value: 'Australia/Sydney', label: 'Sydney (AEST/AEDT)', offset: 10 }, - ], - - setTimezone(tz) { - this.timezone = tz; - localStorage.setItem('timezone', tz); - document.dispatchEvent(new CustomEvent('timezone-changed', { detail: tz })); - }, - - getTimezone() { - return this.timezone; - }, - - getOffsetHours(tz = this.timezone) { - const now = new Date(); - const tzDate = new Date(now.toLocaleString('en-US', { timeZone: tz })); - const utcDate = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' })); - return (tzDate - utcDate) / 3600000; - }, - - formatDate(timestamp) { - const date = new Date(timestamp); - const tz = this.timezone; - - const options = { - timeZone: tz, - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: false - }; - - const formatter = new Intl.DateTimeFormat('en-GB', options); - const parts = formatter.formatToParts(date); - const get = (type) => parts.find(p => p.type === type).value; - - return `${get('day')}/${get('month')}/${get('year')} ${get('hour')}:${get('minute')}`; - }, - - formatTickMark(timestamp) { - const date = new Date(timestamp * 1000); - const tz = this.timezone; - - const options = { - timeZone: tz, - year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', - hour12: false - }; - - const formatter = new Intl.DateTimeFormat('en-GB', options); - const parts = formatter.formatToParts(date); - const get = (type) => parts.find(p => p.type === type).value; - - // If it's exactly midnight, just show the date, otherwise show time too - const isMidnight = get('hour') === '00' && get('minute') === '00'; - if (isMidnight) { - return `${get('day')}/${get('month')}/${get('year')}`; - } - return `${get('day')}/${get('month')} ${get('hour')}:${get('minute')}`; - } -}; - -export { TimezoneConfig }; diff --git a/src/api/dashboard/static/js/core/constants.js b/src/api/dashboard/static/js/core/constants.js deleted file mode 100644 index d011415..0000000 --- a/src/api/dashboard/static/js/core/constants.js +++ /dev/null @@ -1,15 +0,0 @@ -export const INTERVALS = ['1m', '3m', '5m', '15m', '30m', '37m', '1h', '2h', '4h', '8h', '12h', '1d', '3d', '1w', '1M']; - -export const COLORS = { - tvBg: '#131722', - tvPanelBg: '#1e222d', - tvBorder: '#2a2e39', - tvText: '#d1d4dc', - tvTextSecondary: '#787b86', - tvGreen: '#26a69a', - tvRed: '#ef5350', - tvBlue: '#2962ff', - tvHover: '#2a2e39' -}; - -export const API_BASE = '/api/v1'; diff --git a/src/api/dashboard/static/js/core/index.js b/src/api/dashboard/static/js/core/index.js deleted file mode 100644 index 79fc52b..0000000 --- a/src/api/dashboard/static/js/core/index.js +++ /dev/null @@ -1 +0,0 @@ -export { INTERVALS, COLORS, API_BASE } from './constants.js'; diff --git a/src/api/dashboard/static/js/indicators/atr.js b/src/api/dashboard/static/js/indicators/atr.js deleted file mode 100644 index 0f45697..0000000 --- a/src/api/dashboard/static/js/indicators/atr.js +++ /dev/null @@ -1,118 +0,0 @@ -// Self-contained ATR indicator -// Includes math, metadata, signal calculation, and base class - -// Signal constants (defined in each indicator file) -const SIGNAL_TYPES = { - BUY: 'buy', - SELL: 'sell', - HOLD: 'hold' -}; - -const SIGNAL_COLORS = { - buy: '#26a69a', - hold: '#787b86', - sell: '#ef5350' -}; - -// Base class (inline replacement for BaseIndicator) -class BaseIndicator { - constructor(config) { - this.id = config.id; - this.type = config.type; - this.name = config.name; - this.params = config.params || {}; - this.timeframe = config.timeframe || '1m'; - this.series = []; - this.visible = config.visible !== false; - this.cachedResults = null; - this.cachedMeta = null; - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } -} - -// Signal calculation for ATR -function calculateATRSignal(indicator, lastCandle, prevCandle, values) { - const atr = values?.atr; - const close = lastCandle.close; - const prevClose = prevCandle?.close; - - if (!atr || atr === null || !prevClose) { - return null; - } - - const atrPercent = atr / close * 100; - const priceChange = Math.abs(close - prevClose); - const atrRatio = priceChange / atr; - - if (atrRatio > 1.5) { - return { - type: SIGNAL_TYPES.HOLD, - strength: 70, - value: atr, - reasoning: `High volatility: ATR (${atr.toFixed(2)}, ${atrPercent.toFixed(2)}%)` - }; - } - - return null; -} - -// ATR Indicator class -export class ATRIndicator extends BaseIndicator { - constructor(config) { - super(config); - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } - - calculate(candles) { - const period = this.params.period || 14; - const results = new Array(candles.length).fill(null); - const tr = new Array(candles.length).fill(0); - - for (let i = 1; i < candles.length; i++) { - const h_l = candles[i].high - candles[i].low; - const h_pc = Math.abs(candles[i].high - candles[i-1].close); - const l_pc = Math.abs(candles[i].low - candles[i-1].close); - tr[i] = Math.max(h_l, h_pc, l_pc); - } - - let atr = 0; - let sum = 0; - for (let i = 1; i <= period; i++) sum += tr[i]; - atr = sum / period; - results[period] = atr; - - for (let i = period + 1; i < candles.length; i++) { - atr = (atr * (period - 1) + tr[i]) / period; - results[i] = atr; - } - - return results.map(atr => ({ atr })); - } - - getMetadata() { - return { - name: 'ATR', - description: 'Average True Range - measures market volatility', - inputs: [{ - name: 'period', - label: 'Period', - type: 'number', - default: 14, - min: 1, - max: 100, - description: 'Period for ATR calculation' - }], - plots: [{ - id: 'value', - color: '#795548', - title: 'ATR', - lineWidth: 1 - }], - displayMode: 'pane' - }; - } -} - -export { calculateATRSignal }; \ No newline at end of file diff --git a/src/api/dashboard/static/js/indicators/bb.js b/src/api/dashboard/static/js/indicators/bb.js deleted file mode 100644 index ae1aa5c..0000000 --- a/src/api/dashboard/static/js/indicators/bb.js +++ /dev/null @@ -1,118 +0,0 @@ -// Self-contained Bollinger Bands indicator -// Includes math, metadata, signal calculation, and base class - -// Signal constants (defined in each indicator file) -const SIGNAL_TYPES = { - BUY: 'buy', - SELL: 'sell', - HOLD: 'hold' -}; - -const SIGNAL_COLORS = { - buy: '#26a69a', - hold: '#787b86', - sell: '#ef5350' -}; - -// Base class (inline replacement for BaseIndicator) -class BaseIndicator { - constructor(config) { - this.id = config.id; - this.type = config.type; - this.name = config.name; - this.params = config.params || {}; - this.timeframe = config.timeframe || '1m'; - this.series = []; - this.visible = config.visible !== false; - this.cachedResults = null; - this.cachedMeta = null; - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } -} - -// Signal calculation for Bollinger Bands -function calculateBollingerBandsSignal(indicator, lastCandle, prevCandle, values, prevValues) { - const close = lastCandle.close; - const prevClose = prevCandle?.close; - const upper = values?.upper; - const lower = values?.lower; - const prevUpper = prevValues?.upper; - const prevLower = prevValues?.lower; - - if (!upper || !lower || prevUpper === undefined || prevLower === undefined || prevClose === undefined) { - return null; - } - - // BUY: Price crosses DOWN through lower band (reversal/bounce play) - if (prevClose > prevLower && close <= lower) { - return { - type: SIGNAL_TYPES.BUY, - strength: 70, - value: close, - reasoning: `Price crossed DOWN through lower Bollinger Band` - }; - } - // SELL: Price crosses UP through upper band (overextended play) - else if (prevClose < prevUpper && close >= upper) { - return { - type: SIGNAL_TYPES.SELL, - strength: 70, - value: close, - reasoning: `Price crossed UP through upper Bollinger Band` - }; - } - - return null; -} - -// Bollinger Bands Indicator class -export class BollingerBandsIndicator extends BaseIndicator { - constructor(config) { - super(config); - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } - - calculate(candles) { - const period = this.params.period || 20; - const stdDevMult = this.params.stdDev || 2; - const results = new Array(candles.length).fill(null); - - for (let i = period - 1; i < candles.length; i++) { - let sum = 0; - for (let j = 0; j < period; j++) sum += candles[i-j].close; - const sma = sum / period; - - let diffSum = 0; - for (let j = 0; j < period; j++) diffSum += Math.pow(candles[i-j].close - sma, 2); - const stdDev = Math.sqrt(diffSum / period); - - results[i] = { - middle: sma, - upper: sma + (stdDevMult * stdDev), - lower: sma - (stdDevMult * stdDev) - }; - } - return results; - } - - getMetadata() { - return { - name: 'Bollinger Bands', - description: 'Volatility bands around a moving average', - inputs: [ - { name: 'period', label: 'Period', type: 'number', default: 20, min: 1, max: 100 }, - { name: 'stdDev', label: 'Std Dev', type: 'number', default: 2, min: 0.5, max: 5, step: 0.5 } - ], - plots: [ - { id: 'upper', color: '#4caf50', title: 'Upper' }, - { id: 'middle', color: '#4caf50', title: 'Middle', lineStyle: 2 }, - { id: 'lower', color: '#4caf50', title: 'Lower' } - ], - displayMode: 'overlay' - }; - } -} - -export { calculateBollingerBandsSignal }; \ No newline at end of file diff --git a/src/api/dashboard/static/js/indicators/hts.js b/src/api/dashboard/static/js/indicators/hts.js deleted file mode 100644 index df15521..0000000 --- a/src/api/dashboard/static/js/indicators/hts.js +++ /dev/null @@ -1,255 +0,0 @@ -// Self-contained HTS Trend System indicator -// Includes math, metadata, signal calculation, and base class - -// Signal constants (defined in each indicator file) -const SIGNAL_TYPES = { - BUY: 'buy', - SELL: 'sell', - HOLD: 'hold' -}; - -const SIGNAL_COLORS = { - buy: '#26a69a', - hold: '#787b86', - sell: '#ef5350' -}; - -// Base class (inline replacement for BaseIndicator) -class BaseIndicator { - constructor(config) { - this.id = config.id; - this.type = config.type; - this.name = config.name; - this.params = config.params || {}; - this.timeframe = config.timeframe || '1m'; - this.series = []; - this.visible = config.visible !== false; - this.cachedResults = null; - this.cachedMeta = null; - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } -} - -// MA calculations inline (SMA/EMA/RMA/WMA/VWMA) -function calculateSMA(candles, period, source = 'close') { - const results = new Array(candles.length).fill(null); - let sum = 0; - for (let i = 0; i < candles.length; i++) { - sum += candles[i][source]; - if (i >= period) sum -= candles[i - period][source]; - if (i >= period - 1) results[i] = sum / period; - } - return results; -} - -function calculateEMA(candles, period, source = 'close') { - const multiplier = 2 / (period + 1); - const results = new Array(candles.length).fill(null); - let ema = 0; - let sum = 0; - for (let i = 0; i < candles.length; i++) { - if (i < period) { - sum += candles[i][source]; - if (i === period - 1) { - ema = sum / period; - results[i] = ema; - } - } else { - ema = (candles[i][source] - ema) * multiplier + ema; - results[i] = ema; - } - } - return results; -} - -function calculateRMA(candles, period, source = 'close') { - const multiplier = 1 / period; - const results = new Array(candles.length).fill(null); - let rma = 0; - let sum = 0; - - for (let i = 0; i < candles.length; i++) { - if (i < period) { - sum += candles[i][source]; - if (i === period - 1) { - rma = sum / period; - results[i] = rma; - } - } else { - rma = (candles[i][source] - rma) * multiplier + rma; - results[i] = rma; - } - } - return results; -} - -function calculateWMA(candles, period, source = 'close') { - const results = new Array(candles.length).fill(null); - const weightSum = (period * (period + 1)) / 2; - - for (let i = period - 1; i < candles.length; i++) { - let sum = 0; - for (let j = 0; j < period; j++) { - sum += candles[i - j][source] * (period - j); - } - results[i] = sum / weightSum; - } - return results; -} - -function calculateVWMA(candles, period, source = 'close') { - const results = new Array(candles.length).fill(null); - - for (let i = period - 1; i < candles.length; i++) { - let sumPV = 0; - let sumV = 0; - for (let j = 0; j < period; j++) { - sumPV += candles[i - j][source] * candles[i - j].volume; - sumV += candles[i - j].volume; - } - results[i] = sumV !== 0 ? sumPV / sumV : null; - } - return results; -} - -// MA dispatcher function -function getMA(type, candles, period, source = 'close') { - switch (type.toUpperCase()) { - case 'SMA': return calculateSMA(candles, period, source); - case 'EMA': return calculateEMA(candles, period, source); - case 'RMA': return calculateRMA(candles, period, source); - case 'WMA': return calculateWMA(candles, period, source); - case 'VWMA': return calculateVWMA(candles, period, source); - default: return calculateSMA(candles, period, source); - } -} - -// Signal calculation for HTS -function calculateHTSSignal(indicator, lastCandle, prevCandle, values, prevValues) { - const slowLow = values?.slowLow; - const slowHigh = values?.slowHigh; - const prevSlowLow = prevValues?.slowLow; - const prevSlowHigh = prevValues?.slowHigh; - - if (!slowLow || !slowHigh || !prevSlowLow || !prevSlowHigh) { - return null; - } - - const close = lastCandle.close; - const prevClose = prevCandle?.close; - - if (prevClose === undefined) return null; - - // BUY: Price crosses UP through slow low - if (prevClose <= prevSlowLow && close > slowLow) { - return { - type: SIGNAL_TYPES.BUY, - strength: 85, - value: close, - reasoning: `Price crossed UP through slow low` - }; - } - // SELL: Price crosses DOWN through slow high - else if (prevClose >= prevSlowHigh && close < slowHigh) { - return { - type: SIGNAL_TYPES.SELL, - strength: 85, - value: close, - reasoning: `Price crossed DOWN through slow high` - }; - } - - return null; -} - -// HTS Indicator class -export class HTSIndicator extends BaseIndicator { - constructor(config) { - super(config); - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } - - calculate(candles, oneMinCandles = null, targetTF = null) { - const shortPeriod = this.params.short || 33; - const longPeriod = this.params.long || 144; - const maType = this.params.maType || 'RMA'; - const useAutoHTS = this.params.useAutoHTS || false; - - let workingCandles = candles; - - if (useAutoHTS && oneMinCandles && targetTF) { - const tfMultipliers = { - '5m': 5, - '15m': 15, - '30m': 30, - '37m': 37, - '1h': 60, - '4h': 240 - }; - - const tfGroup = tfMultipliers[targetTF] || 5; - - const grouped = []; - let currentGroup = []; - for (let i = 0; i < oneMinCandles.length; i++) { - currentGroup.push(oneMinCandles[i]); - if (currentGroup.length >= tfGroup) { - grouped.push({ - time: currentGroup[tfGroup - 1].time, - open: currentGroup[tfGroup - 1].open, - high: currentGroup[tfGroup - 1].high, - low: currentGroup[tfGroup - 1].low, - close: currentGroup[tfGroup - 1].close, - volume: currentGroup[tfGroup - 1].volume - }); - currentGroup = []; - } - } - - workingCandles = grouped; - } - - const shortHigh = getMA(maType, workingCandles, shortPeriod, 'high'); - const shortLow = getMA(maType, workingCandles, shortPeriod, 'low'); - const longHigh = getMA(maType, workingCandles, longPeriod, 'high'); - const longLow = getMA(maType, workingCandles, longPeriod, 'low'); - - return workingCandles.map((_, i) => ({ - fastHigh: shortHigh[i], - fastLow: shortLow[i], - slowHigh: longHigh[i], - slowLow: longLow[i], - fastMidpoint: ((shortHigh[i] || 0) + (shortLow[i] || 0)) / 2, - slowMidpoint: ((longHigh[i] || 0) + (longLow[i] || 0)) / 2 - })); - } - - getMetadata() { - const useAutoHTS = this.params?.useAutoHTS || false; - - const fastLineWidth = useAutoHTS ? 1 : 1; - const slowLineWidth = useAutoHTS ? 2 : 2; - - return { - name: 'HTS Trend System', - description: 'High/Low Trend System with Fast and Slow MAs', - inputs: [ - { name: 'short', label: 'Fast Period', type: 'number', default: 33, min: 1, max: 500 }, - { name: 'long', label: 'Slow Period', type: 'number', default: 144, min: 1, max: 500 }, - { name: 'maType', label: 'MA Type', type: 'select', options: ['SMA', 'EMA', 'RMA', 'WMA', 'VWMA'], default: 'RMA' }, - { name: 'useAutoHTS', label: 'Auto HTS (TF/4)', type: 'boolean', default: false } - ], - plots: [ - { id: 'fastHigh', color: '#00bcd4', title: 'Fast High', width: fastLineWidth }, - { id: 'fastLow', color: '#00bcd4', title: 'Fast Low', width: fastLineWidth }, - { id: 'slowHigh', color: '#f44336', title: 'Slow High', width: slowLineWidth }, - { id: 'slowLow', color: '#f44336', title: 'Slow Low', width: slowLineWidth } - ], - displayMode: 'overlay' - }; - } -} - -export { calculateHTSSignal }; \ No newline at end of file diff --git a/src/api/dashboard/static/js/indicators/hurst.js b/src/api/dashboard/static/js/indicators/hurst.js deleted file mode 100644 index a582b93..0000000 --- a/src/api/dashboard/static/js/indicators/hurst.js +++ /dev/null @@ -1,179 +0,0 @@ -// Self-contained Hurst Bands indicator -// Based on J.M. Hurst's cyclic price channel theory -// Using RMA + ATR displacement method - -const SIGNAL_TYPES = { - BUY: 'buy', - SELL: 'sell' -}; - -const SIGNAL_COLORS = { - buy: '#9e9e9e', - sell: '#9e9e9e' -}; - -class BaseIndicator { - constructor(config) { - this.id = config.id; - this.type = config.type; - this.name = config.name; - this.params = config.params || {}; - this.timeframe = config.timeframe || '1m'; - this.series = []; - this.visible = config.visible !== false; - this.cachedResults = null; - this.cachedMeta = null; - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } -} - -// Calculate RMA (Rolling Moving Average - Wilder's method) -// Recreates Pine Script's ta.rma() exactly -function calculateRMA(sourceArray, length) { - const rma = new Array(sourceArray.length).fill(null); - let sum = 0; - const alpha = 1 / length; - - // PineScript implicitly rounds float lengths for SMA initialization - const smaLength = Math.round(length); - - for (let i = 0; i < sourceArray.length; i++) { - if (i < smaLength - 1) { - // Accumulate first N-1 bars - sum += sourceArray[i]; - } else if (i === smaLength - 1) { - // On the Nth bar, the first RMA value is the SMA - sum += sourceArray[i]; - rma[i] = sum / smaLength; - } else { - // Subsequent bars use the RMA formula - const prevRMA = rma[i - 1]; - rma[i] = (prevRMA === null || isNaN(prevRMA)) - ? alpha * sourceArray[i] - : alpha * sourceArray[i] + (1 - alpha) * prevRMA; - } - } - - return rma; -} - -function calculateHurstSignal(indicator, lastCandle, prevCandle, values, prevValues) { - const close = lastCandle.close; - const prevClose = prevCandle?.close; - const upper = values?.upper; - const lower = values?.lower; - const prevUpper = prevValues?.upper; - const prevLower = prevValues?.lower; - - if (close === undefined || prevClose === undefined || !upper || !lower || !prevUpper || !prevLower) { - return null; - } - - // BUY: Price crosses DOWN through lower Hurst Band (dip entry) - if (prevClose > prevLower && close <= lower) { - return { - type: 'buy', - strength: 80, - value: close, - reasoning: `Price crossed DOWN through lower Hurst Band` - }; - } - - // SELL: Price crosses DOWN through upper Hurst Band (reversal entry) - if (prevClose > prevUpper && close <= upper) { - return { - type: 'sell', - strength: 80, - value: close, - reasoning: `Price crossed DOWN through upper Hurst Band` - }; - } - - return null; -} - -export class HurstBandsIndicator extends BaseIndicator { - constructor(config) { - super(config); - this.lastSignalTimestamp = null; - this.lastSignalType = null; - - if (!this.params.markerBuyShape) this.params.markerBuyShape = 'custom'; - if (!this.params.markerSellShape) this.params.markerSellShape = 'custom'; - if (!this.params.markerBuyColor) this.params.markerBuyColor = '#9e9e9e'; - if (!this.params.markerSellColor) this.params.markerSellColor = '#9e9e9e'; - if (!this.params.markerBuyCustom) this.params.markerBuyCustom = 'ā–²'; - if (!this.params.markerSellCustom) this.params.markerSellCustom = 'ā–¼'; - } - - calculate(candles) { - const mcl_t = this.params.period || 30; - const mcm = this.params.multiplier || 1.8; - - const mcl = mcl_t / 2; - - // FIX: PineScript rounds implicit floats for history references []. - // 15/2 = 7.5. Pine rounds this to 8. Math.floor gives 7. - const mcl_2 = Math.round(mcl / 2); - - const results = new Array(candles.length).fill(null); - const closes = candles.map(c => c.close); - - const trArray = candles.map((d, i) => { - const prevClose = i > 0 ? candles[i - 1].close : null; - const high = d.high; - const low = d.low; - - if (prevClose === null || prevClose === undefined || isNaN(prevClose)) { - return high - low; - } - return Math.max( - high - low, - Math.abs(high - prevClose), - Math.abs(low - prevClose) - ); - }); - - const ma_mcl = calculateRMA(closes, mcl); - const atr = calculateRMA(trArray, mcl); - - for (let i = 0; i < candles.length; i++) { - const src = closes[i]; - const mcm_off = mcm * (atr[i] || 0); - - const historicalIndex = i - mcl_2; - const historical_ma = historicalIndex >= 0 ? ma_mcl[historicalIndex] : null; - - const centerLine = (historical_ma === null || historical_ma === undefined || isNaN(historical_ma)) ? src : historical_ma; - - results[i] = { - upper: centerLine + mcm_off, - lower: centerLine - mcm_off - }; - } - - return results; - } - - getMetadata() { - return { - name: 'Hurst Bands', - description: 'Cyclic price channels based on Hurst theory', - inputs: [ - { name: 'period', label: 'Hurst Cycle Length (mcl_t)', type: 'number', default: 30, min: 5, max: 200 }, - { name: 'multiplier', label: 'Multiplier (mcm)', type: 'number', default: 1.8, min: 0.5, max: 10, step: 0.1 } - ], - plots: [ - { id: 'upper', color: '#808080', title: 'Upper', lineWidth: 1 }, - { id: 'lower', color: '#808080', title: 'Lower', lineWidth: 1 } - ], - bands: [ - { topId: 'upper', bottomId: 'lower', color: 'rgba(128, 128, 128, 0.05)' } - ], - displayMode: 'overlay' - }; - } -} - -export { calculateHurstSignal }; \ No newline at end of file diff --git a/src/api/dashboard/static/js/indicators/index.js b/src/api/dashboard/static/js/indicators/index.js deleted file mode 100644 index 4b88ce9..0000000 --- a/src/api/dashboard/static/js/indicators/index.js +++ /dev/null @@ -1,69 +0,0 @@ -// Indicator registry and exports for self-contained indicators - -// Import all indicator classes and their signal functions -export { MAIndicator, calculateMASignal } from './moving_average.js'; -export { MACDIndicator, calculateMACDSignal } from './macd.js'; -export { HTSIndicator, calculateHTSSignal } from './hts.js'; -export { RSIIndicator, calculateRSISignal } from './rsi.js'; -export { BollingerBandsIndicator, calculateBollingerBandsSignal } from './bb.js'; -export { StochasticIndicator, calculateStochSignal } from './stoch.js'; -export { ATRIndicator, calculateATRSignal } from './atr.js'; -export { HurstBandsIndicator, calculateHurstSignal } from './hurst.js'; - -// Import for registry -import { MAIndicator as MAI, calculateMASignal as CMA } from './moving_average.js'; -import { MACDIndicator as MACDI, calculateMACDSignal as CMC } from './macd.js'; -import { HTSIndicator as HTSI, calculateHTSSignal as CHTS } from './hts.js'; -import { RSIIndicator as RSII, calculateRSISignal as CRSI } from './rsi.js'; -import { BollingerBandsIndicator as BBI, calculateBollingerBandsSignal as CBB } from './bb.js'; -import { StochasticIndicator as STOCHI, calculateStochSignal as CST } from './stoch.js'; -import { ATRIndicator as ATRI, calculateATRSignal as CATR } from './atr.js'; -import { HurstBandsIndicator as HURSTI, calculateHurstSignal as CHURST } from './hurst.js'; - -// Signal function registry for easy dispatch -export const SignalFunctionRegistry = { - ma: CMA, - macd: CMC, - hts: CHTS, - rsi: CRSI, - bb: CBB, - stoch: CST, - atr: CATR, - hurst: CHURST -}; - -// Indicator registry for UI -export const IndicatorRegistry = { - ma: MAI, - macd: MACDI, - hts: HTSI, - rsi: RSII, - bb: BBI, - stoch: STOCHI, - atr: ATRI, - hurst: HURSTI -}; - -/** - * Get list of available indicators for the UI catalog - */ -export function getAvailableIndicators() { - return Object.entries(IndicatorRegistry).map(([type, IndicatorClass]) => { - const instance = new IndicatorClass({ type, params: {}, name: '' }); - const meta = instance.getMetadata(); - return { - type, - name: meta.name || type.toUpperCase(), - description: meta.description || '' - }; - }); -} - -/** - * Get signal function for an indicator type - * @param {string} indicatorType - The type of indicator (e.g., 'ma', 'rsi') - * @returns {Function|null} The signal calculation function or null if not found - */ -export function getSignalFunction(indicatorType) { - return SignalFunctionRegistry[indicatorType] || null; -} \ No newline at end of file diff --git a/src/api/dashboard/static/js/indicators/macd.js b/src/api/dashboard/static/js/indicators/macd.js deleted file mode 100644 index 71ff6ab..0000000 --- a/src/api/dashboard/static/js/indicators/macd.js +++ /dev/null @@ -1,153 +0,0 @@ -// Self-contained MACD indicator -// Includes math, metadata, signal calculation, and base class - -// Signal constants (defined in each indicator file) -const SIGNAL_TYPES = { - BUY: 'buy', - SELL: 'sell', - HOLD: 'hold' -}; - -const SIGNAL_COLORS = { - buy: '#26a69a', - hold: '#787b86', - sell: '#ef5350' -}; - -// Base class (inline replacement for BaseIndicator) -class BaseIndicator { - constructor(config) { - this.id = config.id; - this.type = config.type; - this.name = config.name; - this.params = config.params || {}; - this.timeframe = config.timeframe || '1m'; - this.series = []; - this.visible = config.visible !== false; - this.cachedResults = null; - this.cachedMeta = null; - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } -} - -// EMA calculation inline (needed for MACD) -function calculateEMAInline(data, period) { - const multiplier = 2 / (period + 1); - const ema = []; - - for (let i = 0; i < data.length; i++) { - if (i < period - 1) { - ema.push(null); - } else if (i === period - 1) { - ema.push(data[i]); - } else { - ema.push((data[i] - ema[i - 1]) * multiplier + ema[i - 1]); - } - } - - return ema; -} - -// Signal calculation for MACD -function calculateMACDSignal(indicator, lastCandle, prevCandle, values, prevValues) { - const macd = values?.macd; - const signal = values?.signal; - const prevMacd = prevValues?.macd; - const prevSignal = prevValues?.signal; - - if (macd === undefined || macd === null || signal === undefined || signal === null || - prevMacd === undefined || prevMacd === null || prevSignal === undefined || prevSignal === null) { - return null; - } - - // BUY: MACD crosses UP through Signal line - if (prevMacd <= prevSignal && macd > signal) { - return { - type: SIGNAL_TYPES.BUY, - strength: 80, - value: macd, - reasoning: `MACD crossed UP through Signal line` - }; - } - // SELL: MACD crosses DOWN through Signal line - else if (prevMacd >= prevSignal && macd < signal) { - return { - type: SIGNAL_TYPES.SELL, - strength: 80, - value: macd, - reasoning: `MACD crossed DOWN through Signal line` - }; - } - - return null; -} - -// MACD Indicator class -export class MACDIndicator extends BaseIndicator { - constructor(config) { - super(config); - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } - - calculate(candles) { - const fast = this.params.fast || 12; - const slow = this.params.slow || 26; - const signalPeriod = this.params.signal || 9; - - const closes = candles.map(c => c.close); - - // Use inline EMA calculation instead of MA.ema() - const fastEMA = calculateEMAInline(closes, fast); - const slowEMA = calculateEMAInline(closes, slow); - - const macdLine = fastEMA.map((f, i) => (f !== null && slowEMA[i] !== null) ? f - slowEMA[i] : null); - - let sum = 0; - let ema = 0; - let count = 0; - - const signalLine = macdLine.map(m => { - if (m === null) return null; - count++; - if (count < signalPeriod) { - sum += m; - return null; - } else if (count === signalPeriod) { - sum += m; - ema = sum / signalPeriod; - return ema; - } else { - ema = (m - ema) * (2 / (signalPeriod + 1)) + ema; - return ema; - } - }); - - return macdLine.map((m, i) => ({ - macd: m, - signal: signalLine[i], - histogram: (m !== null && signalLine[i] !== null) ? m - signalLine[i] : null - })); - } - - getMetadata() { - return { - name: 'MACD', - description: 'Moving Average Convergence Divergence - trend & momentum', - inputs: [ - { name: 'fast', label: 'Fast Period', type: 'number', default: 12 }, - { name: 'slow', label: 'Slow Period', type: 'number', default: 26 }, - { name: 'signal', label: 'Signal Period', type: 'number', default: 9 } - ], - plots: [ - { id: 'macd', color: '#2196f3', title: 'MACD' }, - { id: 'signal', color: '#ff5722', title: 'Signal' }, - { id: 'histogram', color: '#607d8b', title: 'Histogram', type: 'histogram' } - ], - displayMode: 'pane' - }; - } -} - -export { calculateMACDSignal }; \ No newline at end of file diff --git a/src/api/dashboard/static/js/indicators/moving_average.js b/src/api/dashboard/static/js/indicators/moving_average.js deleted file mode 100644 index 624ce69..0000000 --- a/src/api/dashboard/static/js/indicators/moving_average.js +++ /dev/null @@ -1,221 +0,0 @@ -// Self-contained Moving Average indicator with SMA/EMA/RMA/WMA/VWMA support -// Includes math, metadata, signal calculation, and base class - -// Signal constants (defined in each indicator file) -const SIGNAL_TYPES = { - BUY: 'buy', - SELL: 'sell', - HOLD: 'hold' -}; - -const SIGNAL_COLORS = { - buy: '#26a69a', - hold: '#787b86', - sell: '#ef5350' -}; - -// Base class (inline replacement for BaseIndicator) -class BaseIndicator { - constructor(config) { - this.id = config.id; - this.type = config.type; - this.name = config.name; - this.params = config.params || {}; - this.timeframe = config.timeframe || '1m'; - this.series = []; - this.visible = config.visible !== false; - this.cachedResults = null; - this.cachedMeta = null; - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } -} - -// Moving Average math (SMA/EMA/RMA/WMA/VWMA) -function calculateSMA(candles, period, source = 'close') { - const results = new Array(candles.length).fill(null); - let sum = 0; - for (let i = 0; i < candles.length; i++) { - sum += candles[i][source]; - if (i >= period) sum -= candles[i - period][source]; - if (i >= period - 1) results[i] = sum / period; - } - return results; -} - -function calculateEMA(candles, period, source = 'close') { - const multiplier = 2 / (period + 1); - const results = new Array(candles.length).fill(null); - let ema = 0; - let sum = 0; - for (let i = 0; i < candles.length; i++) { - if (i < period) { - sum += candles[i][source]; - if (i === period - 1) { - ema = sum / period; - results[i] = ema; - } - } else { - ema = (candles[i][source] - ema) * multiplier + ema; - results[i] = ema; - } - } - return results; -} - -function calculateRMA(candles, period, source = 'close') { - const multiplier = 1 / period; - const results = new Array(candles.length).fill(null); - let rma = 0; - let sum = 0; - - for (let i = 0; i < candles.length; i++) { - if (i < period) { - sum += candles[i][source]; - if (i === period - 1) { - rma = sum / period; - results[i] = rma; - } - } else { - rma = (candles[i][source] - rma) * multiplier + rma; - results[i] = rma; - } - } - return results; -} - -function calculateWMA(candles, period, source = 'close') { - const results = new Array(candles.length).fill(null); - const weightSum = (period * (period + 1)) / 2; - - for (let i = period - 1; i < candles.length; i++) { - let sum = 0; - for (let j = 0; j < period; j++) { - sum += candles[i - j][source] * (period - j); - } - results[i] = sum / weightSum; - } - return results; -} - -function calculateVWMA(candles, period, source = 'close') { - const results = new Array(candles.length).fill(null); - - for (let i = period - 1; i < candles.length; i++) { - let sumPV = 0; - let sumV = 0; - for (let j = 0; j < period; j++) { - sumPV += candles[i - j][source] * candles[i - j].volume; - sumV += candles[i - j].volume; - } - results[i] = sumV !== 0 ? sumPV / sumV : null; - } - return results; -} - -// Signal calculation for Moving Average -function calculateMASignal(indicator, lastCandle, prevCandle, values, prevValues) { - const close = lastCandle.close; - const prevClose = prevCandle?.close; - const ma = values?.ma; - const prevMa = prevValues?.ma; - - if (!ma && ma !== 0) return null; - if (prevClose === undefined || prevMa === undefined || prevMa === null) return null; - - // BUY: Price crosses UP through MA - if (prevClose <= prevMa && close > ma) { - return { - type: SIGNAL_TYPES.BUY, - strength: 80, - value: close, - reasoning: `Price crossed UP through MA` - }; - } - // SELL: Price crosses DOWN through MA - else if (prevClose >= prevMa && close < ma) { - return { - type: SIGNAL_TYPES.SELL, - strength: 80, - value: close, - reasoning: `Price crossed DOWN through MA` - }; - } - - return null; -} - -// MA Indicator class -export class MAIndicator extends BaseIndicator { - constructor(config) { - super(config); - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } - - calculate(candles) { - const maType = (this.params.maType || 'SMA').toLowerCase(); - const period = this.params.period || 44; - -let maValues; - - switch (maType) { - case 'sma': - maValues = calculateSMA(candles, period, this.params.source || 'close'); - break; - case 'ema': - maValues = calculateEMA(candles, period, this.params.source || 'close'); - break; - case 'rma': - maValues = calculateRMA(candles, period, this.params.source || 'close'); - break; - case 'wma': - maValues = calculateWMA(candles, period, this.params.source || 'close'); - break; - case 'vwma': - maValues = calculateVWMA(candles, period, this.params.source || 'close'); - break; - default: - maValues = calculateSMA(candles, period, this.params.source || 'close'); - } - - return maValues.map(ma => ({ ma })); - } - - getMetadata() { - return { - name: 'MA', - description: 'Moving Average (SMA/EMA/RMA/WMA/VWMA)', - inputs: [ - { - name: 'period', - label: 'Period', - type: 'number', - default: 44, - min: 1, - max: 500 - }, - { - name: 'maType', - label: 'MA Type', - type: 'select', - options: ['SMA', 'EMA', 'RMA', 'WMA', 'VWMA'], - default: 'SMA' - } - ], - plots: [ - { - id: 'ma', - color: '#2962ff', - title: 'MA', - style: 'solid', - width: 1 - } - ], - displayMode: 'overlay' - }; - } -} - -// Export signal function for external use -export { calculateMASignal }; \ No newline at end of file diff --git a/src/api/dashboard/static/js/indicators/rsi.js b/src/api/dashboard/static/js/indicators/rsi.js deleted file mode 100644 index a67a1d3..0000000 --- a/src/api/dashboard/static/js/indicators/rsi.js +++ /dev/null @@ -1,141 +0,0 @@ -// Self-contained RSI indicator -// Includes math, metadata, signal calculation, and base class - -// Signal constants (defined in each indicator file) -const SIGNAL_TYPES = { - BUY: 'buy', - SELL: 'sell', - HOLD: 'hold' -}; - -const SIGNAL_COLORS = { - buy: '#26a69a', - hold: '#787b86', - sell: '#ef5350' -}; - -// Base class (inline replacement for BaseIndicator) -class BaseIndicator { - constructor(config) { - this.id = config.id; - this.type = config.type; - this.name = config.name; - this.params = config.params || {}; - this.timeframe = config.timeframe || '1m'; - this.series = []; - this.visible = config.visible !== false; - this.cachedResults = null; - this.cachedMeta = null; - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } -} - -// Signal calculation for RSI -function calculateRSISignal(indicator, lastCandle, prevCandle, values, prevValues) { - const rsi = values?.rsi; - const prevRsi = prevValues?.rsi; - const overbought = indicator.params?.overbought || 70; - const oversold = indicator.params?.oversold || 30; - - if (rsi === undefined || rsi === null || prevRsi === undefined || prevRsi === null) { - return null; - } - - // BUY when RSI crosses UP through oversold level - if (prevRsi < oversold && rsi >= oversold) { - return { - type: SIGNAL_TYPES.BUY, - strength: 75, - value: rsi, - reasoning: `RSI crossed UP through oversold level (${oversold})` - }; - } - // SELL when RSI crosses DOWN through overbought level - else if (prevRsi > overbought && rsi <= overbought) { - return { - type: SIGNAL_TYPES.SELL, - strength: 75, - value: rsi, - reasoning: `RSI crossed DOWN through overbought level (${overbought})` - }; - } - - return null; -} - -// RSI Indicator class -export class RSIIndicator extends BaseIndicator { - constructor(config) { - super(config); - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } - - calculate(candles) { - const period = this.params.period || 14; - const overbought = this.params.overbought || 70; - const oversold = this.params.oversold || 30; - - // 1. Calculate RSI using RMA (Wilder's Smoothing) - let rsiValues = new Array(candles.length).fill(null); - let upSum = 0; - let downSum = 0; - const rmaAlpha = 1 / period; - - for (let i = 1; i < candles.length; i++) { - const diff = candles[i].close - candles[i-1].close; - const up = diff > 0 ? diff : 0; - const down = diff < 0 ? -diff : 0; - - if (i < period) { - upSum += up; - downSum += down; - } else if (i === period) { - upSum += up; - downSum += down; - const avgUp = upSum / period; - const avgDown = downSum / period; - rsiValues[i] = avgDown === 0 ? 100 : (avgUp === 0 ? 0 : 100 - (100 / (1 + avgUp / avgDown))); - upSum = avgUp; - downSum = avgDown; - } else { - upSum = (up - upSum) * rmaAlpha + upSum; - downSum = (down - downSum) * rmaAlpha + downSum; - rsiValues[i] = downSum === 0 ? 100 : (upSum === 0 ? 0 : 100 - (100 / (1 + upSum / downSum))); - } - } - - // Combine results - return rsiValues.map((rsi, i) => { - return { - paneBg: 80, - rsi: rsi, - overboughtBand: overbought, - oversoldBand: oversold - }; - }); - } - - getMetadata() { - return { - name: 'RSI', - description: 'Relative Strength Index', - inputs: [ - { name: 'period', label: 'RSI Length', type: 'number', default: 14, min: 1, max: 100 }, - { name: 'overbought', label: 'Overbought Level', type: 'number', default: 70, min: 50, max: 95 }, - { name: 'oversold', label: 'Oversold Level', type: 'number', default: 30, min: 5, max: 50 } - ], - plots: [ - { id: 'rsi', color: '#7E57C2', title: '', style: 'solid', width: 1, lastValueVisible: true }, - { id: 'overboughtBand', color: '#787B86', title: '', style: 'dashed', width: 1, lastValueVisible: false }, - { id: 'oversoldBand', color: '#787B86', title: '', style: 'dashed', width: 1, lastValueVisible: false } - ], - displayMode: 'pane', - paneMin: 0, - paneMax: 100 - }; - } -} - -export { calculateRSISignal }; \ No newline at end of file diff --git a/src/api/dashboard/static/js/indicators/stoch.js b/src/api/dashboard/static/js/indicators/stoch.js deleted file mode 100644 index 81ad0ef..0000000 --- a/src/api/dashboard/static/js/indicators/stoch.js +++ /dev/null @@ -1,139 +0,0 @@ -// Self-contained Stochastic Oscillator indicator -// Includes math, metadata, signal calculation, and base class - -// Signal constants (defined in each indicator file) -const SIGNAL_TYPES = { - BUY: 'buy', - SELL: 'sell', - HOLD: 'hold' -}; - -const SIGNAL_COLORS = { - buy: '#26a69a', - hold: '#787b86', - sell: '#ef5350' -}; - -// Base class (inline replacement for BaseIndicator) -class BaseIndicator { - constructor(config) { - this.id = config.id; - this.type = config.type; - this.name = config.name; - this.params = config.params || {}; - this.timeframe = config.timeframe || '1m'; - this.series = []; - this.visible = config.visible !== false; - this.cachedResults = null; - this.cachedMeta = null; - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } -} - -// Signal calculation for Stochastic -function calculateStochSignal(indicator, lastCandle, prevCandle, values, prevValues) { - const k = values?.k; - const d = values?.d; - const prevK = prevValues?.k; - const prevD = prevValues?.d; - const overbought = indicator.params?.overbought || 80; - const oversold = indicator.params?.oversold || 20; - - if (k === undefined || d === undefined || prevK === undefined || prevD === undefined) { - return null; - } - - // BUY: %K crosses UP through %D while both are oversold - if (prevK <= prevD && k > d && k < oversold) { - return { - type: SIGNAL_TYPES.BUY, - strength: 80, - value: k, - reasoning: `Stochastic %K crossed UP through %D in oversold zone` - }; - } - // SELL: %K crosses DOWN through %D while both are overbought - else if (prevK >= prevD && k < d && k > overbought) { - return { - type: SIGNAL_TYPES.SELL, - strength: 80, - value: k, - reasoning: `Stochastic %K crossed DOWN through %D in overbought zone` - }; - } - - return null; -} - -// Stochastic Oscillator Indicator class -export class StochasticIndicator extends BaseIndicator { - constructor(config) { - super(config); - this.lastSignalTimestamp = null; - this.lastSignalType = null; - } - - calculate(candles) { - const kPeriod = this.params.kPeriod || 14; - const dPeriod = this.params.dPeriod || 3; - const results = new Array(candles.length).fill(null); - - const kValues = new Array(candles.length).fill(null); - - for (let i = kPeriod - 1; i < candles.length; i++) { - let lowest = Infinity; - let highest = -Infinity; - for (let j = 0; j < kPeriod; j++) { - lowest = Math.min(lowest, candles[i-j].low); - highest = Math.max(highest, candles[i-j].high); - } - const diff = highest - lowest; - kValues[i] = diff === 0 ? 50 : ((candles[i].close - lowest) / diff) * 100; - } - - for (let i = kPeriod + dPeriod - 2; i < candles.length; i++) { - let sum = 0; - for (let j = 0; j < dPeriod; j++) sum += kValues[i-j]; - results[i] = { k: kValues[i], d: sum / dPeriod }; - } - - return results; - } - - getMetadata() { - return { - name: 'Stochastic', - description: 'Stochastic Oscillator - compares close to high-low range', - inputs: [ - { - name: 'kPeriod', - label: '%K Period', - type: 'number', - default: 14, - min: 1, - max: 100, - description: 'Lookback period for %K calculation' - }, - { - name: 'dPeriod', - label: '%D Period', - type: 'number', - default: 3, - min: 1, - max: 20, - description: 'Smoothing period for %D (SMA of %K)' - } - ], - plots: [ - { id: 'k', color: '#3f51b5', title: '%K', style: 'solid', width: 1 }, - { id: 'd', color: '#ff9800', title: '%D', style: 'solid', width: 1 } - ], - displayMode: 'pane', - paneMin: 0, - paneMax: 100 - }; - } -} - -export { calculateStochSignal }; \ No newline at end of file diff --git a/src/api/dashboard/static/js/ui/chart.js b/src/api/dashboard/static/js/ui/chart.js deleted file mode 100644 index 5e5e14a..0000000 --- a/src/api/dashboard/static/js/ui/chart.js +++ /dev/null @@ -1,975 +0,0 @@ -import { INTERVALS, COLORS } from '../core/index.js'; -import { calculateAllIndicatorSignals, calculateSummarySignal } from './signals-calculator.js'; -import { calculateSignalMarkers } from './signal-markers.js'; -import { updateIndicatorCandles } from './indicators-panel-new.js'; -import { TimezoneConfig } from '../config/timezone.js'; - -function formatDate(timestamp) { - return TimezoneConfig.formatDate(timestamp); -} - -export class TradingDashboard { -constructor() { - this.chart = null; - this.candleSeries = null; - this.currentInterval = '1d'; - this.intervals = INTERVALS; - this.allData = new Map(); - this.isLoading = false; - this.hasInitialLoad = false; - this.taData = null; - this.indicatorSignals = []; - this.summarySignal = null; - this.lastCandleTimestamp = null; - this.simulationMarkers = []; - this.avgPriceSeries = null; - this.dailyMAData = new Map(); // timestamp -> { ma44, ma125, price } - this.currentMouseTime = null; - - this.init(); - } - - async loadDailyMAData() { - try { - // Use 1d interval for this calculation - const interval = '1d'; - let candles = this.allData.get(interval); - - if (!candles || candles.length < 125) { - const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${interval}&limit=1000`); - const data = await response.json(); - if (data.candles && data.candles.length > 0) { - candles = data.candles.reverse().map(c => ({ - time: Math.floor(new Date(c.time).getTime() / 1000), - open: parseFloat(c.open), - high: parseFloat(c.high), - low: parseFloat(c.low), - close: parseFloat(c.close) - })); - this.allData.set(interval, candles); - } - } - - if (candles && candles.length >= 44) { - const ma44 = this.calculateSimpleSMA(candles, 44); - const ma125 = this.calculateSimpleSMA(candles, 125); - - this.dailyMAData.clear(); - candles.forEach((c, i) => { - this.dailyMAData.set(c.time, { - price: c.close, - ma44: ma44[i], - ma125: ma125[i] - }); - }); - } - } catch (error) { - console.error('[DailyMA] Error:', error); - } - } - - calculateSimpleSMA(candles, period) { - const results = new Array(candles.length).fill(null); - let sum = 0; - for (let i = 0; i < candles.length; i++) { - sum += candles[i].close; - if (i >= period) sum -= candles[i - period].close; - if (i >= period - 1) results[i] = sum / period; - } - return results; - } - - setSimulationMarkers(markers) { - this.simulationMarkers = markers || []; - this.updateSignalMarkers(); - } - - clearSimulationMarkers() { - this.simulationMarkers = []; - this.updateSignalMarkers(); - } - - setAvgPriceData(data) { - if (this.avgPriceSeries) { - this.avgPriceSeries.setData(data || []); - } - } - - clearAvgPriceData() { - if (this.avgPriceSeries) { - this.avgPriceSeries.setData([]); - } - } - - init() { - this.createTimeframeButtons(); - this.initChart(); - this.initEventListeners(); - this.loadInitialData(); - - setInterval(() => { - this.loadNewData(); - this.loadStats(); - if (new Date().getSeconds() < 15) this.loadTA(); - }, 10000); - } - - isAtRightEdge() { - const timeScale = this.chart.timeScale(); - const visibleRange = timeScale.getVisibleLogicalRange(); - if (!visibleRange) return true; - - const data = this.candleSeries.data(); - if (!data || data.length === 0) return true; - - return visibleRange.to >= data.length - 5; - } - - createTimeframeButtons() { - const container = document.getElementById('timeframeContainer'); - container.innerHTML = ''; - this.intervals.forEach(interval => { - const btn = document.createElement('button'); - btn.className = 'timeframe-btn'; - btn.dataset.interval = interval; - btn.textContent = interval; - if (interval === this.currentInterval) { - btn.classList.add('active'); - } - btn.addEventListener('click', () => this.switchTimeframe(interval)); - container.appendChild(btn); - }); - } - - initChart() { - const chartContainer = document.getElementById('chart'); - - this.chart = LightweightCharts.createChart(chartContainer, { - layout: { - background: { color: COLORS.tvBg }, - textColor: COLORS.tvText, - panes: { - background: { color: '#1e222d' }, - separatorColor: '#2a2e39', - separatorHoverColor: '#363c4e', - enableResize: true - } - }, - grid: { - vertLines: { color: '#363d4e' }, - horzLines: { color: '#363d4e' }, - }, - rightPriceScale: { - borderColor: '#363d4e', - autoScale: true, - }, - timeScale: { - borderColor: '#363d4e', - timeVisible: true, - secondsVisible: false, - rightOffset: 12, - barSpacing: 10, - tickMarkFormatter: (time, tickMarkType, locale) => { - return TimezoneConfig.formatTickMark(time); - }, - }, - localization: { - timeFormatter: (timestamp) => { - return TimezoneConfig.formatDate(timestamp * 1000); - }, - }, - handleScroll: { - vertTouchDrag: false, - }, - }); - - this.candleSeries = this.chart.addSeries(LightweightCharts.CandlestickSeries, { - upColor: '#ff9800', - downColor: '#ff9800', - borderUpColor: '#ff9800', - borderDownColor: '#ff9800', - wickUpColor: '#ff9800', - wickDownColor: '#ff9800', - lastValueVisible: false, - priceLineVisible: false, - }, 0); - - this.avgPriceSeries = this.chart.addSeries(LightweightCharts.LineSeries, { - color: '#00bcd4', - lineWidth: 1, - lineStyle: LightweightCharts.LineStyle.Solid, - lastValueVisible: true, - priceLineVisible: false, - crosshairMarkerVisible: false, - title: '', - }); - - this.currentPriceLine = this.candleSeries.createPriceLine({ - price: 0, - color: '#26a69a', - lineWidth: 1, - lineStyle: LightweightCharts.LineStyle.Dotted, - axisLabelVisible: true, - title: '', - }); - - this.initPriceScaleControls(); - this.initNavigationControls(); - - this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.onVisibleRangeChange.bind(this)); - - // Subscribe to crosshair movement for Best Moving Averages updates - this.chart.subscribeCrosshairMove(param => { - if (param.time) { - this.currentMouseTime = param.time; - this.renderTA(); - } else { - this.currentMouseTime = null; - this.renderTA(); - } - }); - - window.addEventListener('resize', () => { - this.chart.applyOptions({ - width: chartContainer.clientWidth, - height: chartContainer.clientHeight, - }); - }); - - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible') { - this.loadNewData(); - this.loadTA(); - } - }); - window.addEventListener('focus', () => { - this.loadNewData(); - this.loadTA(); - }); - } - - initPriceScaleControls() { - const btnAutoScale = document.getElementById('btnAutoScale'); - const btnLogScale = document.getElementById('btnLogScale'); - - if (!btnAutoScale || !btnLogScale) return; - - this.priceScaleState = { - autoScale: true, - logScale: false - }; - - btnAutoScale.addEventListener('click', () => { - this.priceScaleState.autoScale = !this.priceScaleState.autoScale; - btnAutoScale.classList.toggle('active', this.priceScaleState.autoScale); - - this.candleSeries.priceScale().applyOptions({ - autoScale: this.priceScaleState.autoScale - }); - - console.log('Auto Scale:', this.priceScaleState.autoScale ? 'ON' : 'OFF'); - }); - - btnLogScale.addEventListener('click', () => { - this.priceScaleState.logScale = !this.priceScaleState.logScale; - btnLogScale.classList.toggle('active', this.priceScaleState.logScale); - - let currentPriceRange = null; - let currentTimeRange = null; - if (!this.priceScaleState.autoScale) { - try { - currentPriceRange = this.candleSeries.priceScale().getVisiblePriceRange(); - } catch (e) { - console.log('Could not get price range'); - } - } - try { - currentTimeRange = this.chart.timeScale().getVisibleLogicalRange(); - } catch (e) { - console.log('Could not get time range'); - } - - this.candleSeries.priceScale().applyOptions({ - mode: this.priceScaleState.logScale ? LightweightCharts.PriceScaleMode.Logarithmic : LightweightCharts.PriceScaleMode.Normal - }); - - this.chart.applyOptions({}); - - setTimeout(() => { - if (currentTimeRange) { - try { - this.chart.timeScale().setVisibleLogicalRange(currentTimeRange); - } catch (e) { - console.log('Could not restore time range'); - } - } - - if (!this.priceScaleState.autoScale && currentPriceRange) { - try { - this.candleSeries.priceScale().setVisiblePriceRange(currentPriceRange); - } catch (e) { - console.log('Could not restore price range'); - } - } - }, 100); - - console.log('Log Scale:', this.priceScaleState.logScale ? 'ON' : 'OFF'); - }); - - document.addEventListener('keydown', (e) => { - if (e.key === 'a' || e.key === 'A') { - if (e.target.tagName !== 'INPUT') { - btnAutoScale.click(); - } - } - }); - } - - initNavigationControls() { - const chartWrapper = document.getElementById('chartWrapper'); - const navLeft = document.getElementById('navLeft'); - const navRight = document.getElementById('navRight'); - const navRecent = document.getElementById('navRecent'); - - if (!chartWrapper || !navLeft || !navRight || !navRecent) return; - - chartWrapper.addEventListener('mousemove', (e) => { - const rect = chartWrapper.getBoundingClientRect(); - const distanceFromBottom = rect.bottom - e.clientY; - chartWrapper.classList.toggle('show-nav', distanceFromBottom < 30); - }); - - chartWrapper.addEventListener('mouseleave', () => { - chartWrapper.classList.remove('show-nav'); - }); - - navLeft.addEventListener('click', () => this.navigateLeft()); - navRight.addEventListener('click', () => this.navigateRight()); - navRecent.addEventListener('click', () => this.navigateToRecent()); - } - - navigateLeft() { - const visibleRange = this.chart.timeScale().getVisibleLogicalRange(); - if (!visibleRange) return; - - const visibleBars = visibleRange.to - visibleRange.from; - const shift = visibleBars * 0.8; - const newFrom = visibleRange.from - shift; - const newTo = visibleRange.to - shift; - - this.chart.timeScale().setVisibleLogicalRange({ from: newFrom, to: newTo }); - } - - navigateRight() { - const visibleRange = this.chart.timeScale().getVisibleLogicalRange(); - if (!visibleRange) return; - - const visibleBars = visibleRange.to - visibleRange.from; - const shift = visibleBars * 0.8; - const newFrom = visibleRange.from + shift; - const newTo = visibleRange.to + shift; - - this.chart.timeScale().setVisibleLogicalRange({ from: newFrom, to: newTo }); - } - - navigateToRecent() { - this.chart.timeScale().scrollToRealTime(); - } - - initEventListeners() { - document.addEventListener('keydown', (e) => { - if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return; - - const shortcuts = { - '1': '1m', '2': '3m', '3': '5m', '4': '15m', '5': '30m', '7': '37m', - '6': '1h', '8': '4h', '9': '8h', '0': '12h', - 'd': '1d', 'D': '1d', 'w': '1w', 'W': '1w', 'm': '1M', 'M': '1M' - }; - - if (shortcuts[e.key]) { - this.switchTimeframe(shortcuts[e.key]); - } - - if (e.key === 'ArrowLeft') { - this.navigateLeft(); - } else if (e.key === 'ArrowRight') { - this.navigateRight(); - } else if (e.key === 'ArrowUp') { - this.navigateToRecent(); - } -}); - } - - clearIndicatorCaches(clearSignalState = false) { - const activeIndicators = window.getActiveIndicators?.() || []; - activeIndicators.forEach(indicator => { - // Always clear calculation caches - indicator.cachedResults = null; - indicator.cachedMeta = null; - - // Only clear signal state if explicitly requested (e.g., timeframe change) - // Do not clear on new candle completion - preserve signal change tracking - if (clearSignalState) { - indicator.lastSignalTimestamp = null; - indicator.lastSignalType = null; - } - }); - console.log(`[Dashboard] Cleared caches for ${activeIndicators.length} indicators (signals: ${clearSignalState})`); - } - - async loadInitialData() { - await Promise.all([ - this.loadData(2000, true), - this.loadStats(), - this.loadDailyMAData() - ]); - this.hasInitialLoad = true; - this.loadTA(); - } - - async loadData(limit = 1000, fitToContent = false) { - if (this.isLoading) return; - this.isLoading = true; - - try { - const visibleRange = this.chart.timeScale().getVisibleLogicalRange(); - - const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&limit=${limit}`); - const data = await response.json(); - -if (data.candles && data.candles.length > 0) { - const chartData = data.candles.reverse().map(c => ({ - time: Math.floor(new Date(c.time).getTime() / 1000), - open: parseFloat(c.open), - high: parseFloat(c.high), - low: parseFloat(c.low), - close: parseFloat(c.close), - volume: parseFloat(c.volume || 0) - })); - - const existingData = this.allData.get(this.currentInterval) || []; - const mergedData = this.mergeData(existingData, chartData); - this.allData.set(this.currentInterval, mergedData); - - this.candleSeries.setData(mergedData); - - if (fitToContent) { - this.chart.timeScale().scrollToRealTime(); - } else if (visibleRange) { - this.chart.timeScale().setVisibleLogicalRange(visibleRange); - } - - const latest = mergedData[mergedData.length - 1]; - this.updateStats(latest); - } - - window.drawIndicatorsOnChart?.(); - } catch (error) { - console.error('Error loading data:', error); - } finally { - this.isLoading = false; - } - } - -async loadNewData() { - if (!this.hasInitialLoad || this.isLoading) return; - - try { - const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&limit=50`); - const data = await response.json(); - - if (data.candles && data.candles.length > 0) { - const atEdge = this.isAtRightEdge(); - - const currentSeriesData = this.candleSeries.data(); - const lastTimestamp = currentSeriesData.length > 0 - ? currentSeriesData[currentSeriesData.length - 1].time - : 0; - - const chartData = data.candles.reverse().map(c => ({ - time: Math.floor(new Date(c.time).getTime() / 1000), - open: parseFloat(c.open), - high: parseFloat(c.high), - low: parseFloat(c.low), - close: parseFloat(c.close), - volume: parseFloat(c.volume || 0) - })); - - const latest = chartData[chartData.length - 1]; - - // Check if new candle detected - const isNewCandle = this.lastCandleTimestamp !== null && latest.time > this.lastCandleTimestamp; - - if (isNewCandle) { - console.log(`[NewData Load] New candle detected: ${this.lastCandleTimestamp} -> ${latest.time}`); - // Clear indicator caches but preserve signal state - this.clearIndicatorCaches(false); - } - - this.lastCandleTimestamp = latest.time; - - chartData.forEach(candle => { - if (candle.time >= lastTimestamp) { - this.candleSeries.update(candle); - } - }); - - const existingData = this.allData.get(this.currentInterval) || []; - this.allData.set(this.currentInterval, this.mergeData(existingData, chartData)); - - //console.log(`[NewData Load] Added ${chartData.length} new candles, total in dataset: ${this.allData.get(this.currentInterval).length}`); - - if (atEdge) { - this.chart.timeScale().scrollToRealTime(); - } - - this.updateStats(latest); - - //console.log('[Chart] Calling drawIndicatorsOnChart after new data'); - window.drawIndicatorsOnChart?.(); - window.updateIndicatorCandles?.(); - - this.loadDailyMAData(); - await this.loadSignals(); - } - } catch (error) { - console.error('Error loading new data:', error); - } - } - - mergeData(existing, newData) { - const dataMap = new Map(); - existing.forEach(c => dataMap.set(c.time, c)); - newData.forEach(c => dataMap.set(c.time, c)); - return Array.from(dataMap.values()).sort((a, b) => a.time - b.time); - } - -onVisibleRangeChange() { - if (!this.hasInitialLoad || this.isLoading) { - return; - } - - const visibleRange = this.chart.timeScale().getVisibleLogicalRange(); - if (!visibleRange) { - return; - } - - const data = this.candleSeries.data(); - const allData = this.allData.get(this.currentInterval); - - if (!data || data.length === 0) { - return; - } - - const visibleBars = Math.ceil(visibleRange.to - visibleRange.from); - const bufferSize = visibleBars * 2; - const refillThreshold = bufferSize * 0.8; - const barsFromLeft = Math.floor(visibleRange.from); - const visibleOldestTime = data[Math.floor(visibleRange.from)]?.time; - const visibleNewestTime = data[Math.ceil(visibleRange.to)]?.time; - - console.log(`[VisibleRange] Visible: ${visibleBars} bars (${data.length} in chart, ${allData?.length || 0} in dataset)`); - console.log(`[VisibleRange] Time range: ${new Date((visibleOldestTime || 0) * 1000).toLocaleDateString()} to ${new Date((visibleNewestTime || 0) * 1000).toLocaleDateString()}`); - - if (barsFromLeft < refillThreshold) { - console.log(`Buffer low (${barsFromLeft} < ${refillThreshold.toFixed(0)}), prefetching ${bufferSize} candles...`); - const oldestCandle = data[0]; - if (oldestCandle) { - this.loadHistoricalData(oldestCandle.time, bufferSize); - } - } - - // Recalculate indicators when data changes - if (data.length !== allData?.length) { - console.log(`[VisibleRange] Chart data (${data.length}) vs dataset (${allData?.length || 0}) differ, redrawing indicators...`); - } - - window.drawIndicatorsOnChart?.(); - this.loadSignals().catch(e => console.error('Error loading signals:', e)); - } - -async loadHistoricalData(beforeTime, limit = 1000) { - if (this.isLoading) { - return; - } - this.isLoading = true; - - console.log(`[Historical] Loading historical data before ${new Date(beforeTime * 1000).toLocaleDateString()}, limit=${limit}`); - - try { - const endTime = new Date((beforeTime - 1) * 1000); - - const response = await fetch( - `/api/v1/candles?symbol=BTC&interval=${this.currentInterval}&end=${endTime.toISOString()}&limit=${limit}` - ); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - - if (data.candles && data.candles.length > 0) { - const chartData = data.candles.reverse().map(c => ({ - time: Math.floor(new Date(c.time).getTime() / 1000), - open: parseFloat(c.open), - high: parseFloat(c.high), - low: parseFloat(c.low), - close: parseFloat(c.close), - volume: parseFloat(c.volume || 0) - })); - - const existingData = this.allData.get(this.currentInterval) || []; - const mergedData = this.mergeData(existingData, chartData); - this.allData.set(this.currentInterval, mergedData); - - console.log(`[Historical] SUCCESS: Added ${chartData.length} candles`); - console.log(`[Historical] Total candles in dataset: ${mergedData.length}`); - console.log(`[Historical] Oldest: ${new Date(mergedData[0]?.time * 1000).toLocaleDateString()}`); - console.log(`[Historical] Newest: ${new Date(mergedData[mergedData.length - 1]?.time * 1000).toLocaleDateString()}`); - - this.candleSeries.setData(mergedData); - - // Recalculate indicators and signals with the expanded dataset - console.log(`[Historical] Recalculating indicators...`); - window.drawIndicatorsOnChart?.(); - await this.loadSignals(); - - console.log(`[Historical] Indicators recalculated for ${mergedData.length} candles`); - } else { - console.log('[Historical] No more historical data available from database'); - } - } catch (error) { - console.error('[Historical] Error loading historical data:', error); - } finally { - this.isLoading = false; - } - } - -async loadTA() { - if (!this.hasInitialLoad) { - const time = new Date().toLocaleTimeString(); - document.getElementById('taContent').innerHTML = `
Loading technical analysis... ${time}
`; - return; - } - - try { - const response = await fetch(`/api/v1/ta?symbol=BTC&interval=${this.currentInterval}`); - const data = await response.json(); - - if (data.error) { - document.getElementById('taContent').innerHTML = `
${data.error}
`; - return; - } - - this.taData = data; - await this.loadSignals(); - this.renderTA(); - } catch (error) { - console.error('Error loading TA:', error); - document.getElementById('taContent').innerHTML = '
Failed to load technical analysis. Please check if the database has candle data.
'; - } - } - -async loadSignals() { - try { - this.indicatorSignals = calculateAllIndicatorSignals(); - this.summarySignal = calculateSummarySignal(this.indicatorSignals); - this.updateSignalMarkers(); - } catch (error) { - console.error('Error loading signals:', error); - this.indicatorSignals = []; - this.summarySignal = null; - } - } - - updateSignalMarkers() { - const candles = this.allData.get(this.currentInterval); - if (!candles || candles.length === 0) return; - - let markers = calculateSignalMarkers(candles); - - // Merge simulation markers if present - if (this.simulationMarkers && this.simulationMarkers.length > 0) { - markers = [...markers, ...this.simulationMarkers]; - } - - // CRITICAL: Filter out any markers with invalid timestamps before passing to chart - markers = markers.filter(m => m && m.time !== null && m.time !== undefined && !isNaN(m.time)); - - // Re-sort combined markers by time - markers.sort((a, b) => a.time - b.time); - - // If we have a marker controller, update markers through it - if (this.markerController) { - try { - this.markerController.setMarkers(markers); - return; - } catch (e) { - console.warn('[SignalMarkers] setMarkers error:', e.message); - this.markerController = null; - } - } - - // Clear price lines - if (this.markerPriceLines) { - this.markerPriceLines.forEach(ml => { - try { this.candleSeries.removePriceLine(ml); } catch (e) {} - }); - this.markerPriceLines = []; - } - - if (markers.length === 0) return; - - // Create new marker controller - if (typeof LightweightCharts.createSeriesMarkers === 'function') { - try { - this.markerController = LightweightCharts.createSeriesMarkers(this.candleSeries, markers); - return; - } catch (e) { - console.warn('[SignalMarkers] createSeriesMarkers error:', e.message); - } - } - - // Fallback: use price lines - this.addMarkerPriceLines(markers); - } - - addMarkerPriceLines(markers) { - if (this.markerPriceLines) { - this.markerPriceLines.forEach(ml => { - try { this.candleSeries.removePriceLine(ml); } catch (e) {} - }); - } - this.markerPriceLines = []; - - const recentMarkers = markers.slice(-20); - - recentMarkers.forEach(m => { - const isBuy = m.position === 'belowBar'; - const price = isBuy ? this.getMarkerLowPrice(m.time) : this.getMarkerHighPrice(m.time); - - const priceLine = this.candleSeries.createPriceLine({ - price: price, - color: m.color, - lineWidth: 2, - lineStyle: LightweightCharts.LineStyle.Dashed, - axisLabelVisible: true, - title: m.text - }); - - this.markerPriceLines.push(priceLine); - }); - } - - getMarkerLowPrice(time) { - const candles = this.allData.get(this.currentInterval); - const candle = candles?.find(c => c.time === time); - return candle ? candle.low * 0.995 : 0; - } - - getMarkerHighPrice(time) { - const candles = this.allData.get(this.currentInterval); - const candle = candles?.find(c => c.time === time); - return candle ? candle.high * 1.005 : 0; - } - - renderTA() { - if (!this.taData || this.taData.error) { - document.getElementById('taContent').innerHTML = `
${this.taData?.error || 'No data available'}
`; - return; - } - - const data = this.taData; - const trendClass = data.trend.direction.toLowerCase(); - const signalClass = data.trend.signal.toLowerCase(); - - document.getElementById('taInterval').textContent = this.currentInterval.toUpperCase(); - document.getElementById('taLastUpdate').textContent = new Date().toLocaleTimeString(); - - const summary = this.summarySignal || {}; - const summarySignalClass = summary.signal || 'hold'; - - const signalsHtml = this.indicatorSignals?.length > 0 ? this.indicatorSignals.map(indSignal => { - const signalIcon = indSignal.signal === 'buy' ? '🟢' : indSignal.signal === 'sell' ? 'šŸ”“' : '⚪'; - const signalColor = indSignal.signal === 'buy' ? '#26a69a' : indSignal.signal === 'sell' ? '#ef5350' : '#787b86'; - const lastSignalDate = indSignal.lastSignalDate ? formatDate(indSignal.lastSignalDate * 1000) : '-'; - - // Format params as "MA(44)" style - let paramsStr = ''; - if (indSignal.params !== null && indSignal.params !== undefined) { - paramsStr = `(${indSignal.params})`; - } - - return ` -
- ${indSignal.name}${paramsStr} - - ${signalIcon} ${indSignal.signal.toUpperCase()} - ${lastSignalDate} - -
- `; - }).join('') : ''; - - const summaryBadge = ''; - - // Best Moving Averages Logic (1D based) - let displayMA = { ma44: null, ma125: null, price: null, time: null }; - - if (this.currentMouseTime && this.dailyMAData.size > 0) { - // Find the 1D candle that includes this mouse time - const dayTimestamp = Math.floor(this.currentMouseTime / 86400) * 86400; - if (this.dailyMAData.has(dayTimestamp)) { - displayMA = { ...this.dailyMAData.get(dayTimestamp), time: dayTimestamp }; - } else { - // Fallback to latest if specific day not found - const keys = Array.from(this.dailyMAData.keys()).sort((a, b) => b - a); - const latestKey = keys[0]; - displayMA = { ...this.dailyMAData.get(latestKey), time: latestKey }; - } - } else if (this.dailyMAData.size > 0) { - const keys = Array.from(this.dailyMAData.keys()).sort((a, b) => b - a); - const latestKey = keys[0]; - displayMA = { ...this.dailyMAData.get(latestKey), time: latestKey }; - } - - const ma44Value = displayMA.ma44; - const ma125Value = displayMA.ma125; - const currentPrice = displayMA.price; - - const ma44Change = (ma44Value && currentPrice) ? ((currentPrice - ma44Value) / ma44Value * 100) : null; - const ma125Change = (ma125Value && currentPrice) ? ((currentPrice - ma125Value) / ma125Value * 100) : null; - const maDateStr = displayMA.time ? TimezoneConfig.formatDate(displayMA.time * 1000).split(' ')[0] : 'Latest'; - - document.getElementById('taContent').innerHTML = ` -
-
- Indicator Analysis - ${summaryBadge} -
- ${signalsHtml ? signalsHtml : `
No indicators selected. Add indicators from the sidebar panel to view signals.
`} -
- -
-
- Best Moving Averages - ${maDateStr} (1D) -
-
- MA 44 - - ${ma44Value ? ma44Value.toFixed(2) : 'N/A'} - ${ma44Change !== null ? `${ma44Change >= 0 ? '+' : ''}${ma44Change.toFixed(1)}%` : ''} - -
-
- MA 125 - - ${ma125Value ? ma125Value.toFixed(2) : 'N/A'} - ${ma125Change !== null ? `${ma125Change >= 0 ? '+' : ''}${ma125Change.toFixed(1)}%` : ''} - -
-
- -
-
Support / Resistance
-
- Resistance - ${data.levels.resistance.toFixed(2)} -
-
- Support - ${data.levels.support.toFixed(2)} -
-
- -
-
Price Position
-
-
-
-
- ${data.levels.position_in_range.toFixed(0)}% in range -
-
- `; - } - - renderSignalsSection() { - return ''; - } - - async loadStats() { - try { - const response = await fetch('/api/v1/stats?symbol=BTC'); - this.statsData = await response.json(); - } catch (error) { - console.error('Error loading stats:', error); - } - } - - updateStats(candle) { - const price = candle.close; - const isUp = candle.close >= candle.open; - - if (this.currentPriceLine) { - this.currentPriceLine.applyOptions({ - price: price, - color: isUp ? '#26a69a' : '#ef5350', - }); - } - - document.getElementById('currentPrice').textContent = price.toFixed(2); - - if (this.statsData) { - const change = this.statsData.change_24h; - document.getElementById('currentPrice').className = 'stat-value ' + (change >= 0 ? 'positive' : 'negative'); - document.getElementById('priceChange').textContent = (change >= 0 ? '+' : '') + change.toFixed(2) + '%'; - document.getElementById('priceChange').className = 'stat-value ' + (change >= 0 ? 'positive' : 'negative'); - document.getElementById('dailyHigh').textContent = this.statsData.high_24h.toFixed(2); - document.getElementById('dailyLow').textContent = this.statsData.low_24h.toFixed(2); -} - } - -switchTimeframe(interval) { - if (!this.intervals.includes(interval) || interval === this.currentInterval) return; - - const oldInterval = this.currentInterval; - this.currentInterval = interval; - this.hasInitialLoad = false; - - document.querySelectorAll('.timeframe-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.interval === interval); - }); - - // Clear indicator caches and signal state before switching timeframe - this.clearIndicatorCaches(true); - - // Clear old interval data, not new interval - this.allData.delete(oldInterval); - this.lastCandleTimestamp = null; - - this.loadInitialData(); - - window.clearSimulationResults?.(); - window.updateTimeframeDisplay?.(); - } -} - -export function refreshTA() { - if (window.dashboard) { - const time = new Date().toLocaleTimeString(); - document.getElementById('taContent').innerHTML = `
Refreshing... ${time}
`; - window.dashboard.loadTA(); - } -} - -export function openAIAnalysis() { - const symbol = 'BTC'; - const interval = window.dashboard?.currentInterval || '1d'; - const prompt = `Analyze Bitcoin (${symbol}) ${interval} chart. Current trend, support/resistance levels, and trading recommendation. Technical indicators: MA44, MA125.`; - - const geminiUrl = `https://gemini.google.com/app?prompt=${encodeURIComponent(prompt)}`; - window.open(geminiUrl, '_blank'); -} diff --git a/src/api/dashboard/static/js/ui/hts-visualizer.js b/src/api/dashboard/static/js/ui/hts-visualizer.js deleted file mode 100644 index bebc3dc..0000000 --- a/src/api/dashboard/static/js/ui/hts-visualizer.js +++ /dev/null @@ -1,231 +0,0 @@ -const HTS_COLORS = { - fastHigh: '#00bcd4', - fastLow: '#00bcd4', - slowHigh: '#f44336', - slowLow: '#f44336', - bullishZone: 'rgba(38, 166, 154, 0.1)', - bearishZone: 'rgba(239, 83, 80, 0.1)', - channelRegion: 'rgba(41, 98, 255, 0.05)' -}; - -let HTSOverlays = []; - -export class HTSVisualizer { - constructor(chart, candles) { - this.chart = chart; - this.candles = candles; - this.overlays = []; - } - - clear() { - this.overlays.forEach(overlay => { - try { - this.chart.removeSeries(overlay.series); - } catch (e) { } - }); - this.overlays = []; - } - - addHTSChannels(htsData, isAutoHTS = false) { - this.clear(); - - if (!htsData || htsData.length === 0) return; - - const alpha = isAutoHTS ? 0.3 : 0.3; - const lineWidth = isAutoHTS ? 1 : 2; - - const fastHighSeries = this.chart.addSeries(LightweightCharts.LineSeries, { - color: `rgba(0, 188, 212, ${alpha})`, - lineWidth: lineWidth, - lastValueVisible: false, - title: 'HTS Fast High' + (isAutoHTS ? ' (Auto)' : ''), - priceLineVisible: false, - crosshairMarkerVisible: false - }); - - const fastLowSeries = this.chart.addSeries(LightweightCharts.LineSeries, { - color: `rgba(0, 188, 212, ${alpha})`, - lineWidth: lineWidth, - lastValueVisible: false, - title: 'HTS Fast Low' + (isAutoHTS ? ' (Auto)' : ''), - priceLineVisible: false, - crosshairMarkerVisible: false - }); - - const slowHighSeries = this.chart.addSeries(LightweightCharts.LineSeries, { - color: `rgba(244, 67, 54, ${alpha})`, - lineWidth: lineWidth + 1, - lastValueVisible: false, - title: 'HTS Slow High' + (isAutoHTS ? ' (Auto)' : ''), - priceLineVisible: false, - crosshairMarkerVisible: false - }); - - const slowLowSeries = this.chart.addSeries(LightweightCharts.LineSeries, { - color: `rgba(244, 67, 54, ${alpha})`, - lineWidth: lineWidth + 1, - lastValueVisible: false, - title: 'HTS Slow Low' + (isAutoHTS ? ' (Auto)' : ''), - priceLineVisible: false, - crosshairMarkerVisible: false - }); - - const fastHighData = htsData.map(h => ({ time: h.time, value: h.fastHigh })); - const fastLowData = htsData.map(h => ({ time: h.time, value: h.fastLow })); - const slowHighData = htsData.map(h => ({ time: h.time, value: h.slowHigh })); - const slowLowData = htsData.map(h => ({ time: h.time, value: h.slowLow })); - - fastHighSeries.setData(fastHighData); - fastLowSeries.setData(fastLowData); - slowHighSeries.setData(slowHighData); - slowLowSeries.setData(slowLowData); - - this.overlays.push( - { series: fastHighSeries, name: 'fastHigh' }, - { series: fastLowSeries, name: 'fastLow' }, - { series: slowHighSeries, name: 'slowHigh' }, - { series: slowLowSeries, name: 'slowLow' } - ); - - return { - fastHigh: fastHighSeries, - fastLow: fastLowSeries, - slowHigh: slowHighSeries, - slowLow: slowLowSeries - }; - } - - addTrendZones(htsData) { - if (!htsData || htsData.length < 2) return; - - const trendZones = []; - let currentZone = null; - - for (let i = 1; i < htsData.length; i++) { - const prev = htsData[i - 1]; - const curr = htsData[i]; - - const prevBullish = prev.fastLow > prev.slowLow && prev.fastHigh > prev.slowHigh; - const currBullish = curr.fastLow > curr.slowLow && curr.fastHigh > curr.slowHigh; - - const prevBearish = prev.fastLow < prev.slowLow && prev.fastHigh < prev.slowHigh; - const currBearish = curr.fastLow < curr.slowLow && curr.fastHigh < curr.slowHigh; - - if (currBullish && !prevBullish) { - currentZone = { type: 'bullish', start: curr.time }; - } else if (currBearish && !prevBearish) { - currentZone = { type: 'bearish', start: curr.time }; - } else if (!currBullish && !currBearish && currentZone) { - currentZone.end = prev.time; - trendZones.push({ ...currentZone }); - currentZone = null; - } - } - - if (currentZone) { - currentZone.end = htsData[htsData.length - 1].time; - trendZones.push(currentZone); - } - - trendZones.forEach(zone => { - const zoneSeries = this.chart.addSeries(LightweightCharts.AreaSeries, { - topColor: zone.type === 'bullish' ? 'rgba(38, 166, 154, 0.02)' : 'rgba(239, 83, 80, 0.02)', - bottomColor: zone.type === 'bullish' ? 'rgba(38, 166, 154, 0.02)' : 'rgba(239, 83, 80, 0.02)', - lineColor: 'transparent', - lastValueVisible: false, - priceLineVisible: false, - }); - - if (this.candles && this.candles.length > 0) { - const maxPrice = Math.max(...this.candles.map(c => c.high)) * 2; - const minPrice = Math.min(...this.candles.map(c => c.low)) * 0.5; - - const startTime = zone.start || (this.candles[0]?.time); - const endTime = zone.end || (this.candles[this.candles.length - 1]?.time); - - zoneSeries.setData([ - { time: startTime, value: minPrice }, - { time: startTime, value: maxPrice }, - { time: endTime, value: maxPrice }, - { time: endTime, value: minPrice } - ]); - } - - this.overlays.push({ series: zoneSeries, name: `trendZone_${zone.type}_${zone.start}` }); - }); - } - - addCrossoverMarkers(htsData) { - if (!htsData || htsData.length < 2) return; - - const markers = []; - - for (let i = 1; i < htsData.length; i++) { - const prev = htsData[i - 1]; - const curr = htsData[i]; - - if (!prev || !curr) continue; - - const price = curr.price; - - const prevFastLow = prev.fastLow; - const currFastLow = curr.fastLow; - const prevFastHigh = prev.fastHigh; - const currFastHigh = curr.fastHigh; - const prevSlowLow = prev.slowLow; - const currSlowLow = curr.slowLow; - const prevSlowHigh = prev.slowHigh; - const currSlowHigh = curr.slowHigh; - - if (prevFastLow <= prevSlowLow && currFastLow > currSlowLow && price > currSlowLow) { - markers.push({ - time: curr.time, - position: 'belowBar', - color: '#26a69a', - shape: 'arrowUp', - text: 'BUY', - size: 1.2 - }); - } - - if (prevFastHigh >= prevSlowHigh && currFastHigh < currSlowHigh && price < currSlowHigh) { - markers.push({ - time: curr.time, - position: 'aboveBar', - color: '#ef5350', - shape: 'arrowDown', - text: 'SELL', - size: 1.2 - }); - } - } - - const candleSeries = this.candleData?.series; - if (candleSeries && typeof candleSeries.setMarkers === 'function') { - candleSeries.setMarkers(markers); - } - - return markers; - } -} - -export function addHTSVisualization(chart, candleSeries, htsData, candles, isAutoHTS = false) { - const visualizer = new HTSVisualizer(chart, candles); - visualizer.candleData = { series: candleSeries }; - visualizer.addHTSChannels(htsData, isAutoHTS); - - // Disable trend zones to avoid visual clutter - // visualizer.addTrendZones(htsData); - - if (window.showCrossoverMarkers !== false) { - setTimeout(() => { - try { - visualizer.addCrossoverMarkers(htsData); - } catch (e) { - console.warn('Crossover markers skipped (API limitation):', e.message); - } - }, 100); - } - - return visualizer; -} \ No newline at end of file diff --git a/src/api/dashboard/static/js/ui/index.js b/src/api/dashboard/static/js/ui/index.js deleted file mode 100644 index a007e51..0000000 --- a/src/api/dashboard/static/js/ui/index.js +++ /dev/null @@ -1,14 +0,0 @@ -export { TradingDashboard, refreshTA, openAIAnalysis } from './chart.js'; -export { toggleSidebar, restoreSidebarState } from './sidebar.js'; -export { - renderIndicatorList, - addNewIndicator, - selectIndicator, - renderIndicatorConfig, - applyIndicatorConfig, - removeIndicator, - removeIndicatorByIndex, - drawIndicatorsOnChart, - getActiveIndicators, - setActiveIndicators -} from './indicators-panel.js'; diff --git a/src/api/dashboard/static/js/ui/indicators-panel-new.js b/src/api/dashboard/static/js/ui/indicators-panel-new.js deleted file mode 100644 index c30713d..0000000 --- a/src/api/dashboard/static/js/ui/indicators-panel-new.js +++ /dev/null @@ -1,1157 +0,0 @@ -import { getAvailableIndicators, IndicatorRegistry as IR } from '../indicators/index.js'; - -// State management -let activeIndicators = []; - -console.log('[Module] indicators-panel-new.js loaded - activeIndicators count:', activeIndicators?.length || 0); -let configuringId = null; -let searchQuery = ''; -let selectedCategory = 'all'; -let nextInstanceId = 1; -let listenersAttached = false; // Single flag to track if any listeners are attached - -// Chart pane management -let indicatorPanes = new Map(); -let nextPaneIndex = 1; - -// Presets storage -let userPresets; -try { - userPresets = JSON.parse(localStorage.getItem('indicator_presets') || '{}'); - if (!userPresets || typeof userPresets !== 'object') { - userPresets = { presets: [] }; - } -} catch (e) { - console.warn('Failed to parse presets:', e); - userPresets = { presets: [] }; -} - -// Categories -const CATEGORIES = [ - { id: 'all', name: 'All Indicators', icon: 'šŸ“Š' }, - { id: 'trend', name: 'Trend', icon: 'šŸ“Š' }, - { id: 'momentum', name: 'Momentum', icon: 'šŸ“ˆ' }, - { id: 'volatility', name: 'Volatility', icon: 'šŸ“‰' }, - { id: 'volume', name: 'Volume', icon: 'šŸ”€' }, - { id: 'favorites', name: 'Favorites', icon: 'ā˜…' } -]; - -const CATEGORY_MAP = { - sma: 'trend', ema: 'trend', hts: 'trend', - rsi: 'momentum', macd: 'momentum', stoch: 'momentum', - bb: 'volatility', atr: 'volatility', - others: 'volume' -}; - -const DEFAULT_COLORS = ['#2962ff', '#26a69a', '#ef5350', '#ff9800', '#9c27b0', '#00bcd4', '#ffeb3b', '#e91e63']; -const LINE_TYPES = ['solid', 'dotted', 'dashed']; - -function getDefaultColor(index) { - return DEFAULT_COLORS[index % DEFAULT_COLORS.length]; -} - -function getIndicatorCategory(indicator) { - return CATEGORY_MAP[indicator.type] || 'trend'; -} - -function getIndicatorLabel(indicator) { - const meta = getIndicatorMeta(indicator); - if (!meta) return indicator.name; - - // Always show params in parentheses (e.g., "MA(44)" or "MA(SMA,44)") - const paramParts = meta.inputs.map(input => { - const val = indicator.params[input.name]; - return val !== undefined ? val : input.default; - }); - - if (paramParts.length > 0) { - return `${indicator.name}(${paramParts.join(',')})`; - } - return indicator.name; -} - -function getIndicatorMeta(indicator) { - const IndicatorClass = IR?.[indicator.type]; - if (!IndicatorClass) return null; - const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name }); - return instance.getMetadata(); -} - -function groupPlotsByColor(plots) { - const groups = {}; - plots.forEach((plot, idx) => { - const groupMap = { - 'fast': 'Fast', 'slow': 'Slow', 'upper': 'Upper', 'lower': 'Lower', - 'middle': 'Middle', 'basis': 'Middle', 'signal': 'Signal', - 'histogram': 'Histogram', 'k': '%K', 'd': '%D' - }; - const groupName = Object.entries(groupMap).find(([k, v]) => plot.id.toLowerCase().includes(k))?.[1] || plot.id; - if (!groups[groupName]) { - groups[groupName] = { name: groupName, indices: [], plots: [] }; - } - groups[groupName].indices.push(idx); - groups[groupName].plots.push(plot); - }); - return Object.values(groups); -} - -export function initIndicatorPanel() { - renderIndicatorPanel(); -} - -export function getActiveIndicators() { - return activeIndicators; -} - -export function setActiveIndicators(indicators) { - console.warn('setActiveIndicators() called with', indicators.length, 'indicators - this will replace activeIndicators array!'); - console.trace('Call stack:'); - activeIndicators = indicators; - renderIndicatorPanel(); -} - -window.getActiveIndicators = getActiveIndicators; - -// Render main panel -export function renderIndicatorPanel() { - const container = document.getElementById('indicatorPanel'); - if (!container) { - return; - } - - const available = getAvailableIndicators(); - const catalog = available.filter(ind => { - if (searchQuery && !ind.name.toLowerCase().includes(searchQuery.toLowerCase())) return false; - if (selectedCategory === 'all') return true; - if (selectedCategory === 'favorites') return false; - const cat = CATEGORY_MAP[ind.type] || 'trend'; - return cat === selectedCategory; - }); - - const favoriteIds = new Set(userPresets.favorites || []); - - const allVisible = activeIndicators.length > 0 ? activeIndicators.every(ind => ind.visible !== false) : false; - const visibilityBtnText = allVisible ? 'Hide All' : 'Show All'; - - container.innerHTML = ` -
- - - - -
- ${CATEGORIES.map(cat => ` - - `).join('')} -
- - - ${[...favoriteIds].length > 0 ? ` -
-
ā˜… Favorites
- ${[...favoriteIds].map(id => { - const ind = available.find(a => { - return a.type === id || (activeIndicators.find(ai => ai.id === id)?.type === ''); - }); - if (!ind) return ''; - return renderIndicatorItem(ind, true); - }).join('')} -
- ` : ''} - - - ${activeIndicators.length > 0 ? ` -
-
- ${activeIndicators.length} Active - ${activeIndicators.length > 0 ? `` : ''} - ${activeIndicators.length > 0 ? `` : ''} -
- ${activeIndicators.slice().reverse().map(ind => renderActiveIndicator(ind)).join('')} -
- ` : ''} - - - ${catalog.length > 0 ? ` -
-
Available Indicators
- ${catalog.map(ind => renderIndicatorItem(ind, false)).join('')} -
- ` : ` -
- No indicators found -
- `} -
- `; - - // Only setup event listeners once - if (!listenersAttached) { - setupEventListeners(); - listenersAttached = true; - } -} - -function renderIndicatorItem(indicator, isFavorite) { - return ` -
-
- ${indicator.name} - ${indicator.description || ''} -
- - ${isFavorite ? '' : ` - - `} -
-
-
- `; -} - -function renderActiveIndicator(indicator) { - const isExpanded = configuringId === indicator.id; - const meta = getIndicatorMeta(indicator); - const label = getIndicatorLabel(indicator); - const isFavorite = userPresets.favorites?.includes(indicator.type) || false; - const showPresets = meta.name && function() { - const hasPresets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : []; - if (!hasPresets || hasPresets.length === 0) return ''; - return `
- -
`; - }(); - - return ` -
-
-
⋮⋮
- - ${label} - ${showPresets} - - -
- - ${isExpanded ? ` -
- ${typeof renderIndicatorConfig === 'function' ? renderIndicatorConfig(indicator, meta) : ''} -
- ` : ''} -
- `; -} - -function renderPresetIndicatorIndicator(meta, indicator) { - const hasPresets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : []; - if (!hasPresets || hasPresets.length === 0) return ''; - - return ``; -} - -function renderIndicatorConfig(indicator, meta) { - const plotGroups = groupPlotsByColor(meta?.plots || []); - - return ` -
- -
-
Visual Settings
- ${plotGroups.map(group => { - const firstIdx = group.indices[0]; - const color = indicator.params[`_color_${firstIdx}`] || meta.plots[firstIdx]?.color || getDefaultColor(activeIndicators.indexOf(indicator)); - return ` -
- -
- - -
-
- `.trim() + ''; - }).join('')} - - ${indicator.type !== 'rsi' ? ` -
- - -
- -
- - - ${indicator.params._lineWidth || 2} -
-` : ''} -
- - ${meta?.inputs && meta.inputs.length > 0 ? ` -
-
Parameters
- ${meta.inputs.map(input => ` -
- - ${input.type === 'select' ? - `` : - `` - } -
- `).join('')} -
- ` : ''} - -
-
Signals
-
- - -
-
- - - -
-
- -
- - -
-
-
- - - -
-
- -
- - -
-
-
- -
-
- Presets - -
- ${typeof renderIndicatorPresets === 'function' ? renderIndicatorPresets(indicator, meta) : ''} -
- -
- - -
-
- `; -} - -function renderIndicatorPresets(indicator, meta) { - const presets = typeof getPresetsForIndicator === 'function' ? getPresetsForIndicator(meta.name) : []; - - return presets.length > 0 ? ` -
- ${presets.map(p => { - const isApplied = meta.inputs.every(input => - (indicator.params[input.name] === (p.values?.[input.name] ?? input.default)) - ); - - return ` -
- ${p.name} - -
- `; - }).join('')} -
- ` : '
No saved presets
'; -} - -// Event listeners -function setupEventListeners() { - const container = document.getElementById('indicatorPanel'); - if (!container) return; - - container.addEventListener('click', (e) => { - e.stopPropagation(); - - // Add button - const addBtn = e.target.closest('.indicator-btn.add'); - if (addBtn) { - e.stopPropagation(); - const type = addBtn.dataset.type; - if (type && window.addIndicator) { - window.addIndicator(type); - } - return; - } - - // Remove button - const removeBtn = e.target.closest('.indicator-btn.remove'); - if (removeBtn) { - e.stopPropagation(); - const id = removeBtn.dataset.id; - if (id && window.removeIndicatorById) { - window.removeIndicatorById(id); - } - return; - } - - // Clear all button - const clearAllBtn = e.target.closest('.clear-all'); - if (clearAllBtn) { - if (window.clearAllIndicators) { - window.clearAllIndicators(); - } - return; - } - - // Visibility toggle (Hide All / Show All) button - const visibilityToggleBtn = e.target.closest('.visibility-toggle'); - if (visibilityToggleBtn) { - if (window.toggleAllIndicatorsVisibility) { - window.toggleAllIndicatorsVisibility(); - } - return; - } - - // Expand/collapse button - const expandBtn = e.target.closest('.indicator-btn.expand'); - if (expandBtn) { - e.stopPropagation(); - const id = expandBtn.dataset.id; - if (id && window.toggleIndicatorExpand) { - window.toggleIndicatorExpand(id); - } - return; - } - - // Visibility button (eye) - const visibleBtn = e.target.closest('.indicator-btn.visible'); - if (visibleBtn) { - e.stopPropagation(); - const id = visibleBtn.dataset.id; - if (id && window.toggleIndicatorVisibility) { - window.toggleIndicatorVisibility(id); - } - return; - } - }); - - // Search input - const searchInput = document.getElementById('indicatorSearch'); - if (searchInput) { - searchInput.addEventListener('input', (e) => { - searchQuery = e.target.value; - renderIndicatorPanel(); - }); - } - - // Search clear button - const searchClear = container.querySelector('.search-clear'); - if (searchClear) { - searchClear.addEventListener('click', (e) => { - searchQuery = ''; - renderIndicatorPanel(); - }); - } - - // Category tabs - document.querySelectorAll('.category-tab').forEach(tab => { - tab.addEventListener('click', (e) => { - selectedCategory = tab.dataset.category; - renderIndicatorPanel(); - }); - }); -} - -// Actions -window.toggleIndicatorExpand = function(id) { - configuringId = configuringId === id ? null : id; - renderIndicatorPanel(); -}; - -window.clearSearch = function() { - searchQuery = ''; - renderIndicatorPanel(); -}; - -window.updateIndicatorColor = function(id, index, color) { - const indicator = activeIndicators.find(a => a.id === id); - if (!indicator) return; - - indicator.params[`_color_${index}`] = color; - drawIndicatorsOnChart(); -}; - -window.updateIndicatorSetting = function(id, key, value) { - const indicator = activeIndicators.find(a => a.id === id); - if (!indicator) return; - - indicator.params[key] = value; - indicator.lastSignalTimestamp = null; - indicator.lastSignalType = null; - drawIndicatorsOnChart(); -}; - -window.clearAllIndicators = function() { - activeIndicators.forEach(ind => { - ind.series?.forEach(s => { - try { window.dashboard?.chart?.removeSeries(s); } catch(e) {} - }); - }); - activeIndicators = []; - configuringId = null; - renderIndicatorPanel(); - drawIndicatorsOnChart(); -} - -window.toggleAllIndicatorsVisibility = function() { - const allVisible = activeIndicators.every(ind => ind.visible !== false); - - activeIndicators.forEach(ind => { - ind.visible = !allVisible; - }); - - drawIndicatorsOnChart(); - renderIndicatorPanel(); -} - -function removeIndicatorById(id) { - const idx = activeIndicators.findIndex(a => a.id === id); - if (idx < 0) return; - - activeIndicators[idx].series?.forEach(s => { - try { window.dashboard?.chart?.removeSeries(s); } catch(e) {} - }); - - activeIndicators.splice(idx, 1); - - if (configuringId === id) { - configuringId = null; - } - - renderIndicatorPanel(); - drawIndicatorsOnChart(); -} - -// Presets -function getPresetsForIndicator(indicatorName) { - if (!userPresets || !userPresets.presets) return []; - return userPresets.presets.filter(p => p.indicatorName === indicatorName); -} - -window.savePreset = function(id) { - console.log('[savePreset] Attempting to save preset for id:', id); - const indicator = activeIndicators.find(a => a.id === id); - if (!indicator) { - console.error('[savePreset] Indicator not found for id:', id); - return; - } - - const presetName = prompt('Enter preset name:'); - if (!presetName) return; - - const IndicatorClass = IR?.[indicator.type]; - if (!IndicatorClass) { - console.error('[savePreset] Indicator class not found for type:', indicator.type); - return; - } - - try { - const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name }); - const meta = instance.getMetadata(); - - const preset = { - id: `preset_${Date.now()}`, - name: presetName, - indicatorName: meta.name, - values: {} - }; - - // Save standard inputs - if (meta.inputs && Array.isArray(meta.inputs)) { - meta.inputs.forEach(input => { - preset.values[input.name] = indicator.params[input.name]; - }); - } - - // Save visual settings (line width, type, colors) - preset.values._lineWidth = indicator.params._lineWidth; - preset.values._lineType = indicator.params._lineType; - - // Save colors for each plot - if (meta.plots && Array.isArray(meta.plots)) { - meta.plots.forEach((plot, idx) => { - const colorKey = `_color_${idx}`; - if (indicator.params[colorKey]) { - preset.values[colorKey] = indicator.params[colorKey]; - } - }); - } - - // Save marker settings - const markerKeys = [ - 'showMarkers', - 'markerBuyShape', 'markerBuyColor', 'markerBuyCustom', - 'markerSellShape', 'markerSellColor', 'markerSellCustom' - ]; - markerKeys.forEach(key => { - if (indicator.params[key] !== undefined) { - preset.values[key] = indicator.params[key]; - } - }); - - if (!userPresets) userPresets = { presets: [] }; - if (!userPresets.presets) userPresets.presets = []; - - userPresets.presets.push(preset); - saveUserPresets(); - renderIndicatorPanel(); - - alert(`Preset "${presetName}" saved!`); - } catch (error) { - console.error('[savePreset] Error saving preset:', error); - alert('Error saving preset. See console for details.'); - } -}; - -window.applyPreset = function(id, presetId) { - const allPresets = (userPresets?.presets || []).filter(p => typeof p === 'object' && p.id); - const preset = allPresets.find(p => p.id === presetId); - if (!preset) return; - - const indicator = activeIndicators.find(a => a.id === id); - if (!indicator) return; - - Object.keys(preset.values).forEach(key => { - indicator.params[key] = preset.values[key]; - }); - - renderIndicatorPanel(); - drawIndicatorsOnChart(); -}; - -window.deletePreset = function(presetId) { - if (!confirm('Delete this preset?')) return; - - if (userPresets?.presets) { - userPresets.presets = userPresets.presets.filter(p => p.id !== presetId); - saveUserPresets(); - renderIndicatorPanel(); - } -}; - -window.showPresets = function(indicatorName) { - const presets = getPresetsForIndicator(indicatorName); - if (presets.length === 0) { - alert('No saved presets for this indicator'); - return; - } - - const menu = window.open('', '_blank', 'width=400,height=500'); - - let htmlContent = - 'Presets - ' + indicatorName + '' + - '

' + indicatorName + ' Presets

'; - - presets.forEach(p => { - htmlContent += '
' + p.name + '
'; - }); - - htmlContent += ''; - - menu.document.write(htmlContent); -}; - -window.applyPresetFromWindow = function(presetId) { - const indicator = activeIndicators.find(a => a.id === configuringId); - if (!indicator) return; - applyPreset(indicator.id, presetId); -}; - -function addIndicator(type) { - const IndicatorClass = IR?.[type]; - if (!IndicatorClass) return; - - const id = `${type}_${nextInstanceId++}`; - const instance = new IndicatorClass({ type, params: {}, name: '' }); - const metadata = instance.getMetadata(); - - const params = { - _lineType: 'solid', - _lineWidth: 1, - showMarkers: true, - markerBuyShape: 'custom', - markerBuyColor: '#9e9e9e', - markerBuyCustom: 'ā–²', - markerSellShape: 'custom', - markerSellColor: '#9e9e9e', - markerSellCustom: 'ā–¼' - }; - - // Override with Hurst-specific defaults - if (type === 'hurst') { - params._lineWidth = 1; - params.markerBuyShape = 'custom'; - params.markerSellShape = 'custom'; - params.markerBuyColor = '#9e9e9e'; - params.markerSellColor = '#9e9e9e'; - params.markerBuyCustom = 'ā–²'; - params.markerSellCustom = 'ā–¼'; - } - - metadata.plots.forEach((plot, idx) => { - params[`_color_${idx}`] = plot.color || getDefaultColor(activeIndicators.length + idx); - }); - metadata.inputs.forEach(input => { - params[input.name] = input.default; - }); - - activeIndicators.push({ - id, - type, - name: metadata.name, - params, - plots: metadata.plots, - series: [], - visible: true, - paneHeight: 120 // default 120px - }); - - // Don't set configuringId so indicators are NOT expanded by default - renderIndicatorPanel(); - drawIndicatorsOnChart(); -}; - -function saveUserPresets() { - localStorage.setItem('indicator_presets', JSON.stringify(userPresets)); -} - -function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) { - // Recalculate with current TF candles - console.log(`[renderIndicatorOnPane] ${indicator.name}: START`); - console.log(`[renderIndicatorOnPane] ${indicator.name}: Input candles = ${candles.length}`); - console.log(`[renderIndicatorOnPane] ${indicator.name}: Calling instance.calculate()...`); - - const results = instance.calculate(candles); - - console.log(`[renderIndicatorOnPane] ${indicator.name}: calculate() returned ${results?.length || 0} results`); - console.log(`[renderIndicatorOnPane] ${indicator.name}: Expected ${candles.length} results, got ${results?.length || 0}`); - - if (results.length !== candles.length) { - console.error(`[renderIndicatorOnPane] ${indicator.name}: MISMATCH! Expected ${candles.length} results but got ${results.length}`); - console.error(`[renderIndicatorOnPane] ${indicator.name}: This means instance.calculate() is not returning the correct number of results!`); - } - - // Clear previous series for this indicator - if (indicator.series && indicator.series.length > 0) { - indicator.series.forEach(s => { - try { - window.dashboard.chart.removeSeries(s); - } catch(e) {} - }); - } - indicator.series = []; - - const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid; - const lineWidth = indicator.params._lineWidth || 1; - - const firstNonNull = results?.find(r => r !== null && r !== undefined); - const isObjectResult = firstNonNull && typeof firstNonNull === 'object'; - - let plotsCreated = 0; - let dataPointsAdded = 0; - - meta.plots.forEach((plot, plotIdx) => { - if (isObjectResult) { - const hasData = results.some(r => r && r[plot.id] !== undefined && r[plot.id] !== null); - if (!hasData) return; - } - - const plotColor = indicator.params[`_color_${plotIdx}`] || plot.color || '#2962ff'; - - const data = []; - let firstDataIndex = -1; - - for (let i = 0; i < candles.length; i++) { - let value; - if (isObjectResult) { - value = results[i]?.[plot.id]; - } else { - value = results[i]; - } - - if (value !== null && value !== undefined) { - if (firstDataIndex === -1) { - firstDataIndex = i; - } - data.push({ - time: candles[i].time, - value: value - }); - } - } - - console.log(`[renderIndicatorOnPane] ${indicator.name} plot ${plotIdx}: ${data.length} data points created, first data at index ${firstDataIndex}/${candles.length}`); - - if (data.length === 0) { - console.log(`[renderIndicatorOnPane] ${indicator.name} plot ${plotIdx}: No data to render`); - return; - } - - console.log(`[renderIndicatorOnPane] ${indicator.name} plot ${plotIdx}: Creating series with ${data.length} data points [${data[0].time} to ${data[data.length - 1].time}]`); - - let series; - let plotLineStyle = lineStyle; - if (plot.style === 'dashed') plotLineStyle = LightweightCharts.LineStyle.Dashed; - else if (plot.style === 'dotted') plotLineStyle = LightweightCharts.LineStyle.Dotted; - else if (plot.style === 'solid') plotLineStyle = LightweightCharts.LineStyle.Solid; - - if (plot.type === 'histogram') { - series = window.dashboard.chart.addSeries(LightweightCharts.HistogramSeries, { - color: plotColor, - priceFormat: { type: 'price', precision: 4, minMove: 0.0001 }, - priceLineVisible: false, - lastValueVisible: false - }, paneIndex); - } else if (plot.type === 'baseline') { - series = window.dashboard.chart.addSeries(LightweightCharts.BaselineSeries, { - baseValue: { type: 'price', price: plot.baseValue || 0 }, - topLineColor: plot.topLineColor || plotColor, - topFillColor1: plot.topFillColor1 || plotColor, - topFillColor2: '#00000000', - bottomFillColor1: '#00000000', - bottomColor: plot.bottomColor || '#00000000', - lineWidth: plot.width || indicator.params._lineWidth || lineWidth, - lineStyle: plotLineStyle, - title: plot.title || '', - priceLineVisible: false, - lastValueVisible: plot.lastValueVisible !== false - }, paneIndex); - } else { - series = window.dashboard.chart.addSeries(LightweightCharts.LineSeries, { - color: plotColor, - lineWidth: plot.width || indicator.params._lineWidth || lineWidth, - lineStyle: plotLineStyle, - title: plot.title || '', - priceLineVisible: false, - lastValueVisible: plot.lastValueVisible !== false - }, paneIndex); - } - - series.setData(data); - indicator.series.push(series); - plotsCreated++; - console.log(`Created series for ${indicator.id}, plot=${plot.id}, total series now=${indicator.series.length}`); - - // Create horizontal band lines for RSI - if (meta.name === 'RSI' && indicator.series.length > 0) { - const mainSeries = indicator.series[0]; - const overbought = indicator.params.overbought || 70; - const oversold = indicator.params.oversold || 30; - - // Remove existing price lines first - while (indicator.bands && indicator.bands.length > 0) { - try { - indicator.bands.pop(); - } catch(e) {} - } - indicator.bands = indicator.bands || []; - - // Create overbought band line - indicator.bands.push(mainSeries.createPriceLine({ - price: overbought, - color: '#787B86', - lineWidth: 1, - lineStyle: LightweightCharts.LineStyle.Dashed, - axisLabelVisible: false, - title: '' - })); - - // Create oversold band line - indicator.bands.push(mainSeries.createPriceLine({ - price: oversold, - color: '#787B86', - lineWidth: 1, - lineStyle: LightweightCharts.LineStyle.Dashed, - axisLabelVisible: false, - title: '' - })); - } - }); -} - -// Completely redraw indicators (works for both overlay and pane) -export function updateIndicatorCandles() { - console.log('[UpdateIndicators] Removing and recreating all indicator series'); - - // Remove all existing series - const activeIndicators = getActiveIndicators(); - activeIndicators.forEach(indicator => { - indicator.series?.forEach(s => { - try { - window.dashboard.chart.removeSeries(s); - } catch(e) { - console.warn('[UpdateIndicators] Error removing series:', e); - } - }); - indicator.series = []; - }); - - // Clear pane mappings - indicatorPanes.clear(); - nextPaneIndex = 1; - - // Now call drawIndicatorsOnChart to recreate everything - drawIndicatorsOnChart(); - - console.log(`[UpdateIndicators] Recreated ${activeIndicators.length} indicators`); -} - -// Chart drawing -export function drawIndicatorsOnChart() { - try { - if (!window.dashboard || !window.dashboard.chart) { - return; - } - - const currentInterval = window.dashboard.currentInterval; - const candles = window.dashboard?.allData?.get(currentInterval); - - if (!candles || candles.length === 0) { - //console.log('[Indicators] No candles available'); - return; - } - - // console.log(`[Indicators] ========== drawIndicatorsOnChart START ==========`); - // console.log(`[Indicators] Candles from allData: ${candles.length}`); - // console.log(`[Indicators] First candle time: ${candles[0]?.time} (${new Date(candles[0]?.time * 1000).toLocaleDateString()})`); - // console.log(`[Indicators] Last candle time: ${candles[candles.length - 1]?.time} (${new Date(candles[candles.length - 1]?.time * 1000).toLocaleDateString()})`); - - const oldestTime = candles[0]?.time; - const newestTime = candles[candles.length - 1]?.time; - const oldestDate = oldestTime ? new Date(oldestTime * 1000).toLocaleDateString() : 'N/A'; - const newestDate = newestTime ? new Date(newestTime * 1000).toLocaleDateString() : 'N/A'; - - //console.log(`[Indicators] ========== Redrawing ==========`); - // console.log(`[Indicators] Candles: ${candles.length} | Time range: ${oldestDate} (${oldestTime}) to ${newestDate} (${newestTime})`); - - const activeIndicators = getActiveIndicators(); - - // Remove all existing series - activeIndicators.forEach(ind => { - ind.series?.forEach(s => { - try { window.dashboard.chart.removeSeries(s); } catch(e) {} - }); - ind.series = []; - }); - - const lineStyleMap = { - 'solid': LightweightCharts.LineStyle.Solid, - 'dotted': LightweightCharts.LineStyle.Dotted, - 'dashed': LightweightCharts.LineStyle.Dashed - }; - - // Don't clear indicatorPanes - preserve pane assignments across redraws - // Only reset nextPaneIndex to avoid creating duplicate panes - const maxExistingPane = Math.max(...indicatorPanes.values(), 0); - nextPaneIndex = maxExistingPane + 1; - - const overlayIndicators = []; - const paneIndicators = []; - - // Process all indicators, filtering by visibility - activeIndicators.forEach(ind => { - if (ind.visible === false || ind.visible === 'false') { - return; - } - - const IndicatorClass = IR?.[ind.type]; - if (!IndicatorClass) return; - - const instance = new IndicatorClass(ind); - const meta = instance.getMetadata(); - - // Store calculated results and metadata for signal calculation - const results = instance.calculate(candles); - ind.cachedResults = results; - ind.cachedMeta = meta; - - const validResults = results.filter(r => r !== null && r !== undefined); - const warmupPeriod = ind.params?.period || 44; - console.log(`[Indicators] ${ind.name}: ${validResults.length} valid results (need ${warmupPeriod} candles warmup)`); - - if (meta.displayMode === 'pane') { - paneIndicators.push({ indicator: ind, meta, instance }); - } else { - overlayIndicators.push({ indicator: ind, meta, instance }); - } - }); - - // Set main pane height (60% if indicator panes exist, 100% otherwise) - const totalPanes = 1 + paneIndicators.length; - const mainPaneHeight = paneIndicators.length > 0 ? 60 : 100; - - window.dashboard.chart.panes()[0]?.setHeight(mainPaneHeight); - - //console.log(`[Indicators] ========== Rendering Indicators ==========`); - //console.log(`[Indicators] Input candles: ${candles.length} | Panel count: ${totalPanes}`); - - overlayIndicators.forEach(({ indicator, meta, instance }) => { - //console.log(`[Indicators] Processing overlay: ${indicator.name}`); - //console.log(`[Indicators] Before renderIndicatorOnPane: indicator.cachedResults length = ${indicator.cachedResults?.length || 0}`); - renderIndicatorOnPane(indicator, meta, instance, candles, 0, lineStyleMap); - //console.log(`[Indicators] After renderIndicatorOnPane: indicator.cachedResults length = ${indicator.cachedResults?.length || 0}`); - }); - - paneIndicators.forEach(({ indicator, meta, instance }, idx) => { - // Use existing pane index if already assigned, otherwise create new one - let paneIndex = indicatorPanes.get(indicator.id); - if (paneIndex === undefined) { - paneIndex = nextPaneIndex++; - indicatorPanes.set(indicator.id, paneIndex); - } - - //console.log(`[Indicators] Processing pane: ${indicator.name} (pane ${paneIndex})`); - //console.log(`[Indicators] Before renderIndicatorOnPane: indicator.cachedResults length = ${indicator.cachedResults?.length || 0}`); - renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap); - //console.log(`[Indicators] After renderIndicatorOnPane: indicator.cachedResults length = ${indicator.cachedResults?.length || 0}`); - - const pane = window.dashboard.chart.panes()[paneIndex]; - if (pane) { - // Use stored height, localStorage, or default 120px - const storedHeight = indicator.paneHeight || - parseInt(localStorage.getItem(`pane_height_${indicator.type}`)) || - 120; - pane.setHeight(storedHeight); - } - }); - - //console.log(`[Indicators] ========== drawIndicatorsOnChart END ==========`); - } catch (error) { - console.error('[Indicators] Error drawing indicators:', error); - } - - // Update signal markers after indicators are drawn - if (window.dashboard && typeof window.dashboard.updateSignalMarkers === 'function') { - window.dashboard.updateSignalMarkers(); - } -} - -function resetIndicator(id) { - const indicator = activeIndicators.find(a => a.id === id); - if (!indicator) return; - - const IndicatorClass = IR[indicator.type]; - if (!IndicatorClass) return; - - const instance = new IndicatorClass({ type: indicator.type, params: {}, name: '' }); - const meta = instance.getMetadata(); - if (!meta || !meta.inputs) return; - - meta.inputs.forEach(input => { - indicator.params[input.name] = input.default; - }); - - renderIndicatorPanel(); - drawIndicatorsOnChart(); -} - -function removeIndicator(id) { - removeIndicatorById(id); -} - -function toggleIndicatorVisibility(id) { - const indicator = activeIndicators.find(a => a.id === id); - if (!indicator) { - return; - } - - indicator.visible = indicator.visible === false; - - // Full redraw to ensure all indicators render correctly - if (typeof drawIndicatorsOnChart === 'function') { - drawIndicatorsOnChart(); - } - - renderIndicatorPanel(); -} - -// Export functions for module access -export { addIndicator, removeIndicatorById, toggleIndicatorVisibility }; - -// Legacy compatibility functions -window.renderIndicatorList = renderIndicatorPanel; -window.resetIndicator = resetIndicator; -window.removeIndicator = removeIndicator; -window.toggleIndicator = addIndicator; -window.toggleIndicatorVisibility = toggleIndicatorVisibility; -window.showIndicatorConfig = function(id) { - const ind = activeIndicators.find(a => a.id === id); - if (ind) configuringId = id; - renderIndicatorPanel(); -}; -window.applyIndicatorConfig = function() { -// No-op - config is applied immediately -}; \ No newline at end of file diff --git a/src/api/dashboard/static/js/ui/indicators-panel.js b/src/api/dashboard/static/js/ui/indicators-panel.js deleted file mode 100644 index e103d68..0000000 --- a/src/api/dashboard/static/js/ui/indicators-panel.js +++ /dev/null @@ -1,698 +0,0 @@ -import { getAvailableIndicators, IndicatorRegistry as IR } from '../indicators/index.js'; - -let activeIndicators = []; -let configuringId = null; -let previewingType = null; // type being previewed (not yet added) -let nextInstanceId = 1; - -const DEFAULT_COLORS = ['#2962ff', '#26a69a', '#ef5350', '#ff9800', '#9c27b0', '#00bcd4', '#ffeb3b', '#e91e63']; -const LINE_TYPES = ['solid', 'dotted', 'dashed']; - -function getDefaultColor(index) { - return DEFAULT_COLORS[index % DEFAULT_COLORS.length]; -} - -function getPlotGroupName(plotId) { - if (plotId.toLowerCase().includes('fast')) return 'Fast'; - if (plotId.toLowerCase().includes('slow')) return 'Slow'; - if (plotId.toLowerCase().includes('upper')) return 'Upper'; - if (plotId.toLowerCase().includes('lower')) return 'Lower'; - if (plotId.toLowerCase().includes('middle') || plotId.toLowerCase().includes('basis')) return 'Middle'; - if (plotId.toLowerCase().includes('signal')) return 'Signal'; - if (plotId.toLowerCase().includes('histogram')) return 'Histogram'; - if (plotId.toLowerCase().includes('k')) return '%K'; - if (plotId.toLowerCase().includes('d')) return '%D'; - return plotId; -} - -function groupPlotsByColor(plots) { - const groups = {}; - plots.forEach((plot, idx) => { - const groupName = getPlotGroupName(plot.id); - if (!groups[groupName]) { - groups[groupName] = { name: groupName, indices: [], plots: [] }; - } - groups[groupName].indices.push(idx); - groups[groupName].plots.push(plot); - }); - return Object.values(groups); -} - -/** Generate a short label for an active indicator showing its key params */ -function getIndicatorLabel(indicator) { - const meta = getIndicatorMeta(indicator); - if (!meta) return indicator.name; - - const paramParts = meta.inputs.map(input => { - const val = indicator.params[input.name]; - if (val !== undefined && val !== input.default) return val; - if (val !== undefined) return val; - return null; - }).filter(v => v !== null); - - if (paramParts.length > 0) { - return `${indicator.name} (${paramParts.join(', ')})`; - } - return indicator.name; -} - -function getIndicatorMeta(indicator) { - const IndicatorClass = IR?.[indicator.type]; - if (!IndicatorClass) return null; - const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name }); - return instance.getMetadata(); -} - -export function getActiveIndicators() { - return activeIndicators; -} - -export function setActiveIndicators(indicators) { - activeIndicators = indicators; -} - -/** - * Render the indicator catalog (available indicators) and active list. - * Catalog items are added via double-click (multiple instances allowed). - */ -export function renderIndicatorList() { - const container = document.getElementById('indicatorList'); - if (!container) return; - - const available = getAvailableIndicators(); - - container.innerHTML = ` -
- ${available.map(ind => ` -
- ${ind.name} - + -
- `).join('')} -
- ${activeIndicators.length > 0 ? ` -
Active
-
- ${activeIndicators.map(ind => { - const isConfiguring = ind.id === configuringId; - const plotGroups = groupPlotsByColor(ind.plots || []); - const colorDots = plotGroups.map(group => { - const firstIdx = group.indices[0]; - const color = ind.params[`_color_${firstIdx}`] || '#2962ff'; - return ``; - }).join(''); - const label = getIndicatorLabel(ind); - - return ` -
- - ${ind.visible !== false ? 'šŸ‘' : 'šŸ‘ā€šŸ—Ø'} - - ${label} - ${colorDots} - - -
- `; - }).join('')} -
- ` : ''} - `; - - // Bind events via delegation - container.querySelectorAll('.indicator-catalog-item').forEach(el => { - el.addEventListener('click', () => previewIndicator(el.dataset.type)); - el.addEventListener('dblclick', () => addIndicator(el.dataset.type)); - }); - container.querySelectorAll('.indicator-catalog-add').forEach(el => { - el.addEventListener('click', (e) => { - e.stopPropagation(); - addIndicator(el.dataset.type); - }); - }); - container.querySelectorAll('.indicator-active-name').forEach(el => { - el.addEventListener('click', () => selectIndicatorConfig(el.dataset.id)); - }); - container.querySelectorAll('.indicator-config-btn').forEach(el => { - el.addEventListener('click', (e) => { - e.stopPropagation(); - selectIndicatorConfig(el.dataset.id); - }); - }); - container.querySelectorAll('.indicator-remove-btn').forEach(el => { - el.addEventListener('click', (e) => { - e.stopPropagation(); - removeIndicatorById(el.dataset.id); - }); - }); - container.querySelectorAll('.indicator-active-eye').forEach(el => { - el.addEventListener('click', (e) => { - e.stopPropagation(); - toggleVisibility(el.dataset.id); - }); - }); - - updateConfigPanel(); - updateChartLegend(); -} - -function updateConfigPanel() { - const configPanel = document.getElementById('indicatorConfigPanel'); - const configButtons = document.getElementById('configButtons'); - if (!configPanel) return; - - configPanel.style.display = 'block'; - - // Active indicator config takes priority over preview - const indicator = configuringId ? activeIndicators.find(a => a.id === configuringId) : null; - - if (indicator) { - renderIndicatorConfig(indicator); - if (configButtons) configButtons.style.display = 'flex'; - } else if (previewingType) { - renderPreviewConfig(previewingType); - if (configButtons) configButtons.style.display = 'none'; - } else { - const container = document.getElementById('configForm'); - if (container) { - container.innerHTML = '
Click an indicator to preview its settings
'; - } - if (configButtons) configButtons.style.display = 'none'; - } -} - -/** Single-click: preview config for a catalog indicator type (read-only) */ -function previewIndicator(type) { - configuringId = null; - previewingType = previewingType === type ? null : type; - renderIndicatorList(); -} - -/** Render a read-only preview of an indicator's default config */ -function renderPreviewConfig(type) { - const container = document.getElementById('configForm'); - if (!container) return; - - const IndicatorClass = IR?.[type]; - if (!IndicatorClass) return; - - const instance = new IndicatorClass({ type, params: {}, name: '' }); - const meta = instance.getMetadata(); - - container.innerHTML = ` -
${meta.name}
-
${meta.description || ''}
- - ${meta.inputs.map(input => ` -
- - ${input.type === 'select' ? - `` : - `` - } -
- `).join('')} - -
Double-click to add to chart
- `; -} - -/** Add a new instance of an indicator type */ -export function addIndicator(type) { - const IndicatorClass = IR?.[type]; - if (!IndicatorClass) return; - - previewingType = null; - const id = `${type}_${nextInstanceId++}`; - const instance = new IndicatorClass({ type, params: {}, name: '' }); - const metadata = instance.getMetadata(); - - const params = { - _lineType: 'solid', - _lineWidth: 1 - }; - - // Set Hurst-specific defaults - if (type === 'hurst') { - params.markerBuyShape = 'custom'; - params.markerSellShape = 'custom'; - params.markerBuyColor = '#9e9e9e'; - params.markerSellColor = '#9e9e9e'; - params.markerBuyCustom = 'ā–²'; - params.markerSellCustom = 'ā–¼'; - } - - metadata.plots.forEach((plot, idx) => { - params[`_color_${idx}`] = plot.color || getDefaultColor(activeIndicators.length + idx); - }); - metadata.inputs.forEach(input => { - params[input.name] = input.default; - }); - - activeIndicators.push({ - id, - type, - name: metadata.name, - params, - plots: metadata.plots, - series: [], - visible: true - }); - - configuringId = id; - - renderIndicatorList(); - drawIndicatorsOnChart(); -} - -function selectIndicatorConfig(id) { - previewingType = null; - if (configuringId === id) { - configuringId = null; - } else { - configuringId = id; - } - renderIndicatorList(); -} - -function toggleVisibility(id) { - const indicator = activeIndicators.find(a => a.id === id); - if (!indicator) return; - - indicator.visible = indicator.visible === false ? true : false; - - // Show/hide all series for this indicator - indicator.series?.forEach(s => { - try { - s.applyOptions({ visible: indicator.visible }); - } catch(e) {} - }); - - renderIndicatorList(); -} - -export function renderIndicatorConfig(indicator) { - const container = document.getElementById('configForm'); - if (!container || !indicator) return; - - const IndicatorClass = IR?.[indicator.type]; - if (!IndicatorClass) { - container.innerHTML = '
Error loading indicator
'; - return; - } - - const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name }); - const meta = instance.getMetadata(); - - const plotGroups = groupPlotsByColor(meta.plots); - - const colorInputs = plotGroups.map(group => { - const firstIdx = group.indices[0]; - const color = indicator.params[`_color_${firstIdx}`] || meta.plots[firstIdx].color || '#2962ff'; - return ` -
- - -
- `; - }).join(''); - - container.innerHTML = ` -
${getIndicatorLabel(indicator)}
- - ${colorInputs} - -
- - -
- -
- - -
- - ${meta.inputs.map(input => ` -
- - ${input.type === 'select' ? - `` : - `` - } -
- `).join('')} - `; -} - -export function applyIndicatorConfig() { - const indicator = configuringId ? activeIndicators.find(a => a.id === configuringId) : null; - if (!indicator) return; - - const IndicatorClass = IR?.[indicator.type]; - if (!IndicatorClass) return; - - const instance = new IndicatorClass({ type: indicator.type, params: {}, name: indicator.name }); - const meta = instance.getMetadata(); - - const plotGroups = groupPlotsByColor(meta.plots); - plotGroups.forEach(group => { - const firstIdx = group.indices[0]; - const colorEl = document.getElementById(`config__color_${firstIdx}`); - if (colorEl) { - const color = colorEl.value; - group.indices.forEach(idx => { - indicator.params[`_color_${idx}`] = color; - }); - } - }); - - const lineTypeEl = document.getElementById('config__lineType'); - const lineWidthEl = document.getElementById('config__lineWidth'); - - if (lineTypeEl) indicator.params._lineType = lineTypeEl.value; - if (lineWidthEl) indicator.params._lineWidth = parseInt(lineWidthEl.value); - - meta.inputs.forEach(input => { - const el = document.getElementById(`config_${input.name}`); - if (el) { - indicator.params[input.name] = input.type === 'select' ? el.value : parseFloat(el.value); - } - }); - - renderIndicatorList(); - drawIndicatorsOnChart(); -} - -export function removeIndicator() { - if (!configuringId) return; - removeIndicatorById(configuringId); -} - -export function removeIndicatorById(id) { - const idx = activeIndicators.findIndex(a => a.id === id); - if (idx < 0) return; - - activeIndicators[idx].series?.forEach(s => { - try { window.dashboard?.chart?.removeSeries(s); } catch(e) {} - }); - - activeIndicators.splice(idx, 1); - - if (configuringId === id) { - configuringId = null; - } - - renderIndicatorList(); - drawIndicatorsOnChart(); -} - -export function removeIndicatorByIndex(index) { - if (index < 0 || index >= activeIndicators.length) return; - removeIndicatorById(activeIndicators[index].id); -} - -let indicatorPanes = new Map(); -let nextPaneIndex = 1; - -export function drawIndicatorsOnChart() { - if (!window.dashboard || !window.dashboard.chart) return; - - activeIndicators.forEach(ind => { - ind.series?.forEach(s => { - try { window.dashboard.chart.removeSeries(s); } catch(e) {} - }); - }); - - const candles = window.dashboard.allData.get(window.dashboard.currentInterval); - if (!candles || candles.length === 0) return; - - const lineStyleMap = { 'solid': LightweightCharts.LineStyle.Solid, 'dotted': LightweightCharts.LineStyle.Dotted, 'dashed': LightweightCharts.LineStyle.Dashed }; - - indicatorPanes.clear(); - nextPaneIndex = 1; - - const overlayIndicators = []; - const paneIndicators = []; - - activeIndicators.forEach(ind => { - const IndicatorClass = IR?.[ind.type]; - if (!IndicatorClass) return; - - const instance = new IndicatorClass({ type: ind.type, params: ind.params, name: ind.name }); - const meta = instance.getMetadata(); - - if (meta.displayMode === 'pane') { - paneIndicators.push({ indicator: ind, meta, instance }); - } else { - overlayIndicators.push({ indicator: ind, meta, instance }); - } - }); - - const totalPanes = 1 + paneIndicators.length; - const mainPaneHeight = paneIndicators.length > 0 ? 60 : 100; - const paneHeight = paneIndicators.length > 0 ? Math.floor(40 / paneIndicators.length) : 0; - - window.dashboard.chart.panes()[0]?.setHeight(mainPaneHeight); - - overlayIndicators.forEach(({ indicator, meta, instance }) => { - if (indicator.visible === false) { - indicator.series = []; - return; - } - - renderIndicatorOnPane(indicator, meta, instance, candles, 0, lineStyleMap); - }); - - paneIndicators.forEach(({ indicator, meta, instance }, idx) => { - if (indicator.visible === false) { - indicator.series = []; - return; - } - - const paneIndex = nextPaneIndex++; - indicatorPanes.set(indicator.id, paneIndex); - - renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap); - - const pane = window.dashboard.chart.panes()[paneIndex]; - if (pane) { - pane.setHeight(paneHeight); - } - }); - - updateChartLegend(); -} - -function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) { - const results = instance.calculate(candles); - indicator.series = []; - - const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid; - const lineWidth = indicator.params._lineWidth || 1; - - const firstNonNull = results?.find(r => r !== null && r !== undefined); - const isObjectResult = firstNonNull && typeof firstNonNull === 'object'; - - meta.plots.forEach((plot, plotIdx) => { - if (isObjectResult) { - // Find if this specific plot has any non-null data across all results - const hasData = results.some(r => r && r[plot.id] !== undefined && r[plot.id] !== null); - if (!hasData) return; - } - - // Skip hidden plots - if (plot.visible === false) return; - - const plotColor = indicator.params[`_color_${plotIdx}`] || plot.color || '#2962ff'; - - const data = []; - for (let i = 0; i < candles.length; i++) { - let value; - if (isObjectResult) { - value = results[i]?.[plot.id]; - } else { - value = results[i]; - } - - if (value !== null && value !== undefined) { - data.push({ - time: candles[i].time, - value: value - }); - } - } - - if (data.length === 0) return; - - let series; - - // Determine line style for this specific plot - let plotLineStyle = lineStyle; - if (plot.style === 'dashed') plotLineStyle = LightweightCharts.LineStyle.Dashed; - else if (plot.style === 'dotted') plotLineStyle = LightweightCharts.LineStyle.Dotted; - else if (plot.style === 'solid') plotLineStyle = LightweightCharts.LineStyle.Solid; - - if (plot.type === 'histogram') { - series = window.dashboard.chart.addSeries(LightweightCharts.HistogramSeries, { - color: plotColor, - priceFormat: { - type: 'price', - precision: 4, - minMove: 0.0001 - }, - priceLineVisible: false, - lastValueVisible: false - }, paneIndex); - } else if (plot.type === 'baseline') { - series = window.dashboard.chart.addSeries(LightweightCharts.BaselineSeries, { - baseValue: { type: 'price', price: plot.baseValue || 0 }, - topLineColor: plot.topLineColor || plotColor, - topFillColor1: plot.topFillColor1 || plotColor, - topFillColor2: plot.topFillColor2 || '#00000000', - bottomFillColor1: plot.bottomFillColor1 || '#00000000', - bottomColor: plot.bottomColor || '#00000000', - lineWidth: plot.width !== undefined ? plot.width : lineWidth, - lineStyle: plotLineStyle, - title: plot.title || '', - priceLineVisible: false, - lastValueVisible: plot.lastValueVisible !== false - }, paneIndex); - } else { - series = window.dashboard.chart.addSeries(LightweightCharts.LineSeries, { - color: plotColor, - lineWidth: plot.width !== undefined ? plot.width : lineWidth, - lineStyle: plotLineStyle, - title: plot.title || '', - priceLineVisible: false, - lastValueVisible: plot.lastValueVisible !== false - }, paneIndex); - } - - series.setData(data); - series.plotId = plot.id; - - // Skip hidden plots (visible: false) - if (plot.visible === false) { - series.applyOptions({ visible: false }); - } - - indicator.series.push(series); - }); - - // Render gradient zones if available - if (meta.gradientZones && indicator.series.length > 0) { - // Find the main series to attach zones to - let baseSeries = indicator.series[0]; - - meta.gradientZones.forEach(zone => { - if (zone.from === undefined || zone.to === undefined) return; - - // We use createPriceLine on the series for horizontal bands with custom colors - baseSeries.createPriceLine({ - price: zone.from, - color: zone.color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[^)]+\)/, 'rgb($1, $2, $3)'), - lineWidth: 1, - lineStyle: LightweightCharts.LineStyle.Solid, - axisLabelVisible: false, - title: zone.label || '', - }); - - if (zone.to !== zone.from) { - baseSeries.createPriceLine({ - price: zone.to, - color: zone.color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[^)]+\)/, 'rgb($1, $2, $3)'), - lineWidth: 1, - lineStyle: LightweightCharts.LineStyle.Solid, - axisLabelVisible: false, - title: '', - }); - } - }); - } -} - -/** Update the TradingView-style legend overlay on the chart */ -export function updateChartLegend() { - let legend = document.getElementById('chartIndicatorLegend'); - if (!legend) { - const chartWrapper = document.getElementById('chartWrapper'); - if (!chartWrapper) return; - legend = document.createElement('div'); - legend.id = 'chartIndicatorLegend'; - legend.className = 'chart-indicator-legend'; - chartWrapper.appendChild(legend); - } - - if (activeIndicators.length === 0) { - legend.innerHTML = ''; - legend.style.display = 'none'; - return; - } - - legend.style.display = 'flex'; - legend.innerHTML = activeIndicators.map(ind => { - const label = getIndicatorLabel(ind); - const plotGroups = groupPlotsByColor(ind.plots || []); - const firstColor = ind.params['_color_0'] || '#2962ff'; - const dimmed = ind.visible === false; - - return ` -
- - ${label} - × -
- `; - }).join(''); - - // Bind legend events - legend.querySelectorAll('.legend-item').forEach(el => { - el.addEventListener('click', (e) => { - if (e.target.classList.contains('legend-close')) return; - selectIndicatorConfig(el.dataset.id); - renderIndicatorList(); - }); - }); - legend.querySelectorAll('.legend-close').forEach(el => { - el.addEventListener('click', (e) => { - e.stopPropagation(); - removeIndicatorById(el.dataset.id); - }); - }); -} - -// Legacy compat: toggleIndicator still works for external callers -export function toggleIndicator(type) { - addIndicator(type); -} - -export function showIndicatorConfig(index) { - if (index >= 0 && index < activeIndicators.length) { - selectIndicatorConfig(activeIndicators[index].id); - } -} - -export function showIndicatorConfigByType(type) { - const ind = activeIndicators.find(a => a.type === type); - if (ind) { - selectIndicatorConfig(ind.id); - } -} - -window.addIndicator = addIndicator; -window.toggleIndicator = toggleIndicator; -window.showIndicatorConfig = showIndicatorConfig; -window.applyIndicatorConfig = applyIndicatorConfig; -window.removeIndicator = removeIndicator; -window.removeIndicatorById = removeIndicatorById; -window.removeIndicatorByIndex = removeIndicatorByIndex; -window.drawIndicatorsOnChart = drawIndicatorsOnChart; diff --git a/src/api/dashboard/static/js/ui/sidebar.js b/src/api/dashboard/static/js/ui/sidebar.js deleted file mode 100644 index c5f2b7c..0000000 --- a/src/api/dashboard/static/js/ui/sidebar.js +++ /dev/null @@ -1,73 +0,0 @@ -export function toggleSidebar() { - const sidebar = document.getElementById('rightSidebar'); - sidebar.classList.toggle('collapsed'); - localStorage.setItem('sidebar_collapsed', sidebar.classList.contains('collapsed')); - - // Resize chart after sidebar toggle - setTimeout(() => { - if (window.dashboard && window.dashboard.chart) { - const container = document.getElementById('chart'); - window.dashboard.chart.applyOptions({ - width: container.clientWidth, - height: container.clientHeight - }); - } - }, 350); // Wait for CSS transition -} - -export function restoreSidebarState() { - const collapsed = localStorage.getItem('sidebar_collapsed') !== 'false'; // Default to collapsed - const sidebar = document.getElementById('rightSidebar'); - if (collapsed && sidebar) { - sidebar.classList.add('collapsed'); - } -} - -// Tab Management -let activeTab = 'indicators'; - -export function initSidebarTabs() { - const tabs = document.querySelectorAll('.sidebar-tab'); - - tabs.forEach(tab => { - tab.addEventListener('click', () => { - switchTab(tab.dataset.tab); - }); - }); -} - -export function switchTab(tabId) { - activeTab = tabId; - localStorage.setItem('sidebar_active_tab', tabId); - - document.querySelectorAll('.sidebar-tab').forEach(tab => { - tab.classList.toggle('active', tab.dataset.tab === tabId); - }); - - document.querySelectorAll('.sidebar-tab-panel').forEach(panel => { - panel.classList.toggle('active', panel.id === `tab-${tabId}`); - }); - - if (tabId === 'indicators') { - setTimeout(() => { - if (window.drawIndicatorsOnChart) { - window.drawIndicatorsOnChart(); - } - }, 50); - } else if (tabId === 'strategy') { - setTimeout(() => { - if (window.renderStrategyPanel) { - window.renderStrategyPanel(); - } - }, 50); - } -} - -export function getActiveTab() { - return activeTab; -} - -export function restoreSidebarTabState() { - const savedTab = localStorage.getItem('sidebar_active_tab') || 'indicators'; - switchTab(savedTab); -} diff --git a/src/api/dashboard/static/js/ui/signal-markers.js b/src/api/dashboard/static/js/ui/signal-markers.js deleted file mode 100644 index e80191d..0000000 --- a/src/api/dashboard/static/js/ui/signal-markers.js +++ /dev/null @@ -1,228 +0,0 @@ -import { IndicatorRegistry } from '../indicators/index.js'; - -export function calculateSignalMarkers(candles) { - const activeIndicators = window.getActiveIndicators?.() || []; - const markers = []; - - if (!candles || candles.length < 2) { - return markers; - } - - for (const indicator of activeIndicators) { - if (indicator.params.showMarkers === false || indicator.params.showMarkers === 'false') { - continue; - } - - console.log('[SignalMarkers] Processing indicator:', indicator.type, 'showMarkers:', indicator.params.showMarkers); - - const IndicatorClass = IndicatorRegistry[indicator.type]; - if (!IndicatorClass) { - continue; - } - - const instance = new IndicatorClass(indicator); - const results = instance.calculate(candles); - - if (!results || results.length === 0) { - continue; - } - - const indicatorMarkers = findCrossoverMarkers(indicator, candles, results); - markers.push(...indicatorMarkers); - } - - markers.sort((a, b) => a.time - b.time); - - return markers; -} - -function findCrossoverMarkers(indicator, candles, results) { - const markers = []; - const overbought = indicator.params?.overbought || 70; - const oversold = indicator.params?.oversold || 30; - const indicatorType = indicator.type; - - const buyColor = indicator.params?.markerBuyColor || '#26a69a'; - const sellColor = indicator.params?.markerSellColor || '#ef5350'; - const buyShape = indicator.params?.markerBuyShape || 'arrowUp'; - const sellShape = indicator.params?.markerSellShape || 'arrowDown'; - const buyCustom = indicator.params?.markerBuyCustom || 'ā—­'; - const sellCustom = indicator.params?.markerSellCustom || 'ā–¼'; - - for (let i = 1; i < results.length; i++) { - const candle = candles[i]; - const prevCandle = candles[i - 1]; - const result = results[i]; - const prevResult = results[i - 1]; - - if (!result || !prevResult) continue; - - if (indicatorType === 'rsi' || indicatorType === 'stoch') { - const rsi = result.rsi ?? result; - const prevRsi = prevResult.rsi ?? prevResult; - - if (rsi === undefined || prevRsi === undefined) continue; - - if (prevRsi > overbought && rsi <= overbought) { - markers.push({ - time: candle.time, - position: 'aboveBar', - color: sellColor, - shape: sellShape === 'custom' ? '' : sellShape, - text: sellShape === 'custom' ? sellCustom : '' - }); - } - - if (prevRsi < oversold && rsi >= oversold) { - markers.push({ - time: candle.time, - position: 'belowBar', - color: buyColor, - shape: buyShape === 'custom' ? '' : buyShape, - text: buyShape === 'custom' ? buyCustom : '' - }); - } - } else if (indicatorType === 'macd') { - const macd = result.macd ?? result.MACD; - const signal = result.signal ?? result.signalLine; - const prevMacd = prevResult.macd ?? prevResult.MACD; - const prevSignal = prevResult.signal ?? prevResult.signalLine; - - if (macd === undefined || signal === undefined || prevMacd === undefined || prevSignal === undefined) continue; - - const macdAbovePrev = prevMacd > prevSignal; - const macdAboveNow = macd > signal; - - if (macdAbovePrev && !macdAboveNow) { - markers.push({ - time: candle.time, - position: 'aboveBar', - color: sellColor, - shape: sellShape === 'custom' ? '' : sellShape, - text: sellShape === 'custom' ? sellCustom : '' - }); - } - - if (!macdAbovePrev && macdAboveNow) { - markers.push({ - time: candle.time, - position: 'belowBar', - color: buyColor, - shape: buyShape === 'custom' ? '' : buyShape, - text: buyShape === 'custom' ? buyCustom : '' - }); - } - } else if (indicatorType === 'bb') { - const upper = result.upper ?? result.upperBand; - const lower = result.lower ?? result.lowerBand; - - if (upper === undefined || lower === undefined) continue; - - const priceAboveUpperPrev = prevCandle.close > (prevResult.upper ?? prevResult.upperBand); - const priceAboveUpperNow = candle.close > upper; - - if (priceAboveUpperPrev && !priceAboveUpperNow) { - markers.push({ - time: candle.time, - position: 'aboveBar', - color: sellColor, - shape: sellShape === 'custom' ? '' : sellShape, - text: sellShape === 'custom' ? sellCustom : '' - }); - } - - if (!priceAboveUpperPrev && priceAboveUpperNow) { - markers.push({ - time: candle.time, - position: 'belowBar', - color: buyColor, - shape: buyShape === 'custom' ? '' : buyShape, - text: buyShape === 'custom' ? buyCustom : '' - }); - } - - const priceBelowLowerPrev = prevCandle.close < (prevResult.lower ?? prevResult.lowerBand); - const priceBelowLowerNow = candle.close < lower; - - if (priceBelowLowerPrev && !priceBelowLowerNow) { - markers.push({ - time: candle.time, - position: 'belowBar', - color: buyColor, - shape: buyShape === 'custom' ? '' : buyShape, - text: buyShape === 'custom' ? buyCustom : '' - }); - } - - if (!priceBelowLowerPrev && priceBelowLowerNow) { - markers.push({ - time: candle.time, - position: 'aboveBar', - color: sellColor, - shape: sellShape === 'custom' ? '' : sellShape, - text: sellShape === 'custom' ? sellCustom : '' - }); - } - } else if (indicatorType === 'hurst') { - const upper = result.upper; - const lower = result.lower; - const prevUpper = prevResult?.upper; - const prevLower = prevResult?.lower; - - if (upper === undefined || lower === undefined || - prevUpper === undefined || prevLower === undefined) continue; - - // BUY: price crosses down below lower band (was above, now below) - if (prevCandle.close > prevLower && candle.close < lower) { - markers.push({ - time: candle.time, - position: 'belowBar', - color: buyColor, - shape: buyShape === 'custom' ? '' : buyShape, - text: buyShape === 'custom' ? buyCustom : '' - }); - } - - // SELL: price crosses down below upper band (was above, now below) - if (prevCandle.close > prevUpper && candle.close < upper) { - markers.push({ - time: candle.time, - position: 'aboveBar', - color: sellColor, - shape: sellShape === 'custom' ? '' : sellShape, - text: sellShape === 'custom' ? sellCustom : '' - }); - } - } else { - const ma = result.ma ?? result; - const prevMa = prevResult.ma ?? prevResult; - - if (ma === undefined || prevMa === undefined) continue; - - const priceAbovePrev = prevCandle.close > prevMa; - const priceAboveNow = candle.close > ma; - - if (priceAbovePrev && !priceAboveNow) { - markers.push({ - time: candle.time, - position: 'aboveBar', - color: sellColor, - shape: sellShape === 'custom' ? '' : sellShape, - text: sellShape === 'custom' ? sellCustom : '' - }); - } - - if (!priceAbovePrev && priceAboveNow) { - markers.push({ - time: candle.time, - position: 'belowBar', - color: buyColor, - shape: buyShape === 'custom' ? '' : buyShape, - text: buyShape === 'custom' ? buyCustom : '' - }); - } - } - } - - return markers; -} diff --git a/src/api/dashboard/static/js/ui/signals-calculator.js b/src/api/dashboard/static/js/ui/signals-calculator.js deleted file mode 100644 index ad97c50..0000000 --- a/src/api/dashboard/static/js/ui/signals-calculator.js +++ /dev/null @@ -1,364 +0,0 @@ -// Signal Calculator - orchestrates signal calculation using indicator-specific functions -// Signal calculation logic is now in each indicator file - -import { IndicatorRegistry, getSignalFunction } from '../indicators/index.js'; - -/** - * Calculate signal for an indicator - * @param {Object} indicator - Indicator configuration - * @param {Array} candles - Candle data array - * @param {Object} indicatorValues - Computed indicator values for last candle - * @param {Object} prevIndicatorValues - Computed indicator values for previous candle - * @returns {Object} Signal object with type, strength, value, reasoning - */ -function calculateIndicatorSignal(indicator, candles, indicatorValues, prevIndicatorValues) { - const signalFunction = getSignalFunction(indicator.type); - - if (!signalFunction) { - console.warn('[Signals] No signal function for indicator type:', indicator.type); - return null; - } - - const lastCandle = candles[candles.length - 1]; - const prevCandle = candles[candles.length - 2]; - - return signalFunction(indicator, lastCandle, prevCandle, indicatorValues, prevIndicatorValues); -} - -/** - * Calculate aggregate summary signal from all indicators - */ -export function calculateSummarySignal(signals) { - console.log('[calculateSummarySignal] Input signals:', signals?.length); - - if (!signals || signals.length === 0) { - return { - signal: 'hold', - strength: 0, - reasoning: 'No active indicators', - buyCount: 0, - sellCount: 0, - holdCount: 0 - }; - } - - const buySignals = signals.filter(s => s.signal === 'buy'); - const sellSignals = signals.filter(s => s.signal === 'sell'); - const holdSignals = signals.filter(s => s.signal === 'hold'); - - const buyCount = buySignals.length; - const sellCount = sellSignals.length; - const holdCount = holdSignals.length; - const total = signals.length; - - console.log('[calculateSummarySignal] BUY:', buyCount, 'SELL:', sellCount, 'HOLD:', holdCount); - - const buyWeight = buySignals.reduce((sum, s) => sum + (s.strength || 0), 0); - const sellWeight = sellSignals.reduce((sum, s) => sum + (s.strength || 0), 0); - - let summarySignal, strength, reasoning; - - if (buyCount > sellCount && buyCount > holdCount) { - summarySignal = 'buy'; - const avgBuyStrength = buyWeight / buyCount; - strength = Math.round(avgBuyStrength * (buyCount / total)); - reasoning = `${buyCount} buy signals, ${sellCount} sell, ${holdCount} hold`; - } else if (sellCount > buyCount && sellCount > holdCount) { - summarySignal = 'sell'; - const avgSellStrength = sellWeight / sellCount; - strength = Math.round(avgSellStrength * (sellCount / total)); - reasoning = `${sellCount} sell signals, ${buyCount} buy, ${holdCount} hold`; - } else { - summarySignal = 'hold'; - strength = 30; - reasoning = `Mixed signals: ${buyCount} buy, ${sellCount} sell, ${holdCount} hold`; - } - - const result = { - signal: summarySignal, - strength: Math.min(Math.max(strength, 0), 100), - reasoning, - buyCount, - sellCount, - holdCount, - color: summarySignal === 'buy' ? '#26a69a' : summarySignal === 'sell' ? '#ef5350' : '#787b86' - }; - - console.log('[calculateSummarySignal] Result:', result); - return result; -} - -/** - * Calculate historical crossovers for all indicators based on full candle history - * Finds the last time each indicator crossed from BUY to SELL or SELL to BUY - */ -function calculateHistoricalCrossovers(activeIndicators, candles) { - activeIndicators.forEach(indicator => { - const indicatorType = indicator.type || indicator.indicatorType; - - // Recalculate indicator values for all candles - const IndicatorClass = IndicatorRegistry[indicatorType]; - if (!IndicatorClass) return; - - const instance = new IndicatorClass(indicator); - const results = instance.calculate(candles); - - if (!results || results.length === 0) return; - - // Find the most recent crossover by going backwards from the newest candle - // candles are sorted oldest first, newest last - let lastCrossoverTimestamp = null; - let lastSignalType = null; - - // Get indicator-specific parameters - const overbought = indicator.params?.overbought || 70; - const oversold = indicator.params?.oversold || 30; - - for (let i = candles.length - 1; i > 0; i--) { - const candle = candles[i]; // newer candle - const prevCandle = candles[i-1]; // older candle - - const result = results[i]; - const prevResult = results[i-1]; - - if (!result || !prevResult) continue; - - // Handle different indicator types - if (indicatorType === 'rsi' || indicatorType === 'stoch') { - // RSI/Stochastic: check crossing overbought/oversold levels - const rsi = result.rsi !== undefined ? result.rsi : result; - const prevRsi = prevResult.rsi !== undefined ? prevResult.rsi : prevResult; - - if (rsi === undefined || prevRsi === undefined) continue; - - // SELL: crossed down through overbought (was above, now below) - if (prevRsi > overbought && rsi <= overbought) { - lastCrossoverTimestamp = candle.time; - lastSignalType = 'sell'; - break; - } - // BUY: crossed up through oversold (was below, now above) - if (prevRsi < oversold && rsi >= oversold) { - lastCrossoverTimestamp = candle.time; - lastSignalType = 'buy'; - break; - } - } else if (indicatorType === 'hurst') { - // Hurst Bands: check price crossing bands - const upper = result.upper; - const lower = result.lower; - const prevUpper = prevResult.upper; - const prevLower = prevResult.lower; - - if (upper === undefined || lower === undefined || - prevUpper === undefined || prevLower === undefined) continue; - - // BUY: price crossed down below lower band - if (prevCandle.close > prevLower && candle.close < lower) { - lastCrossoverTimestamp = candle.time; - lastSignalType = 'buy'; - break; - } - // SELL: price crossed down below upper band - if (prevCandle.close > prevUpper && candle.close < upper) { - lastCrossoverTimestamp = candle.time; - lastSignalType = 'sell'; - break; - } - } else { - // MA-style: check price crossing MA - const ma = result.ma !== undefined ? result.ma : result; - const prevMa = prevResult.ma !== undefined ? prevResult.ma : prevResult; - - if (ma === undefined || prevMa === undefined) continue; - - // Check crossover: price was on one side of MA, now on the other side - const priceAbovePrev = prevCandle.close > prevMa; - const priceAboveNow = candle.close > ma; - - // SELL signal: price crossed from above to below MA - if (priceAbovePrev && !priceAboveNow) { - lastCrossoverTimestamp = candle.time; - lastSignalType = 'sell'; - break; - } - // BUY signal: price crossed from below to above MA - if (!priceAbovePrev && priceAboveNow) { - lastCrossoverTimestamp = candle.time; - lastSignalType = 'buy'; - break; - } - } - } - - // Always update the timestamp based on current data - // If crossover found use that time, otherwise use last candle time - if (lastCrossoverTimestamp) { - console.log(`[HistoricalCross] ${indicatorType}: Found ${lastSignalType} crossover at ${new Date(lastCrossoverTimestamp * 1000).toLocaleString()}`); - indicator.lastSignalTimestamp = lastCrossoverTimestamp; - indicator.lastSignalType = lastSignalType; - } else { - // No crossover found - use last candle time - const lastCandleTime = candles[candles.length - 1]?.time; - if (lastCandleTime) { - const lastResult = results[results.length - 1]; - - if (indicatorType === 'rsi' || indicatorType === 'stoch') { - // RSI/Stochastic: use RSI level to determine signal - const rsi = lastResult?.rsi !== undefined ? lastResult.rsi : lastResult; - if (rsi !== undefined) { - indicator.lastSignalType = rsi > overbought ? 'sell' : (rsi < oversold ? 'buy' : null); - indicator.lastSignalTimestamp = lastCandleTime; - } - } else if (indicatorType === 'hurst') { - // Hurst Bands: use price vs bands - const upper = lastResult?.upper; - const lower = lastResult?.lower; - const currentPrice = candles[candles.length - 1]?.close; - if (upper !== undefined && lower !== undefined && currentPrice !== undefined) { - if (currentPrice < lower) { - indicator.lastSignalType = 'buy'; - } else if (currentPrice > upper) { - indicator.lastSignalType = 'sell'; - } else { - indicator.lastSignalType = null; - } - indicator.lastSignalTimestamp = lastCandleTime; - } - } else { - // MA-style: use price vs MA - const ma = lastResult?.ma !== undefined ? lastResult.ma : lastResult; - if (ma !== undefined) { - const isAbove = candles[candles.length - 1].close > ma; - indicator.lastSignalType = isAbove ? 'buy' : 'sell'; - indicator.lastSignalTimestamp = lastCandleTime; - } - } - } - } - }); -} - -/** - * Calculate signals for all active indicators - * @returns {Array} Array of indicator signals - */ -export function calculateAllIndicatorSignals() { - const activeIndicators = window.getActiveIndicators?.() || []; - const candles = window.dashboard?.allData?.get(window.dashboard?.currentInterval); - - //console.log('[Signals] ========== calculateAllIndicatorSignals START =========='); - console.log('[Signals] Active indicators:', activeIndicators.length, 'Candles:', candles?.length || 0); - - if (!candles || candles.length < 2) { - //console.log('[Signals] Insufficient candles available:', candles?.length || 0); - return []; - } - - if (!activeIndicators || activeIndicators.length === 0) { - //console.log('[Signals] No active indicators'); - return []; - } - - const signals = []; - - // Calculate crossovers for all indicators based on historical data - calculateHistoricalCrossovers(activeIndicators, candles); - - for (const indicator of activeIndicators) { - const IndicatorClass = IndicatorRegistry[indicator.type]; - if (!IndicatorClass) { - console.log('[Signals] No class for indicator type:', indicator.type); - continue; - } - - // Use cached results if available, otherwise calculate - let results = indicator.cachedResults; - let meta = indicator.cachedMeta; - - if (!results || !meta || results.length !== candles.length) { - const instance = new IndicatorClass(indicator); - meta = instance.getMetadata(); - results = instance.calculate(candles); - indicator.cachedResults = results; - indicator.cachedMeta = meta; - } - - if (!results || results.length === 0) { - console.log('[Signals] No results for indicator:', indicator.type); - continue; - } - - const lastResult = results[results.length - 1]; - const prevResult = results[results.length - 2]; - if (lastResult === null || lastResult === undefined) { - console.log('[Signals] No valid last result for indicator:', indicator.type); - continue; - } - - let values; - let prevValues; - if (typeof lastResult === 'object' && lastResult !== null && !Array.isArray(lastResult)) { - values = lastResult; - prevValues = prevResult; - } else if (typeof lastResult === 'number') { - values = { ma: lastResult }; - prevValues = prevResult ? { ma: prevResult } : undefined; - } else { - console.log('[Signals] Unexpected result type for', indicator.type, ':', typeof lastResult); - continue; - } - - const signal = calculateIndicatorSignal(indicator, candles, values, prevValues); - - let currentSignal = signal; - let lastSignalDate = indicator.lastSignalTimestamp || null; - let lastSignalType = indicator.lastSignalType || null; - - if (!currentSignal || !currentSignal.type) { - console.log('[Signals] No valid signal for', indicator.type, '- Using last signal if available'); - - if (lastSignalType && lastSignalDate) { - currentSignal = { - type: lastSignalType, - strength: 50, - value: candles[candles.length - 1]?.close, - reasoning: `No crossover (price equals MA)` - }; - } else { - console.log('[Signals] No previous signal available - Skipping'); - continue; - } - } else { - const currentCandleTimestamp = candles[candles.length - 1].time; - - if (currentSignal.type !== lastSignalType || !lastSignalType) { - console.log('[Signals] Signal changed for', indicator.type, ':', lastSignalType, '->', currentSignal.type); - lastSignalDate = indicator.lastSignalTimestamp || currentCandleTimestamp; - lastSignalType = currentSignal.type; - indicator.lastSignalTimestamp = lastSignalDate; - indicator.lastSignalType = lastSignalType; - } - } - - signals.push({ - id: indicator.id, - name: meta?.name || indicator.type, - label: indicator.type?.toUpperCase(), - params: meta?.inputs && meta.inputs.length > 0 - ? indicator.params[meta.inputs[0].name] - : null, - type: indicator.type, - signal: currentSignal.type, - strength: Math.round(currentSignal.strength), - value: currentSignal.value, - reasoning: currentSignal.reasoning, - color: currentSignal.type === 'buy' ? '#26a69a' : currentSignal.type === 'sell' ? '#ef5350' : '#787b86', - lastSignalDate: lastSignalDate - }); - } - - //console.log('[Signals] ========== calculateAllIndicatorSignals END =========='); - console.log('[Signals] Total signals calculated:', signals.length); - return signals; -} \ No newline at end of file diff --git a/src/api/dashboard/static/js/ui/strategy-panel.js b/src/api/dashboard/static/js/ui/strategy-panel.js deleted file mode 100644 index d7069aa..0000000 --- a/src/api/dashboard/static/js/ui/strategy-panel.js +++ /dev/null @@ -1,791 +0,0 @@ -import { IndicatorRegistry, getSignalFunction } from '../indicators/index.js'; - -let activeIndicators = []; -let simulationResults = null; -let equitySeries = null; -let equityChart = null; -let posSeries = null; -let posSizeChart = null; - -const STORAGE_KEY = 'ping_pong_settings'; - -function formatDisplayDate(timestamp) { - if (!timestamp) return ''; - const date = new Date(timestamp); - const day = String(date.getDate()).padStart(2, '0'); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const year = date.getFullYear(); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${day}/${month}/${year} ${hours}:${minutes}`; -} - -function parseDisplayDate(str) { - if (!str) return null; - const regex = /^(\d{2})\/(\d{2})\/(\d{4})\s(\d{2}):(\d{2})$/; - const match = str.trim().match(regex); - if (!match) return null; - const [_, day, month, year, hours, minutes] = match; - return new Date(year, month - 1, day, hours, minutes); -} - -function getSavedSettings() { - const saved = localStorage.getItem(STORAGE_KEY); - if (!saved) return null; - try { - return JSON.parse(saved); - } catch (e) { - return null; - } -} - -function saveSettings() { - const settings = { - startDate: document.getElementById('simStartDate').value, - stopDate: document.getElementById('simStopDate').value, - contractType: document.getElementById('simContractType').value, - direction: document.getElementById('simDirection').value, - capital: document.getElementById('simCapital').value, - exchangeLeverage: document.getElementById('simExchangeLeverage').value, - maxEffectiveLeverage: document.getElementById('simMaxEffectiveLeverage').value, - posSize: document.getElementById('simPosSize').value, - tp: document.getElementById('simTP').value - }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); - - const btn = document.getElementById('saveSimSettings'); - const originalText = btn.textContent; - btn.textContent = 'Saved!'; - btn.style.color = '#26a69a'; - setTimeout(() => { - btn.textContent = originalText; - btn.style.color = ''; - }, 2000); -} - -export function initStrategyPanel() { - window.renderStrategyPanel = renderStrategyPanel; - renderStrategyPanel(); - - // Listen for indicator changes to update the signal selection list - const originalAddIndicator = window.addIndicator; - window.addIndicator = function(...args) { - const res = originalAddIndicator.apply(this, args); - setTimeout(renderStrategyPanel, 100); - return res; - }; - - const originalRemoveIndicator = window.removeIndicatorById; - window.removeIndicatorById = function(...args) { - const res = originalRemoveIndicator.apply(this, args); - setTimeout(renderStrategyPanel, 100); - return res; - }; -} - -export function renderStrategyPanel() { - const container = document.getElementById('strategyPanel'); - if (!container) return; - - activeIndicators = window.getActiveIndicators?.() || []; - const saved = getSavedSettings(); - - // Format initial values for display - let startDisplay = saved?.startDate || '01/01/2026 00:00'; - let stopDisplay = saved?.stopDate || ''; - - // If the saved value is in ISO format (from previous version), convert it - if (startDisplay.includes('T')) { - startDisplay = formatDisplayDate(new Date(startDisplay)); - } - if (stopDisplay.includes('T')) { - stopDisplay = formatDisplayDate(new Date(stopDisplay)); - } - - container.innerHTML = ` - - - - `; - - document.getElementById('runSimulationBtn').addEventListener('click', runSimulation); - document.getElementById('saveSimSettings').addEventListener('click', saveSettings); -} - -function renderIndicatorChecklist(prefix) { - if (activeIndicators.length === 0) { - return '
No active indicators on chart
'; - } - - return activeIndicators.map(ind => ` - - `).join(''); -} - -async function runSimulation() { - const btn = document.getElementById('runSimulationBtn'); - btn.disabled = true; - const originalBtnText = btn.textContent; - btn.textContent = 'Preparing Data...'; - - try { - const startVal = document.getElementById('simStartDate').value; - const stopVal = document.getElementById('simStopDate').value; - - const config = { - startDate: new Date(startVal).getTime() / 1000, - stopDate: stopVal ? new Date(stopVal).getTime() / 1000 : Math.floor(Date.now() / 1000), - contractType: document.getElementById('simContractType').value, - direction: document.getElementById('simDirection').value, - capital: parseFloat(document.getElementById('simCapital').value), - exchangeLeverage: parseFloat(document.getElementById('simExchangeLeverage').value), - maxEffectiveLeverage: parseFloat(document.getElementById('simMaxEffectiveLeverage').value), - posSize: parseFloat(document.getElementById('simPosSize').value), - tp: parseFloat(document.getElementById('simTP').value) / 100, - openIndicators: Array.from(document.querySelectorAll('.sim-open-check:checked')).map(el => el.dataset.id), - closeIndicators: Array.from(document.querySelectorAll('.sim-close-check:checked')).map(el => el.dataset.id) - }; - - if (config.openIndicators.length === 0) { - alert('Please choose at least one indicator for opening positions.'); - return; - } - - const interval = window.dashboard?.currentInterval || '1d'; - - // 1. Ensure data is loaded for the range - let allCandles = window.dashboard?.allData?.get(interval) || []; - - const earliestInCache = allCandles.length > 0 ? allCandles[0].time : Infinity; - const latestInCache = allCandles.length > 0 ? allCandles[allCandles.length - 1].time : -Infinity; - - if (config.startDate < earliestInCache || config.stopDate > latestInCache) { - btn.textContent = 'Fetching from Server...'; - console.log(`[Simulation] Data gap detected. Range: ${config.startDate}-${config.stopDate}, Cache: ${earliestInCache}-${latestInCache}`); - - const startISO = new Date(config.startDate * 1000).toISOString(); - const stopISO = new Date(config.stopDate * 1000).toISOString(); - - const response = await fetch(`/api/v1/candles?symbol=BTC&interval=${interval}&start=${startISO}&end=${stopISO}&limit=10000`); - const data = await response.json(); - - if (data.candles && data.candles.length > 0) { - const fetchedCandles = data.candles.reverse().map(c => ({ - time: Math.floor(new Date(c.time).getTime() / 1000), - open: parseFloat(c.open), - high: parseFloat(c.high), - low: parseFloat(c.low), - close: parseFloat(c.close), - volume: parseFloat(c.volume || 0) - })); - - // Merge with existing data - allCandles = window.dashboard.mergeData(allCandles, fetchedCandles); - window.dashboard.allData.set(interval, allCandles); - window.dashboard.candleSeries.setData(allCandles); - - // Recalculate indicators - btn.textContent = 'Calculating Indicators...'; - window.drawIndicatorsOnChart?.(); - // Wait a bit for indicators to calculate (they usually run in background/setTimeout) - await new Promise(r => setTimeout(r, 500)); - } - } - - btn.textContent = 'Simulating...'; - - // Filter candles by the exact range - const simCandles = allCandles.filter(c => c.time >= config.startDate && c.time <= config.stopDate); - - if (simCandles.length === 0) { - alert('No data available for the selected range.'); - return; - } - - // Calculate indicator signals - const indicatorSignals = {}; - for (const indId of [...new Set([...config.openIndicators, ...config.closeIndicators])]) { - const ind = activeIndicators.find(a => a.id === indId); - if (!ind) continue; - - const signalFunc = getSignalFunction(ind.type); - const results = ind.cachedResults; - - if (results && signalFunc) { - indicatorSignals[indId] = simCandles.map(candle => { - const idx = allCandles.findIndex(c => c.time === candle.time); - if (idx < 1) return null; - const values = typeof results[idx] === 'object' && results[idx] !== null ? results[idx] : { ma: results[idx] }; - const prevValues = typeof results[idx-1] === 'object' && results[idx-1] !== null ? results[idx-1] : { ma: results[idx-1] }; - return signalFunc(ind, allCandles[idx], allCandles[idx-1], values, prevValues); - }); - } - } - - // Simulation loop - const startPrice = simCandles[0].open; - let balanceBtc = config.contractType === 'inverse' ? config.capital / startPrice : 0; - let balanceUsd = config.contractType === 'linear' ? config.capital : 0; - - let equityData = { usd: [], btc: [] }; - let totalQty = 0; // Linear: BTC, Inverse: USD Contracts - let avgPrice = 0; - let avgPriceData = []; - let posSizeData = { btc: [], usd: [] }; - let trades = []; - - const PARTIAL_EXIT_PCT = 0.15; - const MIN_POSITION_VALUE_USD = 15; - - for (let i = 0; i < simCandles.length; i++) { - const candle = simCandles[i]; - const price = candle.close; - let actionTakenInThisCandle = false; - - // 1. Check TP - if (totalQty > 0) { - let isTP = false; - let exitPrice = price; - if (config.direction === 'long') { - if (candle.high >= avgPrice * (1 + config.tp)) { - isTP = true; - exitPrice = avgPrice * (1 + config.tp); - } - } else { - if (candle.low <= avgPrice * (1 - config.tp)) { - isTP = true; - exitPrice = avgPrice * (1 - config.tp); - } - } - - if (isTP) { - let qtyToClose = totalQty * PARTIAL_EXIT_PCT; - let remainingQty = totalQty - qtyToClose; - let remainingValueUsd = config.contractType === 'linear' ? remainingQty * exitPrice : remainingQty; - let reason = 'TP (Partial)'; - - // Minimum size check - if (remainingValueUsd < MIN_POSITION_VALUE_USD) { - qtyToClose = totalQty; - reason = 'TP (Full - Min Size)'; - } - - let pnl; - if (config.contractType === 'linear') { - pnl = config.direction === 'long' ? (exitPrice - avgPrice) * qtyToClose : (avgPrice - exitPrice) * qtyToClose; - balanceUsd += pnl; - } else { - pnl = config.direction === 'long' - ? qtyToClose * (1/avgPrice - 1/exitPrice) - : qtyToClose * (1/exitPrice - 1/avgPrice); - balanceBtc += pnl; - } - - totalQty -= qtyToClose; - trades.push({ - type: config.direction, recordType: 'exit', time: candle.time, - entryPrice: avgPrice, exitPrice: exitPrice, pnl: pnl, reason: reason, - currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty, - currentQty: config.contractType === 'linear' ? totalQty : totalQty / price - }); - actionTakenInThisCandle = true; - } - } - - // 2. Check Close Signals - if (!actionTakenInThisCandle && totalQty > 0 && config.closeIndicators.length > 0) { - const hasCloseSignal = config.closeIndicators.some(id => { - const sig = indicatorSignals[id][i]; - if (!sig) return false; - return config.direction === 'long' ? sig.type === 'sell' : sig.type === 'buy'; - }); - - if (hasCloseSignal) { - let qtyToClose = totalQty * PARTIAL_EXIT_PCT; - let remainingQty = totalQty - qtyToClose; - let remainingValueUsd = config.contractType === 'linear' ? remainingQty * price : remainingQty; - let reason = 'Signal (Partial)'; - - // Minimum size check - if (remainingValueUsd < MIN_POSITION_VALUE_USD) { - qtyToClose = totalQty; - reason = 'Signal (Full - Min Size)'; - } - - let pnl; - if (config.contractType === 'linear') { - pnl = config.direction === 'long' ? (price - avgPrice) * qtyToClose : (avgPrice - price) * qtyToClose; - balanceUsd += pnl; - } else { - pnl = config.direction === 'long' - ? qtyToClose * (1/avgPrice - 1/price) - : qtyToClose * (1/price - 1/avgPrice); - balanceBtc += pnl; - } - - totalQty -= qtyToClose; - trades.push({ - type: config.direction, recordType: 'exit', time: candle.time, - entryPrice: avgPrice, exitPrice: price, pnl: pnl, reason: reason, - currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty, - currentQty: config.contractType === 'linear' ? totalQty : totalQty / price - }); - actionTakenInThisCandle = true; - } - } - - // Calculate Current Equity for Margin Check - let currentEquityBtc, currentEquityUsd; - if (config.contractType === 'linear') { - const upnlUsd = totalQty > 0 ? (config.direction === 'long' ? (price - avgPrice) : (avgPrice - price)) * totalQty : 0; - currentEquityUsd = balanceUsd + upnlUsd; - currentEquityBtc = currentEquityUsd / price; - } else { - const upnlBtc = totalQty > 0 ? (config.direction === 'long' ? totalQty * (1/avgPrice - 1/price) : totalQty * (1/price - 1/avgPrice)) : 0; - currentEquityBtc = balanceBtc + upnlBtc; - currentEquityUsd = currentEquityBtc * price; - } - - // 3. Check Open Signals - if (!actionTakenInThisCandle) { - const hasOpenSignal = config.openIndicators.some(id => { - const sig = indicatorSignals[id][i]; - if (!sig) return false; - return config.direction === 'long' ? sig.type === 'buy' : sig.type === 'sell'; - }); - - if (hasOpenSignal) { - const entryValUsd = config.posSize * config.exchangeLeverage; - const currentNotionalBtc = config.contractType === 'linear' ? totalQty : totalQty / price; - const entryNotionalBtc = entryValUsd / price; - - const projectedEffectiveLeverage = (currentNotionalBtc + entryNotionalBtc) / Math.max(currentEquityBtc, 0.0000001); - - if (projectedEffectiveLeverage <= config.maxEffectiveLeverage) { - if (config.contractType === 'linear') { - const entryQty = entryValUsd / price; - avgPrice = ((totalQty * avgPrice) + (entryQty * price)) / (totalQty + entryQty); - totalQty += entryQty; - } else { - // Inverse: totalQty is USD contracts - avgPrice = (totalQty + entryValUsd) / ((totalQty / avgPrice || 0) + (entryValUsd / price)); - totalQty += entryValUsd; - } - - trades.push({ - type: config.direction, recordType: 'entry', time: candle.time, - entryPrice: price, reason: 'Entry', - currentUsd: config.contractType === 'linear' ? totalQty * price : totalQty, - currentQty: config.contractType === 'linear' ? totalQty : totalQty / price - }); - } - } - } - - // Final Equity Recording - let finalEquityBtc, finalEquityUsd; - if (config.contractType === 'linear') { - const upnl = totalQty > 0 ? (config.direction === 'long' ? (price - avgPrice) : (avgPrice - price)) * totalQty : 0; - finalEquityUsd = balanceUsd + upnl; - finalEquityBtc = finalEquityUsd / price; - } else { - const upnl = totalQty > 0 ? (config.direction === 'long' ? totalQty * (1/avgPrice - 1/price) : totalQty * (1/price - 1/avgPrice)) : 0; - finalEquityBtc = balanceBtc + upnl; - finalEquityUsd = finalEquityBtc * price; - } - - equityData.usd.push({ time: candle.time, value: finalEquityUsd }); - equityData.btc.push({ time: candle.time, value: finalEquityBtc }); - - if (totalQty > 0.000001) avgPriceData.push({ time: candle.time, value: avgPrice }); - posSizeData.btc.push({ time: candle.time, value: config.contractType === 'linear' ? totalQty : totalQty / price }); - posSizeData.usd.push({ time: candle.time, value: config.contractType === 'linear' ? totalQty * price : totalQty }); - } - - displayResults(trades, equityData, config, simCandles[simCandles.length-1].close, avgPriceData, posSizeData); - - } catch (error) { - console.error('[Simulation] Error:', error); - alert('Simulation failed.'); - } finally { - btn.disabled = false; - btn.textContent = 'Run Simulation'; - } -} - -function displayResults(trades, equityData, config, endPrice, avgPriceData, posSizeData) { - const resultsDiv = document.getElementById('simulationResults'); - resultsDiv.style.display = 'block'; - - // Update main chart with avg price - if (window.dashboard) { - window.dashboard.setAvgPriceData(avgPriceData); - } - - const entryTrades = trades.filter(t => t.recordType === 'entry').length; - const exitTrades = trades.filter(t => t.recordType === 'exit').length; - const profitableTrades = trades.filter(t => t.recordType === 'exit' && t.pnl > 0).length; - const winRate = exitTrades > 0 ? (profitableTrades / exitTrades * 100).toFixed(1) : 0; - - const startPrice = equityData.usd[0].value / equityData.btc[0].value; - const startBtc = config.capital / startPrice; - - const finalUsd = equityData.usd[equityData.usd.length - 1].value; - const finalBtc = finalUsd / endPrice; - - const totalPnlUsd = finalUsd - config.capital; - const roi = (totalPnlUsd / config.capital * 100).toFixed(2); - - const roiBtc = ((finalBtc - startBtc) / startBtc * 100).toFixed(2); - - resultsDiv.innerHTML = ` - - `; - - // Create Charts - const initCharts = () => { - // Equity Chart - const equityContainer = document.getElementById('equityChart'); - if (equityContainer) { - equityContainer.innerHTML = ''; - equityChart = LightweightCharts.createChart(equityContainer, { - layout: { background: { color: '#131722' }, textColor: '#d1d4dc' }, - grid: { vertLines: { visible: false }, horzLines: { color: '#2a2e39' } }, - rightPriceScale: { borderColor: '#2a2e39', autoScale: true }, - timeScale: { - borderColor: '#2a2e39', - visible: true, - timeVisible: true, - secondsVisible: false, - tickMarkFormatter: (time, tickMarkType, locale) => { - return TimezoneConfig.formatTickMark(time); - }, - }, - localization: { - timeFormatter: (timestamp) => { - return TimezoneConfig.formatDate(timestamp * 1000); - }, - }, - handleScroll: true, - handleScale: true - }); - - equitySeries = equityChart.addSeries(LightweightCharts.AreaSeries, { - lineColor: totalPnlUsd >= 0 ? '#26a69a' : '#ef5350', - topColor: totalPnlUsd >= 0 ? 'rgba(38, 166, 154, 0.4)' : 'rgba(239, 83, 80, 0.4)', - bottomColor: 'rgba(0, 0, 0, 0)', - lineWidth: 2, - }); - - equitySeries.setData(equityData['usd']); - equityChart.timeScale().fitContent(); - } - - // Pos Size Chart - const posSizeContainer = document.getElementById('posSizeChart'); - if (posSizeContainer) { - posSizeContainer.innerHTML = ''; - posSizeChart = LightweightCharts.createChart(posSizeContainer, { - layout: { background: { color: '#131722' }, textColor: '#d1d4dc' }, - grid: { vertLines: { visible: false }, horzLines: { color: '#2a2e39' } }, - rightPriceScale: { borderColor: '#2a2e39', autoScale: true }, - timeScale: { - borderColor: '#2a2e39', - visible: true, - timeVisible: true, - secondsVisible: false, - tickMarkFormatter: (time, tickMarkType, locale) => { - return TimezoneConfig.formatTickMark(time); - }, - }, - localization: { - timeFormatter: (timestamp) => { - return TimezoneConfig.formatDate(timestamp * 1000); - }, - }, - handleScroll: true, - handleScale: true - }); - - posSeries = posSizeChart.addSeries(LightweightCharts.AreaSeries, { - lineColor: '#00bcd4', - topColor: 'rgba(0, 188, 212, 0.4)', - bottomColor: 'rgba(0, 0, 0, 0)', - lineWidth: 2, - }); - - posSeries.setData(posSizeData['usd']); - posSizeChart.timeScale().fitContent(); - - const label = document.getElementById('posSizeLabel'); - if (label) label.textContent = 'Position Size (USD)'; - } - - // Sync Time Scales - if (equityChart && posSizeChart) { - let isSyncing = false; - - const syncCharts = (source, target) => { - if (isSyncing) return; - isSyncing = true; - const range = source.timeScale().getVisibleRange(); - target.timeScale().setVisibleRange(range); - isSyncing = false; - }; - - equityChart.timeScale().subscribeVisibleTimeRangeChange(() => syncCharts(equityChart, posSizeChart)); - posSizeChart.timeScale().subscribeVisibleTimeRangeChange(() => syncCharts(posSizeChart, equityChart)); - } - - // Sync to Main Chart on Click - const syncToMain = (param) => { - if (!param.time || !window.dashboard || !window.dashboard.chart) return; - - const timeScale = window.dashboard.chart.timeScale(); - const currentRange = timeScale.getVisibleRange(); - if (!currentRange) return; - - // Calculate current width to preserve zoom level - const width = currentRange.to - currentRange.from; - const halfWidth = width / 2; - - timeScale.setVisibleRange({ - from: param.time - halfWidth, - to: param.time + halfWidth - }); - }; - - if (equityChart) equityChart.subscribeClick(syncToMain); - if (posSizeChart) posSizeChart.subscribeClick(syncToMain); - }; - - setTimeout(initCharts, 100); - - // Toggle Logic - resultsDiv.querySelectorAll('.toggle-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - const unit = btn.dataset.unit; - - // Sync all toggle button groups - resultsDiv.querySelectorAll(`.toggle-btn`).forEach(b => { - if (b.dataset.unit === unit) b.classList.add('active'); - else b.classList.remove('active'); - }); - - if (equitySeries) { - equitySeries.setData(equityData[unit]); - equityChart.timeScale().fitContent(); - } - if (posSeries) { - posSeries.setData(posSizeData[unit]); - posSizeChart.timeScale().fitContent(); - - const label = document.getElementById('posSizeLabel'); - if (label) label.textContent = `Position Size (${unit.toUpperCase()})`; - } - }); - }); - - document.getElementById('toggleTradeMarkers').addEventListener('click', () => { - toggleSimulationMarkers(trades); - }); - - document.getElementById('clearSim').addEventListener('click', () => { - resultsDiv.style.display = 'none'; - clearSimulationMarkers(); - if (window.dashboard) { - window.dashboard.clearAvgPriceData(); - } - }); -} - -let tradeMarkers = []; - -function toggleSimulationMarkers(trades) { - if (tradeMarkers.length > 0) { - clearSimulationMarkers(); - document.getElementById('toggleTradeMarkers').textContent = 'Show Markers'; - return; - } - - const markers = []; - trades.forEach(t => { - const usdVal = t.currentUsd !== undefined ? `$${t.currentUsd.toFixed(0)}` : '0'; - const qtyVal = t.currentQty !== undefined ? `${t.currentQty.toFixed(4)} BTC` : '0'; - const sizeStr = ` (${usdVal} / ${qtyVal})`; - - // Entry marker - if (t.recordType === 'entry') { - markers.push({ - time: t.time, - position: t.type === 'long' ? 'belowBar' : 'aboveBar', - color: t.type === 'long' ? '#2962ff' : '#9c27b0', - shape: t.type === 'long' ? 'arrowUp' : 'arrowDown', - text: `Entry ${t.type.toUpperCase()}${sizeStr}` - }); - } - - // Exit marker - if (t.recordType === 'exit') { - markers.push({ - time: t.time, - position: t.type === 'long' ? 'aboveBar' : 'belowBar', - color: t.pnl >= 0 ? '#26a69a' : '#ef5350', - shape: t.type === 'long' ? 'arrowDown' : 'arrowUp', - text: `Exit ${t.reason}${sizeStr}` - }); - } - }); - - // Sort markers by time - markers.sort((a, b) => a.time - b.time); - - if (window.dashboard) { - window.dashboard.setSimulationMarkers(markers); - tradeMarkers = markers; - document.getElementById('toggleTradeMarkers').textContent = 'Hide Markers'; - } -} - -function clearSimulationMarkers() { - if (window.dashboard) { - window.dashboard.clearSimulationMarkers(); - tradeMarkers = []; - } -} - -window.clearSimulationResults = function() { - const resultsDiv = document.getElementById('simulationResults'); - if (resultsDiv) resultsDiv.style.display = 'none'; - clearSimulationMarkers(); -}; diff --git a/src/api/dashboard/static/js/utils/helpers.js b/src/api/dashboard/static/js/utils/helpers.js deleted file mode 100644 index 03df981..0000000 --- a/src/api/dashboard/static/js/utils/helpers.js +++ /dev/null @@ -1,23 +0,0 @@ -export function downloadFile(content, filename, mimeType) { - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); -} - -export function formatDate(date) { - return new Date(date).toISOString().slice(0, 16); -} - -export function formatPrice(price, decimals = 2) { - return price.toFixed(decimals); -} - -export function formatPercent(value) { - return (value >= 0 ? '+' : '') + value.toFixed(2) + '%'; -} diff --git a/src/api/dashboard/static/js/utils/index.js b/src/api/dashboard/static/js/utils/index.js deleted file mode 100644 index ea672c1..0000000 --- a/src/api/dashboard/static/js/utils/index.js +++ /dev/null @@ -1 +0,0 @@ -export { downloadFile, formatDate, formatPrice, formatPercent } from './helpers.js'; diff --git a/src/api/server.py b/src/api/server.py deleted file mode 100644 index 11d2419..0000000 --- a/src/api/server.py +++ /dev/null @@ -1,547 +0,0 @@ -""" -Simplified FastAPI server - working version -Removes the complex WebSocket manager that was causing issues -""" - -import os -import asyncio -import logging -from dotenv import load_dotenv - -load_dotenv() -from datetime import datetime, timedelta, timezone -from typing import Optional, List -from contextlib import asynccontextmanager - -from fastapi import FastAPI, HTTPException, Query, BackgroundTasks, Response -from fastapi.staticfiles import StaticFiles -from fastapi.responses import StreamingResponse -from fastapi.middleware.cors import CORSMiddleware -import asyncpg -import csv -import io -from pydantic import BaseModel, Field - -# Imports for backtest runner -from src.data_collector.database import DatabaseManager -from src.data_collector.indicator_engine import IndicatorEngine, IndicatorConfig - - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -# Database connection settings -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', '') - - -async def get_db_pool(): - """Create database connection pool""" - logger.info(f"Connecting to database: {DB_HOST}:{DB_PORT}/{DB_NAME} as {DB_USER}") - return await asyncpg.create_pool( - host=DB_HOST, - port=DB_PORT, - database=DB_NAME, - user=DB_USER, - password=DB_PASSWORD, - min_size=2, - max_size=20, - max_inactive_connection_lifetime=300 - ) - - -pool = None - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Manage application lifespan""" - global pool - pool = await get_db_pool() - logger.info("API Server started successfully") - yield - if pool: - await pool.close() - logger.info("API Server stopped") - - -app = FastAPI( - title="BTC Bot Data API", - description="REST API for accessing BTC candle data", - version="1.1.0", - lifespan=lifespan -) - -# Enable CORS -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.get("/") -async def root(): - """Root endpoint""" - return { - "message": "BTC Bot Data API", - "docs": "/docs", - "dashboard": "/dashboard", - "status": "operational" - } - - -@app.get("/api/v1/candles") -async def get_candles( - symbol: str = Query("BTC", description="Trading pair symbol"), - interval: str = Query("1m", description="Candle interval"), - start: Optional[datetime] = Query(None, description="Start time (ISO format)"), - end: Optional[datetime] = Query(None, description="End time (ISO format)"), - limit: int = Query(1000, ge=1, le=10000, description="Maximum number of candles") -): - """Get candle data for a symbol""" - async with pool.acquire() as conn: - query = """ - SELECT time, symbol, interval, open, high, low, close, volume, validated - FROM candles - WHERE symbol = $1 AND interval = $2 - """ - params = [symbol, interval] - - if start: - query += f" AND time >= ${len(params) + 1}" - params.append(start) - - if end: - query += f" AND time <= ${len(params) + 1}" - params.append(end) - - query += f" ORDER BY time DESC LIMIT ${len(params) + 1}" - params.append(limit) - - rows = await conn.fetch(query, *params) - - return { - "symbol": symbol, - "interval": interval, - "count": len(rows), - "candles": [dict(row) for row in rows] - } - - -from typing import Optional, List - -# ... - -@app.get("/api/v1/candles/bulk") -async def get_candles_bulk( - symbol: str = Query("BTC"), - timeframes: List[str] = Query(["1h"]), - start: datetime = Query(...), - end: Optional[datetime] = Query(None), -): - """Get multiple timeframes of candles in a single request for client-side processing""" - logger.info(f"Bulk candle request: {symbol}, TFs: {timeframes}, Start: {start}, End: {end}") - if not end: - end = datetime.now(timezone.utc) - - results = {} - - async with pool.acquire() as conn: - for tf in timeframes: - rows = await conn.fetch(""" - SELECT time, open, high, low, close, volume - FROM candles - WHERE symbol = $1 AND interval = $2 - AND time >= $3 AND time <= $4 - ORDER BY time ASC - """, symbol, tf, start, end) - - results[tf] = [ - { - "time": r['time'].isoformat(), - "open": float(r['open']), - "high": float(r['high']), - "low": float(r['low']), - "close": float(r['close']), - "volume": float(r['volume']) - } for r in rows - ] - - logger.info(f"Returning {sum(len(v) for v in results.values())} candles total") - return results - - -@app.get("/api/v1/candles/latest") -async def get_latest_candle(symbol: str = "BTC", interval: str = "1m"): - """Get the most recent candle""" - async with pool.acquire() as conn: - row = await conn.fetchrow(""" - SELECT time, symbol, interval, open, high, low, close, volume - FROM candles - WHERE symbol = $1 AND interval = $2 - ORDER BY time DESC - LIMIT 1 - """, symbol, interval) - - if not row: - raise HTTPException(status_code=404, detail="No data found") - - return dict(row) - - -@app.get("/api/v1/stats") -async def get_stats(symbol: str = "BTC"): - """Get trading statistics""" - async with pool.acquire() as conn: - # Get latest price and 24h stats - latest = await conn.fetchrow(""" - SELECT close, time - FROM candles - WHERE symbol = $1 AND interval = '1m' - ORDER BY time DESC - LIMIT 1 - """, symbol) - - day_ago = await conn.fetchrow(""" - SELECT close - FROM candles - WHERE symbol = $1 AND interval = '1m' AND time <= NOW() - INTERVAL '24 hours' - ORDER BY time DESC - LIMIT 1 - """, symbol) - - stats_24h = await conn.fetchrow(""" - SELECT - MAX(high) as high_24h, - MIN(low) as low_24h, - SUM(volume) as volume_24h - FROM candles - WHERE symbol = $1 AND interval = '1m' AND time > NOW() - INTERVAL '24 hours' - """, symbol) - - if not latest: - raise HTTPException(status_code=404, detail="No data found") - - current_price = float(latest['close']) - previous_price = float(day_ago['close']) if day_ago else current_price - change_24h = ((current_price - previous_price) / previous_price * 100) if previous_price else 0 - - return { - "symbol": symbol, - "current_price": current_price, - "change_24h": round(change_24h, 2), - "high_24h": float(stats_24h['high_24h']) if stats_24h['high_24h'] else current_price, - "low_24h": float(stats_24h['low_24h']) if stats_24h['low_24h'] else current_price, - "volume_24h": float(stats_24h['volume_24h']) if stats_24h['volume_24h'] else 0, - "last_update": latest['time'].isoformat() - } - - -@app.get("/api/v1/health") -async def health_check(): - """System health check""" - try: - async with pool.acquire() as conn: - latest = await conn.fetchrow(""" - SELECT symbol, MAX(time) as last_time, COUNT(*) as count - FROM candles - WHERE time > NOW() - INTERVAL '24 hours' - GROUP BY symbol - """) - - return { - "status": "healthy", - "database": "connected", - "latest_candles": dict(latest) if latest else None, - "timestamp": datetime.utcnow().isoformat() - } - except Exception as e: - logger.error(f"Health check failed: {e}") - raise HTTPException(status_code=503, detail=f"Health check failed: {str(e)}") - - -@app.get("/api/v1/indicators") -async def get_indicators( - symbol: str = Query("BTC", description="Trading pair symbol"), - interval: str = Query("1d", description="Candle interval"), - name: str = Query(None, description="Filter by indicator name (e.g., ma44)"), - start: Optional[datetime] = Query(None, description="Start time"), - end: Optional[datetime] = Query(None, description="End time"), - limit: int = Query(1000, le=5000) -): - """Get indicator values""" - async with pool.acquire() as conn: - query = """ - SELECT time, indicator_name, value - FROM indicators - WHERE symbol = $1 AND interval = $2 - """ - params = [symbol, interval] - - if name: - query += f" AND indicator_name = ${len(params) + 1}" - params.append(name) - - if start: - query += f" AND time >= ${len(params) + 1}" - params.append(start) - - if end: - query += f" AND time <= ${len(params) + 1}" - params.append(end) - - query += f" ORDER BY time DESC LIMIT ${len(params) + 1}" - params.append(limit) - - rows = await conn.fetch(query, *params) - - # Group by time for easier charting - grouped = {} - for row in rows: - ts = row['time'].isoformat() - if ts not in grouped: - grouped[ts] = {'time': ts} - grouped[ts][row['indicator_name']] = float(row['value']) - - return { - "symbol": symbol, - "interval": interval, - "data": list(grouped.values()) - } - - -@app.get("/api/v1/decisions") -async def get_decisions( - symbol: str = Query("BTC"), - interval: Optional[str] = Query(None), - backtest_id: Optional[str] = Query(None), - limit: int = Query(100, le=1000) -): - """Get brain decisions""" - async with pool.acquire() as conn: - query = """ - SELECT time, interval, decision_type, strategy, confidence, - price_at_decision, indicator_snapshot, reasoning, backtest_id - FROM decisions - WHERE symbol = $1 - """ - params = [symbol] - - if interval: - query += f" AND interval = ${len(params) + 1}" - params.append(interval) - - if backtest_id: - query += f" AND backtest_id = ${len(params) + 1}" - params.append(backtest_id) - else: - query += " AND backtest_id IS NULL" - - query += f" ORDER BY time DESC LIMIT ${len(params) + 1}" - params.append(limit) - - rows = await conn.fetch(query, *params) - return [dict(row) for row in rows] - - -@app.get("/api/v1/backtests") -async def list_backtests(symbol: Optional[str] = None, limit: int = 20): - """List historical backtests""" - async with pool.acquire() as conn: - query = """ - SELECT id, strategy, symbol, start_time, end_time, - intervals, results, created_at - FROM backtest_runs - """ - params = [] - if symbol: - query += " WHERE symbol = $1" - params.append(symbol) - - query += f" ORDER BY created_at DESC LIMIT ${len(params) + 1}" - params.append(limit) - - rows = await conn.fetch(query, *params) - return [dict(row) for row in rows] - - -@app.get("/api/v1/ta") -async def get_technical_analysis( - symbol: str = Query("BTC", description="Trading pair symbol"), - interval: str = Query("1d", description="Candle interval") -): - """ - Get technical analysis for a symbol - Uses stored indicators from DB if available, falls back to on-the-fly calc - """ - try: - async with pool.acquire() as conn: - # 1. Get latest price - latest = await conn.fetchrow(""" - SELECT close, time - FROM candles - WHERE symbol = $1 AND interval = $2 - ORDER BY time DESC - LIMIT 1 - """, symbol, interval) - - if not latest: - return {"error": "No candle data found"} - - current_price = float(latest['close']) - timestamp = latest['time'] - - # 2. Get latest indicators from DB - indicators = await conn.fetch(""" - SELECT indicator_name, value - FROM indicators - WHERE symbol = $1 AND interval = $2 - AND time <= $3 - ORDER BY time DESC - """, symbol, interval, timestamp) - - # Convert list to dict, e.g. {'ma44': 65000, 'ma125': 64000} - # We take the most recent value for each indicator - ind_map = {} - for row in indicators: - name = row['indicator_name'] - if name not in ind_map: - ind_map[name] = float(row['value']) - - ma_44 = ind_map.get('ma44') - ma_125 = ind_map.get('ma125') - - # Determine trend - if ma_44 and ma_125: - if current_price > ma_44 > ma_125: - trend = "Bullish" - trend_strength = "Strong" if current_price > ma_44 * 1.05 else "Moderate" - elif current_price < ma_44 < ma_125: - trend = "Bearish" - trend_strength = "Strong" if current_price < ma_44 * 0.95 else "Moderate" - else: - trend = "Neutral" - trend_strength = "Consolidation" - else: - trend = "Unknown" - trend_strength = "Insufficient data" - - # 3. Find support/resistance (simple recent high/low) - rows = await conn.fetch(""" - SELECT high, low - FROM candles - WHERE symbol = $1 AND interval = $2 - ORDER BY time DESC - LIMIT 20 - """, symbol, interval) - - if rows: - highs = [float(r['high']) for r in rows] - lows = [float(r['low']) for r in rows] - resistance = max(highs) - support = min(lows) - - price_range = resistance - support - if price_range > 0: - position = (current_price - support) / price_range * 100 - else: - position = 50 - else: - resistance = current_price - support = current_price - position = 50 - - return { - "symbol": symbol, - "interval": interval, - "timestamp": timestamp.isoformat(), - "current_price": round(current_price, 2), - "moving_averages": { - "ma_44": round(ma_44, 2) if ma_44 else None, - "ma_125": round(ma_125, 2) if ma_125 else None, - "price_vs_ma44": round((current_price / ma_44 - 1) * 100, 2) if ma_44 else None, - "price_vs_ma125": round((current_price / ma_125 - 1) * 100, 2) if ma_125 else None - }, - "trend": { - "direction": trend, - "strength": trend_strength, - "signal": "Buy" if trend == "Bullish" and trend_strength == "Strong" else - "Sell" if trend == "Bearish" and trend_strength == "Strong" else "Hold" - }, - "levels": { - "resistance": round(resistance, 2), - "support": round(support, 2), - "position_in_range": round(position, 1) - }, - "ai_placeholder": { - "available": False, - "message": "AI analysis available via Gemini or local LLM", - "action": "Click to analyze with AI" - } - } - - except Exception as e: - logger.error(f"Technical analysis error: {e}") - raise HTTPException(status_code=500, detail=f"Technical analysis failed: {str(e)}") - - -@app.get("/api/v1/export/csv") -async def export_csv( - symbol: str = "BTC", - interval: str = "1m", - days: int = Query(7, ge=1, le=365, description="Number of days to export") -): - """Export candle data to CSV""" - start_date = datetime.utcnow() - timedelta(days=days) - - async with pool.acquire() as conn: - query = """ - SELECT time, open, high, low, close, volume - FROM candles - WHERE symbol = $1 AND interval = $2 AND time >= $3 - ORDER BY time - """ - rows = await conn.fetch(query, symbol, interval, start_date) - - if not rows: - raise HTTPException(status_code=404, detail="No data found for export") - - output = io.StringIO() - writer = csv.writer(output) - writer.writerow(['timestamp', 'open', 'high', 'low', 'close', 'volume']) - - for row in rows: - writer.writerow([ - row['time'].isoformat(), - row['open'], - row['high'], - row['low'], - row['close'], - row['volume'] - ]) - - output.seek(0) - - return StreamingResponse( - io.BytesIO(output.getvalue().encode()), - media_type="text/csv", - headers={ - "Content-Disposition": f"attachment; filename={symbol}_{interval}_{days}d.csv" - } - ) - - -# Serve static files for dashboard -app.mount("/dashboard", StaticFiles(directory="src/api/dashboard/static", html=True), name="dashboard") - - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/src/data_collector/__init__.py b/src/data_collector/__init__.py deleted file mode 100644 index cf2dd9c..0000000 --- a/src/data_collector/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -# Data collector module -from .websocket_client import HyperliquidWebSocket, Candle -from .candle_buffer import CandleBuffer -from .database import DatabaseManager -from .backfill import HyperliquidBackfill -from .custom_timeframe_generator import CustomTimeframeGenerator -from .indicator_engine import IndicatorEngine, IndicatorConfig -from .brain import Brain, Decision - -__all__ = [ - 'HyperliquidWebSocket', - 'Candle', - 'CandleBuffer', - 'DatabaseManager', - 'HyperliquidBackfill', - 'CustomTimeframeGenerator', - 'IndicatorEngine', - 'IndicatorConfig', - 'Brain', - 'Decision' -] diff --git a/src/data_collector/backfill.py b/src/data_collector/backfill.py deleted file mode 100644 index 532b92c..0000000 --- a/src/data_collector/backfill.py +++ /dev/null @@ -1,368 +0,0 @@ -""" -Hyperliquid Historical Data Backfill Module -Downloads candle data from Hyperliquid REST API with pagination support -""" - -import asyncio -import logging -from datetime import datetime, timezone, timedelta -from typing import List, Dict, Any, Optional -import aiohttp - -from .database import DatabaseManager -from .websocket_client import Candle - - -logger = logging.getLogger(__name__) - - -class HyperliquidBackfill: - """ - Backfills historical candle data from Hyperliquid REST API - - API Limitations: - - Max 5000 candles per coin/interval combination - - 500 candles per response (requires pagination) - - Available intervals: 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 8h, 12h, 1d, 3d, 1w, 1M - """ - - API_URL = "https://api.hyperliquid.xyz/info" - MAX_CANDLES_PER_REQUEST = 500 - # Hyperliquid API might limit total history, but we'll set a high limit - # and stop when no more data is returned - MAX_TOTAL_CANDLES = 500000 - - # Standard timeframes supported by Hyperliquid - INTERVALS = [ - "1m", "3m", "5m", "15m", "30m", - "1h", "2h", "4h", "8h", "12h", - "1d", "3d", "1w", "1M" - ] - - def __init__( - self, - db: DatabaseManager, - coin: str = "BTC", - intervals: Optional[List[str]] = None - ): - self.db = db - self.coin = coin - self.intervals = intervals or ["1m"] # Default to 1m - self.session: Optional[aiohttp.ClientSession] = None - - async def __aenter__(self): - """Async context manager entry""" - self.session = aiohttp.ClientSession() - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - """Async context manager exit""" - if self.session: - await self.session.close() - - async def fetch_candles( - self, - interval: str, - start_time: datetime, - end_time: Optional[datetime] = None - ) -> List[Candle]: - """ - Fetch candles for a specific interval with pagination - - Args: - interval: Candle interval (e.g., "1m", "1h", "1d") - start_time: Start time (inclusive) - end_time: End time (inclusive, defaults to now) - - Returns: - List of Candle objects - """ - if interval not in self.INTERVALS: - raise ValueError(f"Invalid interval: {interval}. Must be one of {self.INTERVALS}") - - end_time = end_time or datetime.now(timezone.utc) - - # Convert to milliseconds - start_ms = int(start_time.timestamp() * 1000) - end_ms = int(end_time.timestamp() * 1000) - - all_candles = [] - total_fetched = 0 - - while total_fetched < self.MAX_TOTAL_CANDLES: - logger.info(f"Fetching {interval} candles from {datetime.fromtimestamp(start_ms/1000, tz=timezone.utc)} " - f"(batch {total_fetched//self.MAX_CANDLES_PER_REQUEST + 1})") - - try: - batch = await self._fetch_batch(interval, start_ms, end_ms) - - if not batch: - logger.info(f"No more {interval} candles available") - break - - all_candles.extend(batch) - total_fetched += len(batch) - - logger.info(f"Fetched {len(batch)} {interval} candles (total: {total_fetched})") - - # Check if we got less than max, means we're done - if len(batch) < self.MAX_CANDLES_PER_REQUEST: - break - - # Update start_time for next batch (last candle's time + 1ms) - last_candle = batch[-1] - start_ms = int(last_candle.time.timestamp() * 1000) + 1 - - # Small delay to avoid rate limiting - await asyncio.sleep(0.1) - - except Exception as e: - logger.error(f"Error fetching {interval} candles: {e}") - break - - logger.info(f"Backfill complete for {interval}: {len(all_candles)} candles total") - return all_candles - - async def _fetch_batch( - self, - interval: str, - start_ms: int, - end_ms: int - ) -> List[Candle]: - """Fetch a single batch of candles from the API""" - if not self.session: - raise RuntimeError("Session not initialized. Use async context manager.") - - payload = { - "type": "candleSnapshot", - "req": { - "coin": self.coin, - "interval": interval, - "startTime": start_ms, - "endTime": end_ms - } - } - - async with self.session.post(self.API_URL, json=payload) as response: - if response.status != 200: - text = await response.text() - raise Exception(f"API error {response.status}: {text}") - - data = await response.json() - - if not isinstance(data, list): - logger.warning(f"Unexpected response format: {data}") - return [] - - candles = [] - for item in data: - try: - candle = self._parse_candle_item(item, interval) - if candle: - candles.append(candle) - except Exception as e: - logger.warning(f"Failed to parse candle: {item}, error: {e}") - - return candles - - def _parse_candle_item(self, data: Dict[str, Any], interval: str) -> Optional[Candle]: - """Parse a single candle item from API response""" - try: - # Format: {"t": 1770812400000, "T": ..., "s": "BTC", "i": "1m", "o": "67164.0", ...} - timestamp_ms = int(data.get("t", 0)) - timestamp = datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc) - - return Candle( - time=timestamp, - symbol=self.coin, - interval=interval, - open=float(data.get("o", 0)), - high=float(data.get("h", 0)), - low=float(data.get("l", 0)), - close=float(data.get("c", 0)), - volume=float(data.get("v", 0)) - ) - except (KeyError, ValueError, TypeError) as e: - logger.error(f"Failed to parse candle data: {e}, data: {data}") - return None - - async def backfill_interval( - self, - interval: str, - days_back: int = 7 - ) -> int: - """ - Backfill a specific interval for the last N days - - Args: - interval: Candle interval - days_back: Number of days to backfill (use 0 for max available) - - Returns: - Number of candles inserted - """ - if days_back == 0: - # Fetch maximum available data (5000 candles) - return await self.backfill_max(interval) - - end_time = datetime.now(timezone.utc) - start_time = end_time - timedelta(days=days_back) - - logger.info(f"Starting backfill for {interval}: {start_time} to {end_time}") - - candles = await self.fetch_candles(interval, start_time, end_time) - - if not candles: - logger.warning(f"No candles fetched for {interval}") - return 0 - - # Insert into database - inserted = await self.db.insert_candles(candles) - logger.info(f"Inserted {inserted} candles for {interval}") - - return inserted - - async def backfill_max(self, interval: str) -> int: - """ - Backfill maximum available data (5000 candles) for an interval - - Args: - interval: Candle interval - - Returns: - Number of candles inserted - """ - logger.info(f"Fetching maximum available {interval} data (up to 5000 candles)") - - # For weekly and monthly, start from 2020 to ensure we get all available data - # Hyperliquid launched around 2023, so this should capture everything - start_time = datetime(2020, 1, 1, tzinfo=timezone.utc) - end_time = datetime.now(timezone.utc) - - logger.info(f"Fetching {interval} candles from {start_time} to {end_time}") - - candles = await self.fetch_candles(interval, start_time, end_time) - - if not candles: - logger.warning(f"No candles fetched for {interval}") - return 0 - - # Insert into database - inserted = await self.db.insert_candles(candles) - logger.info(f"Inserted {inserted} candles for {interval} (max available)") - - return inserted - - def _interval_to_minutes(self, interval: str) -> int: - """Convert interval string to minutes""" - mapping = { - "1m": 1, "3m": 3, "5m": 5, "15m": 15, "30m": 30, - "1h": 60, "2h": 120, "4h": 240, "8h": 480, "12h": 720, - "1d": 1440, "3d": 4320, "1w": 10080, "1M": 43200 - } - return mapping.get(interval, 1) - - async def backfill_all_intervals( - self, - days_back: int = 7 - ) -> Dict[str, int]: - """ - Backfill all configured intervals - - Args: - days_back: Number of days to backfill - - Returns: - Dictionary mapping interval to count inserted - """ - results = {} - - for interval in self.intervals: - try: - count = await self.backfill_interval(interval, days_back) - results[interval] = count - except Exception as e: - logger.error(f"Failed to backfill {interval}: {e}") - results[interval] = 0 - - return results - - async def get_earliest_candle_time(self, interval: str) -> Optional[datetime]: - """Get the earliest candle time available for an interval""" - # Try fetching from epoch to find earliest available - start_time = datetime(2020, 1, 1, tzinfo=timezone.utc) - end_time = datetime.now(timezone.utc) - - candles = await self.fetch_candles(interval, start_time, end_time) - - if candles: - earliest = min(c.time for c in candles) - logger.info(f"Earliest {interval} candle available: {earliest}") - return earliest - return None - - -async def main(): - """CLI entry point for backfill""" - import argparse - import os - - parser = argparse.ArgumentParser(description="Backfill Hyperliquid historical data") - parser.add_argument("--coin", default="BTC", help="Coin symbol (default: BTC)") - parser.add_argument("--intervals", nargs="+", default=["1m"], - help="Intervals to backfill (default: 1m)") - parser.add_argument("--days", type=str, default="7", - help="Days to backfill (default: 7, use 'max' for maximum available)") - parser.add_argument("--db-host", default=os.getenv("DB_HOST", "localhost"), - help="Database host (default: localhost or DB_HOST env)") - parser.add_argument("--db-port", type=int, default=int(os.getenv("DB_PORT", 5432)), - help="Database port (default: 5432 or DB_PORT env)") - parser.add_argument("--db-name", default=os.getenv("DB_NAME", "btc_data"), - help="Database name (default: btc_data or DB_NAME env)") - parser.add_argument("--db-user", default=os.getenv("DB_USER", "btc_bot"), - help="Database user (default: btc_bot or DB_USER env)") - parser.add_argument("--db-password", default=os.getenv("DB_PASSWORD", ""), - help="Database password (default: from DB_PASSWORD env)") - - args = parser.parse_args() - - # Parse days argument - if args.days.lower() == "max": - days_back = 0 # 0 means max available - logger.info("Backfill mode: MAX (fetching up to 5000 candles per interval)") - else: - days_back = int(args.days) - logger.info(f"Backfill mode: Last {days_back} days") - - # Setup logging - logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' - ) - - # Initialize database - db = DatabaseManager( - host=args.db_host, - port=args.db_port, - database=args.db_name, - user=args.db_user, - password=args.db_password - ) - - await db.connect() - - try: - async with HyperliquidBackfill(db, args.coin, args.intervals) as backfill: - results = await backfill.backfill_all_intervals(days_back) - - print("\n=== Backfill Summary ===") - for interval, count in results.items(): - print(f"{interval}: {count} candles") - print(f"Total: {sum(results.values())} candles") - - finally: - await db.disconnect() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/src/data_collector/backfill_gap.py b/src/data_collector/backfill_gap.py deleted file mode 100644 index 4ab0f61..0000000 --- a/src/data_collector/backfill_gap.py +++ /dev/null @@ -1,154 +0,0 @@ -""" -One-time backfill script to fill gaps in data. -Run with: python -m data_collector.backfill_gap --start "2024-01-01 09:34" --end "2024-01-01 19:39" -""" - -import asyncio -import logging -import sys -import os -from datetime import datetime, timezone -from typing import Optional - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from .database import DatabaseManager -from .backfill import HyperliquidBackfill - -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -INTERVALS = ["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "8h", "12h", "1d", "3d", "1w"] - - -async def backfill_gap( - start_time: datetime, - end_time: datetime, - symbol: str = "BTC", - intervals: Optional[list] = None -) -> dict: - """ - Backfill a specific time gap for all intervals. - - Args: - start_time: Gap start time (UTC) - end_time: Gap end time (UTC) - symbol: Trading symbol - intervals: List of intervals to backfill (default: all standard) - - Returns: - Dictionary with interval -> count mapping - """ - intervals = intervals or INTERVALS - results = {} - - db = DatabaseManager() - await db.connect() - - logger.info(f"Backfilling gap: {start_time} to {end_time} for {symbol}") - - try: - async with HyperliquidBackfill(db, symbol, intervals) as backfill: - for interval in intervals: - try: - logger.info(f"Backfilling {interval}...") - candles = await backfill.fetch_candles(interval, start_time, end_time) - - if candles: - inserted = await db.insert_candles(candles) - results[interval] = inserted - logger.info(f" {interval}: {inserted} candles inserted") - else: - results[interval] = 0 - logger.warning(f" {interval}: No candles returned") - - await asyncio.sleep(0.3) - - except Exception as e: - logger.error(f" {interval}: Error - {e}") - results[interval] = 0 - - finally: - await db.disconnect() - - logger.info(f"Backfill complete. Total: {sum(results.values())} candles") - return results - - -async def auto_detect_and_fill_gaps(symbol: str = "BTC") -> dict: - """ - Detect and fill all gaps in the database for all intervals. - """ - db = DatabaseManager() - await db.connect() - - results = {} - - try: - async with HyperliquidBackfill(db, symbol, INTERVALS) as backfill: - for interval in INTERVALS: - try: - # Detect gaps - gaps = await db.detect_gaps(symbol, interval) - - if not gaps: - logger.info(f"{interval}: No gaps detected") - results[interval] = 0 - continue - - logger.info(f"{interval}: {len(gaps)} gaps detected") - total_filled = 0 - - for gap in gaps: - gap_start = datetime.fromisoformat(gap['gap_start'].replace('Z', '+00:00')) - gap_end = datetime.fromisoformat(gap['gap_end'].replace('Z', '+00:00')) - - logger.info(f" Filling gap: {gap_start} to {gap_end}") - - candles = await backfill.fetch_candles(interval, gap_start, gap_end) - - if candles: - inserted = await db.insert_candles(candles) - total_filled += inserted - logger.info(f" Filled {inserted} candles") - - await asyncio.sleep(0.2) - - results[interval] = total_filled - - except Exception as e: - logger.error(f"{interval}: Error - {e}") - results[interval] = 0 - - finally: - await db.disconnect() - - return results - - -async def main(): - import argparse - - parser = argparse.ArgumentParser(description="Backfill gaps in BTC data") - parser.add_argument("--start", help="Start time (YYYY-MM-DD HH:MM)", default=None) - parser.add_argument("--end", help="End time (YYYY-MM-DD HH:MM)", default=None) - parser.add_argument("--auto", action="store_true", help="Auto-detect and fill all gaps") - parser.add_argument("--symbol", default="BTC", help="Symbol to backfill") - - args = parser.parse_args() - - if args.auto: - await auto_detect_and_fill_gaps(args.symbol) - elif args.start and args.end: - start_time = datetime.strptime(args.start, "%Y-%m-%d %H:%M").replace(tzinfo=timezone.utc) - end_time = datetime.strptime(args.end, "%Y-%m-%d %H:%M").replace(tzinfo=timezone.utc) - await backfill_gap(start_time, end_time, args.symbol) - else: - parser.print_help() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/src/data_collector/brain.py b/src/data_collector/brain.py deleted file mode 100644 index a8a7816..0000000 --- a/src/data_collector/brain.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -Brain - Simplified indicator evaluation -""" - -import json -import logging -from dataclasses import dataclass -from datetime import datetime, timezone -from typing import Dict, Optional, Any, List - -from .database import DatabaseManager -from .indicator_engine import IndicatorEngine - -logger = logging.getLogger(__name__) - -@dataclass -class Decision: - """A single brain evaluation result""" - time: datetime - symbol: str - interval: str - decision_type: str # "buy", "sell", "hold" - strategy: str - confidence: float - price_at_decision: float - indicator_snapshot: Dict[str, Any] - candle_snapshot: Dict[str, Any] - reasoning: str - backtest_id: Optional[str] = None - - def to_db_tuple(self) -> tuple: - """Convert to positional tuple for DB insert""" - return ( - self.time, - self.symbol, - self.interval, - self.decision_type, - self.strategy, - self.confidence, - self.price_at_decision, - json.dumps(self.indicator_snapshot), - json.dumps(self.candle_snapshot), - self.reasoning, - self.backtest_id, - ) - - -class Brain: - """ - Evaluates market conditions using indicators. - Simplified version without complex strategy plug-ins. - """ - - def __init__( - self, - db: DatabaseManager, - indicator_engine: IndicatorEngine, - strategy: str = "default", - ): - self.db = db - self.indicator_engine = indicator_engine - self.strategy_name = strategy - - logger.info("Brain initialized (Simplified)") - - async def evaluate( - self, - symbol: str, - interval: str, - timestamp: datetime, - indicators: Optional[Dict[str, float]] = None, - backtest_id: Optional[str] = None, - current_position: Optional[Dict[str, Any]] = None, - ) -> Decision: - """ - Evaluate market conditions and produce a decision. - """ - # Get indicator values - if indicators is None: - indicators = await self.indicator_engine.get_values_at( - symbol, interval, timestamp - ) - - # Get the triggering candle - candle = await self._get_candle(symbol, interval, timestamp) - if not candle: - return self._create_empty_decision(timestamp, symbol, interval, indicators, backtest_id) - - price = float(candle["close"]) - candle_dict = { - "time": candle["time"].isoformat(), - "open": float(candle["open"]), - "high": float(candle["high"]), - "low": float(candle["low"]), - "close": price, - "volume": float(candle["volume"]), - } - - # Simple crossover logic example if needed, otherwise just return HOLD - # For now, we just return a neutral decision as "Strategies" are removed - decision = Decision( - time=timestamp, - symbol=symbol, - interval=interval, - decision_type="hold", - strategy=self.strategy_name, - confidence=0.0, - price_at_decision=price, - indicator_snapshot=indicators, - candle_snapshot=candle_dict, - reasoning="Strategy logic removed - Dashboard shows indicators", - backtest_id=backtest_id, - ) - - # Store to DB - await self._store_decision(decision) - - return decision - - def _create_empty_decision(self, timestamp, symbol, interval, indicators, backtest_id): - return Decision( - time=timestamp, - symbol=symbol, - interval=interval, - decision_type="hold", - strategy=self.strategy_name, - confidence=0.0, - price_at_decision=0.0, - indicator_snapshot=indicators or {}, - candle_snapshot={}, - reasoning="No candle data available", - backtest_id=backtest_id, - ) - - async def _get_candle( - self, - symbol: str, - interval: str, - timestamp: datetime, - ) -> Optional[Dict[str, Any]]: - """Fetch a specific candle from the database""" - async with self.db.acquire() as conn: - row = await conn.fetchrow(""" - SELECT time, open, high, low, close, volume - FROM candles - WHERE symbol = $1 AND interval = $2 AND time = $3 - """, symbol, interval, timestamp) - - return dict(row) if row else None - - async def _store_decision(self, decision: Decision) -> None: - """Write decision to the decisions table""" - async with self.db.acquire() as conn: - await conn.execute(""" - INSERT INTO decisions ( - time, symbol, interval, decision_type, strategy, - confidence, price_at_decision, indicator_snapshot, - candle_snapshot, reasoning, backtest_id - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - """, *decision.to_db_tuple()) - - async def get_recent_decisions( - self, - symbol: str, - limit: int = 20, - backtest_id: Optional[str] = None, - ) -> List[Dict[str, Any]]: - """Get recent decisions, optionally filtered by backtest_id""" - async with self.db.acquire() as conn: - if backtest_id is not None: - rows = await conn.fetch(""" - SELECT time, symbol, interval, decision_type, strategy, - confidence, price_at_decision, indicator_snapshot, - candle_snapshot, reasoning, backtest_id - FROM decisions - WHERE symbol = $1 AND backtest_id = $2 - ORDER BY time DESC - LIMIT $3 - """, symbol, backtest_id, limit) - else: - rows = await conn.fetch(""" - SELECT time, symbol, interval, decision_type, strategy, - confidence, price_at_decision, indicator_snapshot, - candle_snapshot, reasoning, backtest_id - FROM decisions - WHERE symbol = $1 AND backtest_id IS NULL - ORDER BY time DESC - LIMIT $2 - """, symbol, limit) - - return [dict(row) for row in rows] - - def reset_state(self) -> None: - """Reset internal state tracking""" - pass diff --git a/src/data_collector/candle_buffer.py b/src/data_collector/candle_buffer.py deleted file mode 100644 index 811d32d..0000000 --- a/src/data_collector/candle_buffer.py +++ /dev/null @@ -1,224 +0,0 @@ -""" -In-memory candle buffer with automatic batching -Optimized for low memory footprint on DS218+ -""" - -import asyncio -import logging -from collections import deque -from datetime import datetime, timezone -from typing import Dict, List, Optional, Callable, Any, Awaitable -from dataclasses import dataclass, field - -from .websocket_client import Candle - - -logger = logging.getLogger(__name__) - - -@dataclass -class BufferStats: - """Statistics for buffer performance monitoring""" - total_added: int = 0 - total_flushed: int = 0 - total_dropped: int = 0 - last_flush_time: Optional[datetime] = None - avg_batch_size: float = 0.0 - - def to_dict(self) -> Dict[str, Any]: - return { - 'total_added': self.total_added, - 'total_flushed': self.total_flushed, - 'total_dropped': self.total_dropped, - 'last_flush_time': self.last_flush_time.isoformat() if self.last_flush_time else None, - 'avg_batch_size': round(self.avg_batch_size, 2) - } - - -class CandleBuffer: - """ - Thread-safe circular buffer for candle data - Automatically flushes to database in batches - """ - - def __init__( - self, - max_size: int = 1000, - flush_interval_seconds: float = 30.0, - batch_size: int = 100, - on_flush_callback: Optional[Callable[[List[Candle]], Awaitable[None]]] = None - ): - self.max_size = max_size - self.flush_interval = flush_interval_seconds - self.batch_size = batch_size - self.on_flush = on_flush_callback - - # Thread-safe buffer using deque - self._buffer: deque = deque(maxlen=max_size) - self._lock = asyncio.Lock() - self._flush_event = asyncio.Event() - self._stop_event = asyncio.Event() - - self.stats = BufferStats() - self._batch_sizes: deque = deque(maxlen=100) # For averaging - - # Tasks - self._flush_task: Optional[asyncio.Task] = None - - async def start(self) -> None: - """Start the background flush task""" - self._flush_task = asyncio.create_task(self._flush_loop()) - logger.info(f"CandleBuffer started (max_size={self.max_size}, flush_interval={self.flush_interval}s)") - - async def stop(self) -> None: - """Stop the buffer and flush remaining data""" - self._stop_event.set() - self._flush_event.set() # Wake up flush loop - - if self._flush_task: - try: - await asyncio.wait_for(self._flush_task, timeout=10.0) - except asyncio.TimeoutError: - logger.warning("Flush task did not stop in time") - - # Final flush - await self.flush() - logger.info("CandleBuffer stopped") - - async def add(self, candle: Candle) -> bool: - """ - Add a candle to the buffer - Returns True if added, False if buffer full and candle dropped - """ - async with self._lock: - if len(self._buffer) >= self.max_size: - logger.warning(f"Buffer full, dropping oldest candle. Size: {len(self._buffer)}") - self.stats.total_dropped += 1 - - self._buffer.append(candle) - self.stats.total_added += 1 - - # Trigger immediate flush if batch size reached - if len(self._buffer) >= self.batch_size: - self._flush_event.set() - - return True - - async def add_many(self, candles: List[Candle]) -> int: - """Add multiple candles to the buffer""" - added = 0 - for candle in candles: - if await self.add(candle): - added += 1 - return added - - async def get_batch(self, n: Optional[int] = None) -> List[Candle]: - """Get up to N candles from buffer (without removing)""" - async with self._lock: - n = n or len(self._buffer) - return list(self._buffer)[:n] - - async def flush(self) -> int: - """ - Manually flush buffer to callback - Returns number of candles flushed - """ - candles_to_flush: List[Candle] = [] - - async with self._lock: - if not self._buffer: - return 0 - - candles_to_flush = list(self._buffer) - self._buffer.clear() - - if candles_to_flush and self.on_flush: - try: - await self.on_flush(candles_to_flush) - - # Update stats - self.stats.total_flushed += len(candles_to_flush) - self.stats.last_flush_time = datetime.now(timezone.utc) - self._batch_sizes.append(len(candles_to_flush)) - self.stats.avg_batch_size = sum(self._batch_sizes) / len(self._batch_sizes) - - logger.debug(f"Flushed {len(candles_to_flush)} candles") - return len(candles_to_flush) - - except Exception as e: - logger.error(f"Flush callback failed: {e}") - # Put candles back in buffer - async with self._lock: - for candle in reversed(candles_to_flush): - self._buffer.appendleft(candle) - return 0 - elif candles_to_flush: - # No callback, just clear - self.stats.total_flushed += len(candles_to_flush) - return len(candles_to_flush) - - return 0 - - async def _flush_loop(self) -> None: - """Background task to periodically flush buffer""" - while not self._stop_event.is_set(): - try: - # Wait for flush interval or until triggered - await asyncio.wait_for( - self._flush_event.wait(), - timeout=self.flush_interval - ) - self._flush_event.clear() - - # Flush if we have data - buffer_size = await self.get_buffer_size() - if buffer_size > 0: - await self.flush() - - except asyncio.TimeoutError: - # Flush interval reached, flush if we have data - buffer_size = await self.get_buffer_size() - if buffer_size > 0: - await self.flush() - - except Exception as e: - logger.error(f"Error in flush loop: {e}") - await asyncio.sleep(1) - - def get_stats(self) -> BufferStats: - """Get current buffer statistics""" - return self.stats - - async def get_buffer_size(self) -> int: - """Get current buffer size""" - async with self._lock: - return len(self._buffer) - - def detect_gaps(self, candles: List[Candle]) -> List[Dict[str, Any]]: - """ - Detect gaps in candle sequence - Returns list of gap information - """ - if len(candles) < 2: - return [] - - gaps = [] - sorted_candles = sorted(candles, key=lambda c: c.time) - - for i in range(1, len(sorted_candles)): - prev = sorted_candles[i-1] - curr = sorted_candles[i] - - # Calculate expected interval (1 minute) - expected_diff = 60 # seconds - actual_diff = (curr.time - prev.time).total_seconds() - - if actual_diff > expected_diff * 1.5: # Allow 50% tolerance - gaps.append({ - 'from_time': prev.time.isoformat(), - 'to_time': curr.time.isoformat(), - 'missing_candles': int(actual_diff / expected_diff) - 1, - 'duration_seconds': actual_diff - }) - - return gaps \ No newline at end of file diff --git a/src/data_collector/custom_timeframe_generator.py b/src/data_collector/custom_timeframe_generator.py deleted file mode 100644 index ce1aad4..0000000 --- a/src/data_collector/custom_timeframe_generator.py +++ /dev/null @@ -1,401 +0,0 @@ -""" -Custom Timeframe Generator -Generates both standard and custom timeframes from 1m data -Updates "building" candles in real-time -""" - -import asyncio -import logging -import calendar -from datetime import datetime, timedelta, timezone -from typing import List, Optional, Dict, Tuple -from dataclasses import dataclass - -from .database import DatabaseManager -from .websocket_client import Candle - - -logger = logging.getLogger(__name__) - - -@dataclass -class CustomCandle(Candle): - """Extended candle with completion flag""" - is_complete: bool = True - - -class CustomTimeframeGenerator: - """ - Manages and generates multiple timeframes from 1m candles. - Standard intervals use clock-aligned boundaries. - Custom intervals use continuous bucketing from the first recorded 1m candle. - """ - - # Standard intervals (Hyperliquid supported) - STANDARD_INTERVALS = { - '3m': {'type': 'min', 'value': 3}, - '5m': {'type': 'min', 'value': 5}, - '15m': {'type': 'min', 'value': 15}, - '30m': {'type': 'min', 'value': 30}, - '1h': {'type': 'hour', 'value': 1}, - '2h': {'type': 'hour', 'value': 2}, - '4h': {'type': 'hour', 'value': 4}, - '8h': {'type': 'hour', 'value': 8}, - '12h': {'type': 'hour', 'value': 12}, - '1d': {'type': 'day', 'value': 1}, - '3d': {'type': 'day', 'value': 3}, - '1w': {'type': 'week', 'value': 1}, - '1M': {'type': 'month', 'value': 1} - } - - # Custom intervals - CUSTOM_INTERVALS = { - '37m': {'minutes': 37, 'source': '1m'}, - '148m': {'minutes': 148, 'source': '37m'} - } - - def __init__(self, db: DatabaseManager): - self.db = db - self.first_1m_time: Optional[datetime] = None - # Anchor for 3d candles (fixed date) - self.anchor_3d = datetime(2020, 1, 1, tzinfo=timezone.utc) - - async def initialize(self) -> None: - """Get first 1m timestamp for custom continuous bucketing""" - async with self.db.acquire() as conn: - first = await conn.fetchval(""" - SELECT MIN(time) - FROM candles - WHERE interval = '1m' AND symbol = 'BTC' - """) - if first: - self.first_1m_time = first - logger.info(f"TF Generator: First 1m candle at {first}") - else: - logger.warning("TF Generator: No 1m data found") - - def get_bucket_start(self, timestamp: datetime, interval: str) -> datetime: - """Calculate bucket start time for any interval""" - # Handle custom intervals - if interval in self.CUSTOM_INTERVALS: - if not self.first_1m_time: - return timestamp # Fallback if not initialized - minutes = self.CUSTOM_INTERVALS[interval]['minutes'] - delta = timestamp - self.first_1m_time - bucket_num = int(delta.total_seconds() // (minutes * 60)) - return self.first_1m_time + timedelta(minutes=bucket_num * minutes) - - # Handle standard intervals - if interval not in self.STANDARD_INTERVALS: - return timestamp - - cfg = self.STANDARD_INTERVALS[interval] - t = timestamp.replace(second=0, microsecond=0) - - if cfg['type'] == 'min': - n = cfg['value'] - return t - timedelta(minutes=t.minute % n) - elif cfg['type'] == 'hour': - n = cfg['value'] - t = t.replace(minute=0) - return t - timedelta(hours=t.hour % n) - elif cfg['type'] == 'day': - n = cfg['value'] - t = t.replace(hour=0, minute=0) - if n == 1: - return t - else: # 3d - days_since_anchor = (t - self.anchor_3d).days - return t - timedelta(days=days_since_anchor % n) - elif cfg['type'] == 'week': - t = t.replace(hour=0, minute=0) - return t - timedelta(days=t.weekday()) # Monday start - elif cfg['type'] == 'month': - return t.replace(day=1, hour=0, minute=0) - - return t - - def get_expected_1m_count(self, bucket_start: datetime, interval: str) -> int: - """Calculate expected number of 1m candles in a full bucket""" - if interval in self.CUSTOM_INTERVALS: - return self.CUSTOM_INTERVALS[interval]['minutes'] - - if interval in self.STANDARD_INTERVALS: - cfg = self.STANDARD_INTERVALS[interval] - if cfg['type'] == 'min': return cfg['value'] - if cfg['type'] == 'hour': return cfg['value'] * 60 - if cfg['type'] == 'day': return cfg['value'] * 1440 - if cfg['type'] == 'week': return 7 * 1440 - if cfg['type'] == 'month': - _, days = calendar.monthrange(bucket_start.year, bucket_start.month) - return days * 1440 - return 1 - - async def aggregate_and_upsert(self, symbol: str, interval: str, bucket_start: datetime, conn=None) -> None: - """Aggregate 1m data for a specific bucket and upsert""" - bucket_end = bucket_start # Initialize - - if interval == '148m': - # Aggregate from 37m - source_interval = '37m' - expected_count = 4 - else: - source_interval = '1m' - expected_count = self.get_expected_1m_count(bucket_start, interval) - - # Calculate bucket end - if interval == '1M': - _, days = calendar.monthrange(bucket_start.year, bucket_start.month) - bucket_end = bucket_start + timedelta(days=days) - elif interval in self.STANDARD_INTERVALS: - cfg = self.STANDARD_INTERVALS[interval] - if cfg['type'] == 'min': bucket_end = bucket_start + timedelta(minutes=cfg['value']) - elif cfg['type'] == 'hour': bucket_end = bucket_start + timedelta(hours=cfg['value']) - elif cfg['type'] == 'day': bucket_end = bucket_start + timedelta(days=cfg['value']) - elif cfg['type'] == 'week': bucket_end = bucket_start + timedelta(weeks=1) - elif interval in self.CUSTOM_INTERVALS: - minutes = self.CUSTOM_INTERVALS[interval]['minutes'] - bucket_end = bucket_start + timedelta(minutes=minutes) - else: - bucket_end = bucket_start + timedelta(minutes=1) - - # Use provided connection or acquire a new one - if conn is None: - async with self.db.acquire() as connection: - await self._process_aggregation(connection, symbol, interval, source_interval, bucket_start, bucket_end, expected_count) - else: - await self._process_aggregation(conn, symbol, interval, source_interval, bucket_start, bucket_end, expected_count) - - async def _process_aggregation(self, conn, symbol, interval, source_interval, bucket_start, bucket_end, expected_count): - """Internal method to perform aggregation using a specific connection""" - rows = await conn.fetch(f""" - SELECT time, open, high, low, close, volume - FROM candles - WHERE symbol = $1 AND interval = $2 - AND time >= $3 AND time < $4 - ORDER BY time ASC - """, symbol, source_interval, bucket_start, bucket_end) - - if not rows: - return - - # Aggregate - is_complete = len(rows) >= expected_count - - candle = CustomCandle( - time=bucket_start, - symbol=symbol, - interval=interval, - open=float(rows[0]['open']), - high=max(float(r['high']) for r in rows), - low=min(float(r['low']) for r in rows), - close=float(rows[-1]['close']), - volume=sum(float(r['volume']) for r in rows), - is_complete=is_complete - ) - - await self._upsert_candle(candle, conn) - - async def _upsert_candle(self, c: CustomCandle, conn=None) -> None: - """Upsert a single candle using provided connection or acquiring a new one""" - query = """ - INSERT INTO candles (time, symbol, interval, open, high, low, close, volume, validated) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT (time, symbol, interval) DO UPDATE SET - open = EXCLUDED.open, - high = EXCLUDED.high, - low = EXCLUDED.low, - close = EXCLUDED.close, - volume = EXCLUDED.volume, - validated = EXCLUDED.validated, - created_at = NOW() - """ - values = (c.time, c.symbol, c.interval, c.open, c.high, c.low, c.close, c.volume, c.is_complete) - - if conn is None: - async with self.db.acquire() as connection: - await connection.execute(query, *values) - else: - await conn.execute(query, *values) - - async def update_realtime(self, new_1m_candles: List[Candle]) -> None: - """ - Update ALL timeframes (standard and custom) based on new 1m candles. - Called after 1m buffer flush. - Uses a single connection for all updates sequentially to prevent pool exhaustion. - """ - if not new_1m_candles: - return - - if not self.first_1m_time: - await self.initialize() - - if not self.first_1m_time: - return - - symbol = new_1m_candles[0].symbol - - async with self.db.acquire() as conn: - # 1. Update all standard intervals + 37m sequentially - # sequential is required because we are sharing the same connection 'conn' - intervals_to_update = list(self.STANDARD_INTERVALS.keys()) + ['37m'] - - for interval in intervals_to_update: - try: - bucket_start = self.get_bucket_start(new_1m_candles[-1].time, interval) - await self.aggregate_and_upsert(symbol, interval, bucket_start, conn=conn) - except Exception as e: - logger.error(f"Error updating interval {interval}: {e}") - - # 2. Update 148m (it depends on 37m being updated first) - try: - bucket_148m = self.get_bucket_start(new_1m_candles[-1].time, '148m') - await self.aggregate_and_upsert(symbol, '148m', bucket_148m, conn=conn) - except Exception as e: - logger.error(f"Error updating interval 148m: {e}") - - async def generate_historical(self, interval: str, batch_size: int = 5000) -> int: - """ - Force recalculation of all candles for a timeframe from 1m data. - """ - if not self.first_1m_time: - await self.initialize() - - if not self.first_1m_time: - return 0 - - config = self.CUSTOM_INTERVALS.get(interval) or {'source': '1m'} - source_interval = config.get('source', '1m') - - logger.info(f"Generating historical {interval} from {source_interval}...") - - async with self.db.acquire() as conn: - min_max = await conn.fetchrow(""" - SELECT MIN(time), MAX(time) FROM candles - WHERE symbol = 'BTC' AND interval = $1 - """, source_interval) - - if not min_max or not min_max[0]: - return 0 - - curr = self.get_bucket_start(min_max[0], interval) - end = min_max[1] - - total_inserted = 0 - while curr <= end: - await self.aggregate_and_upsert('BTC', interval, curr) - total_inserted += 1 - - if interval == '1M': - _, days = calendar.monthrange(curr.year, curr.month) - curr += timedelta(days=days) - elif interval in self.STANDARD_INTERVALS: - cfg = self.STANDARD_INTERVALS[interval] - if cfg['type'] == 'min': curr += timedelta(minutes=cfg['value']) - elif cfg['type'] == 'hour': curr += timedelta(hours=cfg['value']) - elif cfg['type'] == 'day': curr += timedelta(days=cfg['value']) - elif cfg['type'] == 'week': curr += timedelta(weeks=1) - else: - minutes = self.CUSTOM_INTERVALS[interval]['minutes'] - curr += timedelta(minutes=minutes) - - if total_inserted % 100 == 0: - logger.info(f"Generated {total_inserted} {interval} candles...") - await asyncio.sleep(0.01) - - return total_inserted - - async def generate_from_gap(self, interval: str) -> int: - """ - Generate candles only from where they're missing. - Compares source interval max time with target interval max time. - """ - if not self.first_1m_time: - await self.initialize() - - if not self.first_1m_time: - return 0 - - config = self.CUSTOM_INTERVALS.get(interval) or {'source': '1m'} - source_interval = config.get('source', '1m') - - async with self.db.acquire() as conn: - # Get source range - source_min_max = await conn.fetchrow(""" - SELECT MIN(time), MAX(time) FROM candles - WHERE symbol = 'BTC' AND interval = $1 - """, source_interval) - - if not source_min_max or not source_min_max[1]: - return 0 - - # Get target (this interval) max time - target_max = await conn.fetchval(""" - SELECT MAX(time) FROM candles - WHERE symbol = 'BTC' AND interval = $1 - """, interval) - - source_max = source_min_max[1] - - if target_max: - # Start from next bucket after target_max - curr = self.get_bucket_start(target_max, interval) - if interval in self.CUSTOM_INTERVALS: - minutes = self.CUSTOM_INTERVALS[interval]['minutes'] - curr = curr + timedelta(minutes=minutes) - elif interval in self.STANDARD_INTERVALS: - cfg = self.STANDARD_INTERVALS[interval] - if cfg['type'] == 'min': curr = curr + timedelta(minutes=cfg['value']) - elif cfg['type'] == 'hour': curr = curr + timedelta(hours=cfg['value']) - elif cfg['type'] == 'day': curr = curr + timedelta(days=cfg['value']) - elif cfg['type'] == 'week': curr = curr + timedelta(weeks=1) - else: - # No target data, start from source min - curr = self.get_bucket_start(source_min_max[0], interval) - - end = source_max - - if curr > end: - logger.info(f"{interval}: Already up to date (target: {target_max}, source: {source_max})") - return 0 - - logger.info(f"Generating {interval} from {curr} to {end}...") - - total_inserted = 0 - while curr <= end: - await self.aggregate_and_upsert('BTC', interval, curr) - total_inserted += 1 - - if interval == '1M': - _, days = calendar.monthrange(curr.year, curr.month) - curr += timedelta(days=days) - elif interval in self.STANDARD_INTERVALS: - cfg = self.STANDARD_INTERVALS[interval] - if cfg['type'] == 'min': curr += timedelta(minutes=cfg['value']) - elif cfg['type'] == 'hour': curr += timedelta(hours=cfg['value']) - elif cfg['type'] == 'day': curr += timedelta(days=cfg['value']) - elif cfg['type'] == 'week': curr += timedelta(weeks=1) - else: - minutes = self.CUSTOM_INTERVALS[interval]['minutes'] - curr += timedelta(minutes=minutes) - - if total_inserted % 50 == 0: - logger.info(f"Generated {total_inserted} {interval} candles...") - await asyncio.sleep(0.01) - - logger.info(f"{interval}: Generated {total_inserted} candles") - return total_inserted - - async def verify_integrity(self, interval: str) -> Dict: - async with self.db.acquire() as conn: - stats = await conn.fetchrow(""" - SELECT - COUNT(*) as total_candles, - MIN(time) as earliest, - MAX(time) as latest, - COUNT(*) FILTER (WHERE validated = TRUE) as complete_candles, - COUNT(*) FILTER (WHERE validated = FALSE) as incomplete_candles - FROM candles - WHERE interval = $1 AND symbol = 'BTC' - """, interval) - return dict(stats) if stats else {} diff --git a/src/data_collector/database.py b/src/data_collector/database.py deleted file mode 100644 index 57d7fc8..0000000 --- a/src/data_collector/database.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -Database interface for TimescaleDB -Optimized for batch inserts and low resource usage -""" - -import asyncio -import logging -from contextlib import asynccontextmanager -from datetime import datetime -from typing import List, Dict, Any, Optional -import os - -import asyncpg -from asyncpg import Pool - -from .websocket_client import Candle - - -logger = logging.getLogger(__name__) - - -class DatabaseManager: - """Manages TimescaleDB connections and operations""" - - def __init__( - self, - host: str = None, - port: int = None, - database: str = None, - user: str = None, - password: str = None, - pool_size: int = 20 - ): - self.host = host or os.getenv('DB_HOST', 'localhost') - self.port = port or int(os.getenv('DB_PORT', 5432)) - self.database = database or os.getenv('DB_NAME', 'btc_data') - self.user = user or os.getenv('DB_USER', 'btc_bot') - self.password = password or os.getenv('DB_PASSWORD', '') - self.pool_size = int(os.getenv('DB_POOL_SIZE', pool_size)) - - self.pool: Optional[Pool] = None - - async def connect(self) -> None: - """Initialize connection pool""" - try: - self.pool = await asyncpg.create_pool( - host=self.host, - port=self.port, - database=self.database, - user=self.user, - password=self.password, - min_size=2, - max_size=self.pool_size, - command_timeout=60, - max_inactive_connection_lifetime=300 - ) - - # Test connection - async with self.acquire() as conn: - version = await conn.fetchval('SELECT version()') - logger.info(f"Connected to database: {version[:50]}...") - - logger.info(f"Database pool created (min: 2, max: {self.pool_size})") - - except Exception as e: - logger.error(f"Failed to connect to database: {type(e).__name__}: {e!r}") - raise - - async def disconnect(self) -> None: - """Close connection pool""" - if self.pool: - await self.pool.close() - logger.info("Database pool closed") - - @asynccontextmanager - async def acquire(self, timeout: float = 30.0): - """Context manager for acquiring connection with timeout""" - if not self.pool: - raise RuntimeError("Database not connected") - try: - async with self.pool.acquire(timeout=timeout) as conn: - yield conn - except asyncio.TimeoutError: - logger.error(f"Database connection acquisition timed out after {timeout}s") - raise - - async def insert_candles(self, candles: List[Candle]) -> int: - """ - Batch insert candles into database - Uses ON CONFLICT to handle duplicates - """ - if not candles: - return 0 - - # Prepare values for batch insert - values = [ - ( - c.time, - c.symbol, - c.interval, - c.open, - c.high, - c.low, - c.close, - c.volume, - False, # validated - 'hyperliquid' # source - ) - for c in candles - ] - - async with self.acquire() as conn: - # Use execute_many for efficient batch insert - result = await conn.executemany(''' - INSERT INTO candles (time, symbol, interval, open, high, low, close, volume, validated, source) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (time, symbol, interval) - DO UPDATE SET - open = EXCLUDED.open, - high = EXCLUDED.high, - low = EXCLUDED.low, - close = EXCLUDED.close, - volume = EXCLUDED.volume, - source = EXCLUDED.source - ''', values) - - inserted = len(candles) - logger.debug(f"Inserted/updated {inserted} candles") - return inserted - - async def get_candles( - self, - symbol: str, - interval: str, - start: Optional[datetime] = None, - end: Optional[datetime] = None, - limit: int = 1000 - ) -> List[Dict[str, Any]]: - """Query candles from database""" - query = ''' - SELECT time, symbol, interval, open, high, low, close, volume, validated - FROM candles - WHERE symbol = $1 AND interval = $2 - ''' - params = [symbol, interval] - - if start: - query += ' AND time >= $3' - params.append(start) - - if end: - query += f' AND time <= ${len(params) + 1}' - params.append(end) - - query += f' ORDER BY time DESC LIMIT ${len(params) + 1}' - params.append(limit) - - async with self.acquire() as conn: - rows = await conn.fetch(query, *params) - return [dict(row) for row in rows] - - async def get_latest_candle(self, symbol: str, interval: str) -> Optional[Dict[str, Any]]: - """Get the most recent candle for a symbol""" - async with self.acquire() as conn: - row = await conn.fetchrow(''' - SELECT time, symbol, interval, open, high, low, close, volume - FROM candles - WHERE symbol = $1 AND interval = $2 - ORDER BY time DESC - LIMIT 1 - ''', symbol, interval) - - return dict(row) if row else None - - async def detect_gaps( - self, - symbol: str, - interval: str, - since: Optional[datetime] = None - ) -> List[Dict[str, Any]]: - """ - Detect missing candles in the database - Uses SQL window functions for efficiency - """ - since = since or datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - - async with self.acquire() as conn: - # Find gaps using lead/lag window functions - rows = await conn.fetch(''' - WITH ordered AS ( - SELECT - time, - LAG(time) OVER (ORDER BY time) as prev_time - FROM candles - WHERE symbol = $1 - AND interval = $2 - AND time >= $3 - ORDER BY time - ) - SELECT - prev_time as gap_start, - time as gap_end, - EXTRACT(EPOCH FROM (time - prev_time)) / 60 - 1 as missing_candles - FROM ordered - WHERE time - prev_time > INTERVAL '2 minutes' - ORDER BY prev_time - ''', symbol, interval, since) - - return [ - { - 'gap_start': row['gap_start'].isoformat(), - 'gap_end': row['gap_end'].isoformat(), - 'missing_candles': int(row['missing_candles']) - } - for row in rows - ] - - async def log_quality_issue( - self, - check_type: str, - severity: str, - symbol: Optional[str] = None, - details: Optional[Dict[str, Any]] = None - ) -> None: - """Log a data quality issue""" - async with self.acquire() as conn: - await conn.execute(''' - INSERT INTO data_quality (check_type, severity, symbol, details) - VALUES ($1, $2, $3, $4) - ''', check_type, severity, symbol, details) - - logger.warning(f"Quality issue logged: {check_type} ({severity})") - - async def get_health_stats(self) -> Dict[str, Any]: - """Get database health statistics""" - async with self.acquire() as conn: - # Get table sizes - table_stats = await conn.fetch(''' - SELECT - relname as table_name, - pg_size_pretty(pg_total_relation_size(relid)) as size, - n_live_tup as row_count - FROM pg_stat_user_tables - WHERE relname IN ('candles', 'indicators', 'data_quality') - ''') - - # Get latest candles - latest = await conn.fetch(''' - SELECT symbol, MAX(time) as last_time, COUNT(*) as count - FROM candles - WHERE time > NOW() - INTERVAL '24 hours' - GROUP BY symbol - ''') - - return { - 'tables': [dict(row) for row in table_stats], - 'latest_candles': [dict(row) for row in latest], - 'unresolved_issues': await conn.fetchval(''' - SELECT COUNT(*) FROM data_quality WHERE resolved = FALSE - ''') - } \ No newline at end of file diff --git a/src/data_collector/indicator_engine.py b/src/data_collector/indicator_engine.py deleted file mode 100644 index be5f2dc..0000000 --- a/src/data_collector/indicator_engine.py +++ /dev/null @@ -1,285 +0,0 @@ -""" -Indicator Engine - Computes and stores technical indicators -Stateless DB-backed design: same code for live updates and backtesting -""" - -import asyncio -import json -import logging -from dataclasses import dataclass, field -from datetime import datetime, timezone -from typing import Dict, List, Optional, Any - -from .database import DatabaseManager - - -logger = logging.getLogger(__name__) - - -@dataclass -class IndicatorConfig: - """Configuration for a single indicator""" - name: str # e.g., "ma44" - type: str # e.g., "sma" - period: int # e.g., 44 - intervals: List[str] # e.g., ["37m", "148m", "1d"] - - @classmethod - def from_dict(cls, name: str, data: Dict[str, Any]) -> "IndicatorConfig": - """Create config from YAML dict entry""" - return cls( - name=name, - type=data["type"], - period=data["period"], - intervals=data["intervals"], - ) - - -@dataclass -class IndicatorResult: - """Result of a single indicator computation""" - name: str - value: Optional[float] - period: int - timestamp: datetime - - -class IndicatorEngine: - """ - Computes technical indicators from candle data in the database. - - Two modes, same math: - - on_interval_update(): called by live system after higher-TF candle update - - compute_at(): called by backtester for a specific point in time - Both query the DB for the required candle history and store results. - """ - - def __init__(self, db: DatabaseManager, configs: List[IndicatorConfig]): - self.db = db - self.configs = configs - # Build lookup: interval -> list of configs that need computation - self._interval_configs: Dict[str, List[IndicatorConfig]] = {} - for cfg in configs: - for interval in cfg.intervals: - if interval not in self._interval_configs: - self._interval_configs[interval] = [] - self._interval_configs[interval].append(cfg) - - logger.info( - f"IndicatorEngine initialized with {len(configs)} indicators " - f"across intervals: {list(self._interval_configs.keys())}" - ) - - def get_configured_intervals(self) -> List[str]: - """Return all intervals that have indicators configured""" - return list(self._interval_configs.keys()) - - async def on_interval_update( - self, - symbol: str, - interval: str, - timestamp: datetime, - ) -> Dict[str, Optional[float]]: - """ - Compute all indicators configured for this interval. - Called by main.py after CustomTimeframeGenerator updates a higher TF. - - Returns dict of indicator_name -> value (for use by Brain). - """ - configs = self._interval_configs.get(interval, []) - if not configs: - return {} - - return await self._compute_and_store(symbol, interval, timestamp, configs) - - async def compute_at( - self, - symbol: str, - interval: str, - timestamp: datetime, - ) -> Dict[str, Optional[float]]: - """ - Compute indicators at a specific point in time. - Alias for on_interval_update -- used by backtester for clarity. - """ - return await self.on_interval_update(symbol, interval, timestamp) - - async def compute_historical( - self, - symbol: str, - interval: str, - start: datetime, - end: datetime, - ) -> int: - """ - Batch-compute indicators for a time range. - Iterates over every candle timestamp in [start, end] and computes. - - Returns total number of indicator values stored. - """ - configs = self._interval_configs.get(interval, []) - if not configs: - logger.warning(f"No indicators configured for interval {interval}") - return 0 - - # Get all candle timestamps in range - async with self.db.acquire() as conn: - rows = await conn.fetch(""" - SELECT time FROM candles - WHERE symbol = $1 AND interval = $2 - AND time >= $3 AND time <= $4 - ORDER BY time ASC - """, symbol, interval, start, end) - - if not rows: - logger.warning(f"No candles found for {symbol}/{interval} in range") - return 0 - - timestamps = [row["time"] for row in rows] - total_stored = 0 - - logger.info( - f"Computing {len(configs)} indicators across " - f"{len(timestamps)} {interval} candles..." - ) - - for i, ts in enumerate(timestamps): - results = await self._compute_and_store(symbol, interval, ts, configs) - total_stored += sum(1 for v in results.values() if v is not None) - - if (i + 1) % 100 == 0: - logger.info(f"Progress: {i + 1}/{len(timestamps)} candles processed") - await asyncio.sleep(0.01) # Yield to event loop - - logger.info( - f"Historical compute complete: {total_stored} indicator values " - f"stored for {interval}" - ) - return total_stored - - async def _compute_and_store( - self, - symbol: str, - interval: str, - timestamp: datetime, - configs: List[IndicatorConfig], - ) -> Dict[str, Optional[float]]: - """Core computation: fetch candles, compute indicators, store results""" - # Determine max lookback needed - max_period = max(cfg.period for cfg in configs) - - # Fetch enough candles for the longest indicator - async with self.db.acquire() as conn: - rows = await conn.fetch(""" - SELECT time, open, high, low, close, volume - FROM candles - WHERE symbol = $1 AND interval = $2 - AND time <= $3 - ORDER BY time DESC - LIMIT $4 - """, symbol, interval, timestamp, max_period) - - if not rows: - return {cfg.name: None for cfg in configs} - - # Reverse to chronological order - candles = list(reversed(rows)) - closes = [float(c["close"]) for c in candles] - - # Compute each indicator - results: Dict[str, Optional[float]] = {} - values_to_store: List[tuple] = [] - - for cfg in configs: - value = self._compute_indicator(cfg, closes) - results[cfg.name] = value - - if value is not None: - values_to_store.append(( - timestamp, - symbol, - interval, - cfg.name, - value, - json.dumps({"type": cfg.type, "period": cfg.period}), - )) - - # Batch upsert all computed values - if values_to_store: - async with self.db.acquire() as conn: - await conn.executemany(""" - INSERT INTO indicators (time, symbol, interval, indicator_name, value, parameters) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (time, symbol, interval, indicator_name) - DO UPDATE SET - value = EXCLUDED.value, - parameters = EXCLUDED.parameters, - computed_at = NOW() - """, values_to_store) - - logger.debug( - f"Stored {len(values_to_store)} indicator values for " - f"{symbol}/{interval} at {timestamp}" - ) - - return results - - def _compute_indicator( - self, - config: IndicatorConfig, - closes: List[float], - ) -> Optional[float]: - """Dispatch to the correct computation function""" - if config.type == "sma": - return self.compute_sma(closes, config.period) - else: - logger.warning(f"Unknown indicator type: {config.type}") - return None - - # ── Pure math functions (no DB, no async, easily testable) ────────── - - @staticmethod - def compute_sma(closes: List[float], period: int) -> Optional[float]: - """Simple Moving Average over the last `period` closes""" - if len(closes) < period: - return None - return sum(closes[-period:]) / period - - async def get_latest_values( - self, - symbol: str, - interval: str, - ) -> Dict[str, float]: - """ - Get the most recent indicator values for a symbol/interval. - Used by Brain to read current state. - """ - async with self.db.acquire() as conn: - rows = await conn.fetch(""" - SELECT DISTINCT ON (indicator_name) - indicator_name, value, time - FROM indicators - WHERE symbol = $1 AND interval = $2 - ORDER BY indicator_name, time DESC - """, symbol, interval) - - return {row["indicator_name"]: float(row["value"]) for row in rows} - - async def get_values_at( - self, - symbol: str, - interval: str, - timestamp: datetime, - ) -> Dict[str, float]: - """ - Get indicator values at a specific timestamp. - Used by Brain during backtesting. - """ - async with self.db.acquire() as conn: - rows = await conn.fetch(""" - SELECT indicator_name, value - FROM indicators - WHERE symbol = $1 AND interval = $2 AND time = $3 - """, symbol, interval, timestamp) - - return {row["indicator_name"]: float(row["value"]) for row in rows} diff --git a/src/data_collector/main.py b/src/data_collector/main.py deleted file mode 100644 index e11a864..0000000 --- a/src/data_collector/main.py +++ /dev/null @@ -1,440 +0,0 @@ -""" -Main entry point for data collector service -Integrates WebSocket client, buffer, database, indicators, and brain -""" - -import asyncio -import logging -import signal -import sys -from datetime import datetime, timezone -from typing import Optional, List -import os - -import yaml - -from .websocket_client import HyperliquidWebSocket, Candle -from .candle_buffer import CandleBuffer -from .database import DatabaseManager -from .custom_timeframe_generator import CustomTimeframeGenerator -from .indicator_engine import IndicatorEngine, IndicatorConfig -from .brain import Brain -from .backfill import HyperliquidBackfill - - -# Configure logging -logging.basicConfig( - level=getattr(logging, os.getenv('LOG_LEVEL', 'INFO')), - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler(sys.stdout), - logging.FileHandler('/app/logs/collector.log') if os.path.exists('/app/logs') else logging.StreamHandler() - ] -) - -logger = logging.getLogger(__name__) - - -class DataCollector: - """ - Main data collection orchestrator - Manages WebSocket connection, buffering, and database writes - """ - - STANDARD_INTERVALS = ["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "8h", "12h", "1d", "3d", "1w"] - - def __init__( - self, - symbol: str = "BTC", - interval: str = "1m" - ): - self.symbol = symbol - self.interval = interval - - # Components - self.db: Optional[DatabaseManager] = None - self.buffer: Optional[CandleBuffer] = None - self.websocket: Optional[HyperliquidWebSocket] = None - self.custom_tf_generator: Optional[CustomTimeframeGenerator] = None - - # State - self.is_running = False - self._stop_event = asyncio.Event() - self._tasks = [] - - async def start(self) -> None: - """Initialize and start all components""" - logger.info(f"Starting DataCollector for {self.symbol}") - - try: - # Initialize database - self.db = DatabaseManager() - await self.db.connect() - - # Run startup backfill for all intervals - await self._startup_backfill() - - # Initialize custom timeframe generator - self.custom_tf_generator = CustomTimeframeGenerator(self.db) - await self.custom_tf_generator.initialize() - - # Regenerate custom timeframes after startup backfill - await self._regenerate_custom_timeframes() - - # Initialize indicator engine - # Hardcoded config for now, eventually load from yaml - indicator_configs = [ - IndicatorConfig("ma44", "sma", 44, ["37m", "148m", "1d"]), - IndicatorConfig("ma125", "sma", 125, ["37m", "148m", "1d"]) - ] - self.indicator_engine = IndicatorEngine(self.db, indicator_configs) - - # Initialize brain - self.brain = Brain(self.db, self.indicator_engine) - - # Initialize buffer - self.buffer = CandleBuffer( - max_size=1000, - flush_interval_seconds=30, - batch_size=100, - on_flush_callback=self._on_buffer_flush - ) - await self.buffer.start() - - # Initialize WebSocket client - self.websocket = HyperliquidWebSocket( - symbol=self.symbol, - interval=self.interval, - on_candle_callback=self._on_candle, - on_error_callback=self._on_error - ) - - # Setup signal handlers - self._setup_signal_handlers() - - # Connect to WebSocket - await self.websocket.connect() - - # Start main loops - self.is_running = True - self._tasks = [ - asyncio.create_task(self.websocket.receive_loop()), - asyncio.create_task(self._health_check_loop()), - asyncio.create_task(self._monitoring_loop()) - ] - - logger.info("DataCollector started successfully") - - # Wait for stop signal - await self._stop_event.wait() - - except Exception as e: - logger.error(f"Failed to start DataCollector: {type(e).__name__}: {e!r}") - raise - finally: - await self.stop() - - async def _startup_backfill(self) -> None: - """ - Backfill missing data on startup for all standard intervals. - Uses both gap detection AND time-based backfill for robustness. - """ - logger.info("Running startup backfill for all intervals...") - - try: - async with HyperliquidBackfill(self.db, self.symbol, self.STANDARD_INTERVALS) as backfill: - for interval in self.STANDARD_INTERVALS: - try: - # First, use gap detection to find any holes - gaps = await self.db.detect_gaps(self.symbol, interval) - - if gaps: - logger.info(f"{interval}: {len(gaps)} gaps detected") - for gap in gaps: - gap_start = datetime.fromisoformat(gap['gap_start'].replace('Z', '+00:00')) - gap_end = datetime.fromisoformat(gap['gap_end'].replace('Z', '+00:00')) - - logger.info(f" Filling gap: {gap_start} to {gap_end}") - candles = await backfill.fetch_candles(interval, gap_start, gap_end) - - if candles: - inserted = await self.db.insert_candles(candles) - logger.info(f" Inserted {inserted} candles for gap") - - await asyncio.sleep(0.2) - - # Second, check if we're behind current time - latest = await self.db.get_latest_candle(self.symbol, interval) - now = datetime.now(timezone.utc) - - if latest: - last_time = latest['time'] - gap_minutes = (now - last_time).total_seconds() / 60 - - if gap_minutes > 2: - logger.info(f"{interval}: {gap_minutes:.0f} min behind, backfilling to now...") - candles = await backfill.fetch_candles(interval, last_time, now) - - if candles: - inserted = await self.db.insert_candles(candles) - logger.info(f" Inserted {inserted} candles") - else: - logger.info(f"{interval}: up to date") - else: - # No data exists, backfill last 7 days - logger.info(f"{interval}: No data, backfilling 7 days...") - count = await backfill.backfill_interval(interval, days_back=7) - logger.info(f" Inserted {count} candles") - - await asyncio.sleep(0.2) - - except Exception as e: - logger.error(f"Startup backfill failed for {interval}: {e}") - import traceback - logger.error(traceback.format_exc()) - continue - - except Exception as e: - logger.error(f"Startup backfill error: {e}") - import traceback - logger.error(traceback.format_exc()) - - logger.info("Startup backfill complete") - - async def _regenerate_custom_timeframes(self) -> None: - """ - Regenerate custom timeframes (37m, 148m) only from gaps. - Only generates candles that are missing, not all from beginning. - """ - if not self.custom_tf_generator: - return - - logger.info("Checking custom timeframes for gaps...") - - try: - for interval in ['37m', '148m']: - try: - count = await self.custom_tf_generator.generate_from_gap(interval) - if count > 0: - logger.info(f"{interval}: Generated {count} candles") - else: - logger.info(f"{interval}: Up to date") - except Exception as e: - logger.error(f"Failed to regenerate {interval}: {e}") - - except Exception as e: - logger.error(f"Custom timeframe regeneration error: {e}") - - logger.info("Custom timeframe check complete") - - async def stop(self) -> None: - """Graceful shutdown""" - if not self.is_running: - return - - logger.info("Stopping DataCollector...") - self.is_running = False - self._stop_event.set() - - # Cancel tasks - for task in self._tasks: - if not task.done(): - task.cancel() - - # Wait for tasks to complete - if self._tasks: - await asyncio.gather(*self._tasks, return_exceptions=True) - - # Stop components - if self.websocket: - await self.websocket.disconnect() - - if self.buffer: - await self.buffer.stop() - - if self.db: - await self.db.disconnect() - - logger.info("DataCollector stopped") - - async def _on_candle(self, candle: Candle) -> None: - """Handle incoming candle from WebSocket""" - try: - # Add to buffer - await self.buffer.add(candle) - logger.debug(f"Received candle: {candle.time} - Close: {candle.close}") - except Exception as e: - logger.error(f"Error processing candle: {e}") - - async def _on_buffer_flush(self, candles: list) -> None: - """Handle buffer flush - write to database and update custom timeframes""" - try: - inserted = await self.db.insert_candles(candles) - logger.info(f"Flushed {inserted} candles to database") - - # Update custom timeframes (37m, 148m) in background - if self.custom_tf_generator and inserted > 0: - asyncio.create_task( - self._update_custom_timeframes(candles), - name="custom_tf_update" - ) - except Exception as e: - logger.error(f"Failed to write candles to database: {e}") - raise # Re-raise to trigger buffer retry - - async def _update_custom_timeframes(self, candles: list) -> None: - """ - Update custom timeframes in background, then trigger indicators/brain. - - This chain ensures that indicators are computed on fresh candle data, - and the brain evaluates on fresh indicator data. - """ - try: - # 1. Update custom candles (37m, 148m, etc.) - await self.custom_tf_generator.update_realtime(candles) - logger.debug("Custom timeframes updated") - - # 2. Trigger indicator updates for configured intervals - # We use the timestamp of the last 1m candle as the trigger point - trigger_time = candles[-1].time - - if self.indicator_engine: - intervals = self.indicator_engine.get_configured_intervals() - for interval in intervals: - # Get the correct bucket start time for this interval - # e.g., if trigger_time is 09:48:00, 37m bucket might start at 09:25:00 - if self.custom_tf_generator: - bucket_start = self.custom_tf_generator.get_bucket_start(trigger_time, interval) - else: - bucket_start = trigger_time - - # Compute indicators for this bucket - raw_indicators = await self.indicator_engine.on_interval_update( - self.symbol, interval, bucket_start - ) - - # Filter out None values to satisfy type checker - indicators = {k: v for k, v in raw_indicators.items() if v is not None} - - # 3. Evaluate brain if we have fresh indicators - if self.brain and indicators: - await self.brain.evaluate( - self.symbol, interval, bucket_start, indicators - ) - - except Exception as e: - logger.error(f"Failed to update custom timeframes/indicators: {e}") - # Don't raise - this is non-critical - - async def _on_error(self, error: Exception) -> None: - """Handle WebSocket errors""" - logger.error(f"WebSocket error: {error}") - # Could implement alerting here (Telegram, etc.) - - async def _health_check_loop(self) -> None: - """Periodic health checks""" - while self.is_running: - try: - await asyncio.sleep(60) # Check every minute - - if not self.is_running: - break - - # Check WebSocket health - health = self.websocket.get_connection_health() - - if health['seconds_since_last_message'] and health['seconds_since_last_message'] > 120: - logger.warning("No messages received for 2+ minutes") - # Could trigger reconnection or alert - - # Log stats - buffer_stats = self.buffer.get_stats() - logger.info(f"Health: {health}, Buffer: {buffer_stats.to_dict()}") - - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"Error in health check: {e}") - - async def _monitoring_loop(self) -> None: - """Periodic monitoring and maintenance tasks""" - while self.is_running: - try: - await asyncio.sleep(300) # Every 5 minutes - - if not self.is_running: - break - - # Detect gaps - gaps = await self.db.detect_gaps(self.symbol, self.interval) - if gaps: - logger.warning(f"Detected {len(gaps)} data gaps: {gaps}") - await self._backfill_gaps(gaps) - - # Log database health - health = await self.db.get_health_stats() - logger.info(f"Database health: {health}") - - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"Error in monitoring loop: {e}") - - async def _backfill_gaps(self, gaps: list) -> None: - """Backfill detected data gaps from Hyperliquid""" - if not gaps: - return - - logger.info(f"Starting backfill for {len(gaps)} gaps...") - - try: - async with HyperliquidBackfill(self.db, self.symbol, [self.interval]) as backfill: - for gap in gaps: - gap_start = datetime.fromisoformat(gap['gap_start'].replace('Z', '+00:00')) - gap_end = datetime.fromisoformat(gap['gap_end'].replace('Z', '+00:00')) - - logger.info(f"Backfilling gap: {gap_start} to {gap_end} ({gap['missing_candles']} candles)") - - candles = await backfill.fetch_candles(self.interval, gap_start, gap_end) - - if candles: - inserted = await self.db.insert_candles(candles) - logger.info(f"Backfilled {inserted} candles for gap {gap_start}") - - # Update custom timeframes and indicators for backfilled data - if inserted > 0: - await self._update_custom_timeframes(candles) - else: - logger.warning(f"No candles available for gap {gap_start} to {gap_end}") - - except Exception as e: - logger.error(f"Backfill failed: {e}") - - def _setup_signal_handlers(self) -> None: - """Setup handlers for graceful shutdown""" - def signal_handler(sig, frame): - logger.info(f"Received signal {sig}, shutting down...") - asyncio.create_task(self.stop()) - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - -async def main(): - """Main entry point""" - collector = DataCollector( - symbol="BTC", - interval="1m" - ) - - try: - await collector.start() - except KeyboardInterrupt: - logger.info("Interrupted by user") - except Exception as e: - logger.error(f"Fatal error: {type(e).__name__}: {e!r}") - sys.exit(1) - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/src/data_collector/websocket_client.py b/src/data_collector/websocket_client.py deleted file mode 100644 index 253e6cf..0000000 --- a/src/data_collector/websocket_client.py +++ /dev/null @@ -1,300 +0,0 @@ -""" -Hyperliquid WebSocket Client for cbBTC Data Collection -Optimized for Synology DS218+ with automatic reconnection -""" - -import asyncio -import json -import logging -from datetime import datetime, timezone -from typing import Optional, Dict, Any, Callable, Awaitable, List -from dataclasses import dataclass -import websockets -from websockets.exceptions import ConnectionClosed, InvalidStatusCode -from websockets.typing import Data - - -logger = logging.getLogger(__name__) - - -@dataclass -class Candle: - """Represents a single candlestick""" - time: datetime - symbol: str - interval: str - open: float - high: float - low: float - close: float - volume: float - - def to_dict(self) -> Dict[str, Any]: - return { - 'time': self.time, - 'symbol': self.symbol, - 'interval': self.interval, - 'open': self.open, - 'high': self.high, - 'low': self.low, - 'close': self.close, - 'volume': self.volume - } - - -class HyperliquidWebSocket: - """ - WebSocket client for Hyperliquid exchange - Handles connection, reconnection, and candle data parsing - """ - - def __init__( - self, - symbol: str = "BTC", - interval: str = "1m", - url: str = "wss://api.hyperliquid.xyz/ws", - reconnect_delays: Optional[List[int]] = None, - on_candle_callback: Optional[Callable[[Candle], Awaitable[None]]] = None, - on_error_callback: Optional[Callable[[Exception], Awaitable[None]]] = None - ): - self.symbol = symbol - self.interval = interval - self.url = url - self.reconnect_delays = reconnect_delays or [1, 2, 5, 10, 30, 60, 120, 300, 600, 900] - self.on_candle = on_candle_callback - self.on_error = on_error_callback - - self.websocket: Optional[websockets.WebSocketClientProtocol] = None - self.is_running = False - self.reconnect_count = 0 - self.last_message_time: Optional[datetime] = None - self.last_candle_time: Optional[datetime] = None - self._should_stop = False - - async def connect(self) -> None: - """Establish WebSocket connection with subscription""" - try: - logger.info(f"Connecting to Hyperliquid WebSocket: {self.url}") - - self.websocket = await websockets.connect( - self.url, - ping_interval=None, - ping_timeout=None, - close_timeout=10 - ) - - # Subscribe to candle data - subscribe_msg = { - "method": "subscribe", - "subscription": { - "type": "candle", - "coin": self.symbol, - "interval": self.interval - } - } - - await self.websocket.send(json.dumps(subscribe_msg)) - response = await self.websocket.recv() - logger.info(f"Subscription response: {response}") - - self.reconnect_count = 0 - self.is_running = True - logger.info(f"Successfully connected and subscribed to {self.symbol} {self.interval} candles") - - except Exception as e: - logger.error(f"Failed to connect: {e}") - raise - - async def disconnect(self) -> None: - """Gracefully close connection""" - self._should_stop = True - self.is_running = False - if self.websocket: - try: - await self.websocket.close() - logger.info("WebSocket connection closed") - except Exception as e: - logger.warning(f"Error closing WebSocket: {e}") - - async def receive_loop(self) -> None: - """Main message receiving loop""" - while self.is_running and not self._should_stop: - try: - if not self.websocket: - raise ConnectionClosed(None, None) - - message = await self.websocket.recv() - self.last_message_time = datetime.now(timezone.utc) - - await self._handle_message(message) - - except ConnectionClosed as e: - if self._should_stop: - break - logger.warning(f"WebSocket connection closed: {e}") - await self._handle_reconnect() - - except Exception as e: - logger.error(f"Error in receive loop: {e}") - if self.on_error: - await self.on_error(e) - await asyncio.sleep(1) - - async def _handle_message(self, message: Data) -> None: - """Parse and process incoming WebSocket message""" - try: - # Convert bytes to string if necessary - if isinstance(message, bytes): - message = message.decode('utf-8') - - data = json.loads(message) - - # Handle subscription confirmation - if data.get("channel") == "subscriptionResponse": - logger.info(f"Subscription confirmed: {data}") - return - - # Handle candle data - if data.get("channel") == "candle": - candle_data = data.get("data", {}) - if candle_data: - candle = self._parse_candle(candle_data) - if candle: - self.last_candle_time = candle.time - if self.on_candle: - await self.on_candle(candle) - - # Handle ping/pong - if "ping" in data and self.websocket: - await self.websocket.send(json.dumps({"pong": data["ping"]})) - - except json.JSONDecodeError as e: - logger.error(f"Failed to parse message: {e}") - except Exception as e: - logger.error(f"Error handling message: {e}") - - def _parse_candle(self, data: Any) -> Optional[Candle]: - """Parse candle data from WebSocket message""" - try: - # Hyperliquid candle format: [open, high, low, close, volume, timestamp] - if isinstance(data, list) and len(data) >= 6: - open_price = float(data[0]) - high = float(data[1]) - low = float(data[2]) - close = float(data[3]) - volume = float(data[4]) - timestamp_ms = int(data[5]) - elif isinstance(data, dict): - # New format: {'t': 1770812400000, 'T': ..., 's': 'BTC', 'i': '1m', 'o': '67164.0', 'c': ..., 'h': ..., 'l': ..., 'v': ..., 'n': ...} - if 't' in data and 'o' in data: - open_price = float(data.get("o", 0)) - high = float(data.get("h", 0)) - low = float(data.get("l", 0)) - close = float(data.get("c", 0)) - volume = float(data.get("v", 0)) - timestamp_ms = int(data.get("t", 0)) - else: - # Old format fallback - open_price = float(data.get("open", 0)) - high = float(data.get("high", 0)) - low = float(data.get("low", 0)) - close = float(data.get("close", 0)) - volume = float(data.get("volume", 0)) - timestamp_ms = int(data.get("time", 0)) - else: - logger.warning(f"Unknown candle format: {data}") - return None - - timestamp = datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc) - - return Candle( - time=timestamp, - symbol=self.symbol, - interval=self.interval, - open=open_price, - high=high, - low=low, - close=close, - volume=volume - ) - - except (KeyError, ValueError, TypeError) as e: - logger.error(f"Failed to parse candle data: {e}, data: {data}") - return None - - async def _handle_reconnect(self) -> None: - """Handle reconnection with exponential backoff""" - if self._should_stop: - return - - if self.reconnect_count >= len(self.reconnect_delays): - logger.error("Max reconnection attempts reached") - self.is_running = False - if self.on_error: - await self.on_error(Exception("Max reconnection attempts reached")) - return - - delay = self.reconnect_delays[self.reconnect_count] - self.reconnect_count += 1 - - logger.info(f"Reconnecting in {delay} seconds (attempt {self.reconnect_count})...") - await asyncio.sleep(delay) - - try: - await self.connect() - except Exception as e: - logger.error(f"Reconnection failed: {e}") - - def get_connection_health(self) -> Dict[str, Any]: - """Return connection health metrics""" - now = datetime.now(timezone.utc) - return { - "is_connected": self.websocket is not None and self.is_running, - "is_running": self.is_running, - "reconnect_count": self.reconnect_count, - "last_message_time": self.last_message_time.isoformat() if self.last_message_time else None, - "last_candle_time": self.last_candle_time.isoformat() if self.last_candle_time else None, - "seconds_since_last_message": (now - self.last_message_time).total_seconds() if self.last_message_time else None - } - - -async def test_websocket(): - """Test function for WebSocket client""" - candles_received = [] - stop_event = asyncio.Event() - - async def on_candle(candle: Candle): - candles_received.append(candle) - print(f"Candle: {candle.time} - O:{candle.open} H:{candle.high} L:{candle.low} C:{candle.close} V:{candle.volume}") - if len(candles_received) >= 5: - print("Received 5 candles, stopping...") - stop_event.set() - - client = HyperliquidWebSocket( - symbol="cbBTC-PERP", - interval="1m", - on_candle_callback=on_candle - ) - - try: - await client.connect() - # Run receive loop in background - receive_task = asyncio.create_task(client.receive_loop()) - # Wait for stop event - await stop_event.wait() - await client.disconnect() - await receive_task - except KeyboardInterrupt: - print("\nStopping...") - finally: - await client.disconnect() - print(f"Total candles received: {len(candles_received)}") - - -if __name__ == "__main__": - logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' - ) - - asyncio.run(test_websocket()) \ No newline at end of file diff --git a/start_dev.cmd b/start_dev.cmd deleted file mode 100644 index 630fad5..0000000 --- a/start_dev.cmd +++ /dev/null @@ -1,52 +0,0 @@ -@echo off -echo =================================== -echo BTC Trading Dashboard - Development Server -echo =================================== -echo. - -REM Check if venv exists -if not exist "venv\Scripts\activate.bat" ( - echo [ERROR] Virtual environment not found! - echo Please run setup first to create the venv. - echo. - pause - exit /b 1 -) - -REM Activate venv -call venv\Scripts\activate.bat - -REM Check dependencies -echo [1/3] Checking dependencies... -pip show fastapi >nul 2>&1 -if %errorlevel% neq 0 ( - echo Installing dependencies... - pip install -r requirements.txt - if %errorlevel% neq 0 ( - echo [ERROR] Failed to install dependencies - pause - exit /b 1 - ) -) - -echo [2/3] Testing database connection... -python test_db.py -if %errorlevel% neq 0 ( - echo [WARNING] Database connection test failed - echo Press Ctrl+C to cancel or any key to continue... - pause >nul -) - -echo [3/3] Starting development server... -echo. -echo =================================== -echo Server will start at: -echo - API Docs: http://localhost:8000/docs -echo - Dashboard: http://localhost:8000/dashboard -echo - Health: http://localhost:8000/api/v1/health -echo =================================== -echo. -echo Press Ctrl+C to stop the server -echo. - -uvicorn src.api.server:app --reload --host 0.0.0.0 --port 8000 \ No newline at end of file diff --git a/start_dev.sh b/start_dev.sh deleted file mode 100644 index 33bd4f9..0000000 --- a/start_dev.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash - -echo "===================================" -echo " BTC Trading Dashboard - Development Server" -echo "===================================" -echo "" - -# Check if venv exists -if [ ! -d "venv" ]; then - echo "[ERROR] Virtual environment not found!" - echo "Please run setup first to create the venv." - exit 1 -fi - -# Activate venv -source venv/bin/activate - -# Check dependencies -echo "[1/3] Checking dependencies..." -if ! pip show fastapi > /dev/null 2>&1; then - echo "Installing dependencies..." - pip install -r requirements.txt - if [ $? -ne 0 ]; then - echo "[ERROR] Failed to install dependencies" - exit 1 - fi -fi - -echo "[2/3] Testing database connection..." -python test_db.py -if [ $? -ne 0 ]; then - echo "[WARNING] Database connection test failed" - read -p "Press Enter to continue or Ctrl+C to cancel..." -fi - -echo "[3/3] Starting development server..." -echo "" -echo "===================================" -echo " Server will start at:" -echo " - API Docs: http://localhost:8000/docs" -echo " - Dashboard: http://localhost:8000/dashboard" -echo " - Health: http://localhost:8000/api/v1/health" -echo "===================================" -echo "" -echo "Press Ctrl+C to stop the server" -echo "" - -uvicorn src.api.server:app --reload --host 0.0.0.0 --port 8000 \ No newline at end of file diff --git a/test_db.py b/test_db.py deleted file mode 100644 index 0d2774c..0000000 --- a/test_db.py +++ /dev/null @@ -1,63 +0,0 @@ -import asyncio -import os -from dotenv import load_dotenv -import asyncpg - -load_dotenv() - -async def test_db_connection(): - """Test database connection""" - try: - conn = await asyncpg.connect( - host=os.getenv('DB_HOST'), - port=int(os.getenv('DB_PORT', 5432)), - database=os.getenv('DB_NAME'), - user=os.getenv('DB_USER'), - password=os.getenv('DB_PASSWORD'), - ) - - version = await conn.fetchval('SELECT version()') - print(f"[OK] Database connected successfully!") - print(f" Host: {os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}") - print(f" Database: {os.getenv('DB_NAME')}") - print(f" User: {os.getenv('DB_USER')}") - print(f" PostgreSQL: {version[:50]}...") - - # Check if tables exist - tables = await conn.fetch(""" - SELECT table_name FROM information_schema.tables - WHERE table_schema = 'public' - ORDER BY table_name - """) - - table_names = [row['table_name'] for row in tables] - print(f"\n[OK] Found {len(table_names)} tables:") - for table in table_names: - print(f" - {table}") - - # Check candles count - if 'candles' in table_names: - count = await conn.fetchval('SELECT COUNT(*) FROM candles') - latest_time = await conn.fetchval(""" - SELECT MAX(time) FROM candles - WHERE time > NOW() - INTERVAL '7 days' - """) - print(f"\n[OK] Candles table has {count} total records") - if latest_time: - print(f" Latest candle (last 7 days): {latest_time}") - - await conn.close() - return True - - except Exception as e: - print(f"[FAIL] Database connection failed:") - print(f" Error: {e}") - print(f"\nCheck:") - print(f" 1. NAS is reachable at {os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}") - print(f" 2. PostgreSQL is running") - print(f" 3. Database '{os.getenv('DB_NAME')}' exists") - print(f" 4. User '{os.getenv('DB_USER')}' has access") - return False - -if __name__ == '__main__': - asyncio.run(test_db_connection()) \ No newline at end of file