5 Commits
v0.1.0 ... main

Author SHA1 Message Date
bnair123
46d7556efb Handle DuckDB lock errors gracefully in UI
All checks were successful
CI/CD Pipeline / test (push) Successful in 1m44s
CI/CD Pipeline / build-engine (push) Has been skipped
CI/CD Pipeline / build-ui (push) Has been skipped
- Catch IOException when engine holds exclusive lock
- Show informative message instead of crashing
2025-12-28 00:00:05 +04:00
bnair123
8a4750c45e Fix UI DuckDB concurrent access with read-only mode
All checks were successful
CI/CD Pipeline / test (push) Successful in 1m46s
CI/CD Pipeline / build-engine (push) Has been skipped
CI/CD Pipeline / build-ui (push) Has been skipped
- Add read_only parameter to DataStorage.connect()
- UI now connects in read-only mode to avoid lock conflicts with engine
2025-12-27 23:43:25 +04:00
bnair123
007633660c Fix engine daemon exit and UI DuckDB access
All checks were successful
CI/CD Pipeline / test (push) Successful in 1m43s
CI/CD Pipeline / build-engine (push) Has been skipped
CI/CD Pipeline / build-ui (push) Has been skipped
- Add if __name__ == '__main__' guard to main.py (engine exited immediately)
- Update Dockerfile TA-Lib build for ARM64 (config.guess/config.sub)
- Add redis dependency to pyproject.toml
- Add docker-compose.override.yml for local dev with source mounting
- Remove :ro from UI engine volume mount (DuckDB needs write for WAL)
2025-12-27 23:27:33 +04:00
bnair123
2f065df1e9 Update docker-compose to use pre-built registry images
All checks were successful
CI/CD Pipeline / test (push) Successful in 1m47s
CI/CD Pipeline / build-engine (push) Has been skipped
CI/CD Pipeline / build-ui (push) Has been skipped
- Use gitea.thefetagroup.com/bnair/cryptobot:latest for engine
- Use gitea.thefetagroup.com/bnair/cryptobot-ui:latest for dashboard
- Remove deprecated 'version' field
- Change default DATA_ROOT from /opt/trading/crypto to ./data/
- Add start_period to healthchecks
- Add service dependency conditions
2025-12-27 21:45:30 +04:00
bnair123
49ea34041e Fix CI/CD: remove GHA cache, split build jobs
All checks were successful
CI/CD Pipeline / test (push) Successful in 1m45s
CI/CD Pipeline / build-engine (push) Successful in 2m56s
CI/CD Pipeline / build-ui (push) Successful in 2m57s
- Remove cache-from/cache-to that caused timeout errors on Gitea
- Split engine and UI builds into parallel jobs
- Both images now build independently (one failure won't block the other)
2025-12-27 21:26:47 +04:00
8 changed files with 149 additions and 89 deletions

View File

@@ -10,7 +10,6 @@ on:
env: env:
REGISTRY: gitea.thefetagroup.com REGISTRY: gitea.thefetagroup.com
IMAGE_NAME: bnair/cryptobot IMAGE_NAME: bnair/cryptobot
# Gitea URL for git operations
GITEA_URL: https://git.thefetagroup.com GITEA_URL: https://git.thefetagroup.com
jobs: jobs:
@@ -54,7 +53,7 @@ jobs:
- name: Run tests - name: Run tests
run: pytest --cov=tradefinder --cov-report=term-missing run: pytest --cov=tradefinder --cov-report=term-missing
build-and-push: build-engine:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test needs: test
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
@@ -89,8 +88,31 @@ jobs:
tags: | tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max build-ui:
runs-on: ubuntu-latest
needs: test
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout code
run: |
git clone --depth 1 ${{ env.GITEA_URL }}/bnair/CryptoTrading.git .
git fetch origin ${{ github.ref_name }} --depth 1
git checkout ${{ github.ref_name }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract version from tag
id: version
run: echo "VERSION=${{ github.ref_name }}" >> $GITHUB_OUTPUT
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push UI image - name: Build and push UI image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
@@ -102,5 +124,3 @@ jobs:
tags: | tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ui:${{ steps.version.outputs.VERSION }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ui:${{ steps.version.outputs.VERSION }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ui:latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ui:latest
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -11,12 +11,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
wget \ wget \
curl \ curl \
dpkg-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install TA-Lib C library # Install TA-Lib C library (with ARM64 support)
RUN wget -q http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz \ RUN wget -q http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz \
&& tar -xzf ta-lib-0.4.0-src.tar.gz \ && tar -xzf ta-lib-0.4.0-src.tar.gz \
&& cd ta-lib \ && cd ta-lib \
&& wget -q -O config.guess 'https://git.savannah.gnu.org/gitweb/?p=config.git;a=blob_plain;f=config.guess;hb=HEAD' \
&& wget -q -O config.sub 'https://git.savannah.gnu.org/gitweb/?p=config.git;a=blob_plain;f=config.sub;hb=HEAD' \
&& ./configure --prefix=/usr \ && ./configure --prefix=/usr \
&& make \ && make \
&& make install \ && make install \

View File

@@ -0,0 +1,31 @@
# Local development override - mounts source code for hot reload
x-engine-image: &engine-image
image: tf-engine:local
x-ui-image: &ui-image
image: tf-ui:local
services:
engine:
<<: *engine-image
volumes:
- /Users/bnair/Documents/Crypto/engine:/data/engine
- /Users/bnair/Documents/Crypto/shared:/data/shared
- /Users/bnair/Documents/TradeFinder/src:/app/src:ro
user: root
ui:
<<: *ui-image
volumes:
- /Users/bnair/Documents/Crypto/engine:/data/engine # DuckDB needs write access for WAL
- /Users/bnair/Documents/Crypto/shared:/data/shared
- /Users/bnair/Documents/TradeFinder/src:/app/src:ro
user: root
optimizer:
<<: *engine-image
user: root
backtester:
<<: *engine-image
user: root

View File

@@ -1,7 +1,15 @@
version: "3.9"
# TradeFinder - Automated Crypto Trading System # TradeFinder - Automated Crypto Trading System
# Mount point: /opt/trading/crypto (configurable via DATA_ROOT) # Uses pre-built images from Gitea Container Registry
#
# Quick Start:
# docker compose up -d
#
# With optimizer/backtester:
# docker compose --profile optimizer up -d
# docker compose --profile backtest run --rm backtester
#
# Environment:
# Copy .env.example to .env and configure API keys
x-common: &common x-common: &common
restart: unless-stopped restart: unless-stopped
@@ -13,43 +21,38 @@ x-common: &common
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
x-engine-image: &engine-image
image: gitea.thefetagroup.com/bnair/cryptobot:latest
x-ui-image: &ui-image
image: gitea.thefetagroup.com/bnair/cryptobot-ui:latest
services: services:
# ==========================================================================
# CORE TRADING ENGINE
# ==========================================================================
engine: engine:
<<: *common <<: [*common, *engine-image]
build:
context: .
dockerfile: Dockerfile
target: engine
container_name: tf-engine container_name: tf-engine
env_file: env_file:
- .env - .env
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- DUCKDB_PATH=/data/engine/tradefinder.duckdb - DUCKDB_PATH=/data/engine/tradefinder.duckdb
- TRADING_MODE=${TRADING_MODE:-paper}
volumes: volumes:
- ${DATA_ROOT:-/opt/trading/crypto}/engine:/data/engine - ${DATA_ROOT:-./data}/engine:/data/engine
- ${DATA_ROOT:-/opt/trading/crypto}/shared:/data/shared - ${DATA_ROOT:-./data}/shared:/data/shared
depends_on: depends_on:
- redis redis:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import tradefinder; print('ok')"] test: ["CMD", "python", "-c", "import tradefinder; print('ok')"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 10s
command: ["python", "-m", "tradefinder.core.main"] command: ["python", "-m", "tradefinder.core.main"]
# ==========================================================================
# STREAMLIT UI
# ==========================================================================
ui: ui:
<<: *common <<: [*common, *ui-image]
build:
context: .
dockerfile: Dockerfile
target: ui
container_name: tf-ui container_name: tf-ui
env_file: env_file:
- .env - .env
@@ -59,8 +62,8 @@ services:
ports: ports:
- "${STREAMLIT_PORT:-8501}:8501" - "${STREAMLIT_PORT:-8501}:8501"
volumes: volumes:
- ${DATA_ROOT:-/opt/trading/crypto}/engine:/data/engine:ro - ${DATA_ROOT:-./data}/engine:/data/engine:ro
- ${DATA_ROOT:-/opt/trading/crypto}/shared:/data/shared - ${DATA_ROOT:-./data}/shared:/data/shared
depends_on: depends_on:
- engine - engine
healthcheck: healthcheck:
@@ -68,40 +71,15 @@ services:
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 15s
command: ["streamlit", "run", "src/tradefinder/ui/app.py", "--server.port=8501", "--server.address=0.0.0.0"] command: ["streamlit", "run", "src/tradefinder/ui/app.py", "--server.port=8501", "--server.address=0.0.0.0"]
# ==========================================================================
# OPTIMIZER (runs weekly via scheduler or manually)
# ==========================================================================
optimizer:
<<: *common
build:
context: .
dockerfile: Dockerfile
target: engine
container_name: tf-optimizer
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
- DUCKDB_PATH=/data/engine/tradefinder.duckdb
volumes:
- ${DATA_ROOT:-/opt/trading/crypto}/engine:/data/engine
- ${DATA_ROOT:-/opt/trading/crypto}/optimizer:/data/optimizer
- ${DATA_ROOT:-/opt/trading/crypto}/shared:/data/shared
profiles:
- optimizer # Only runs when explicitly started: docker compose --profile optimizer up optimizer
command: ["python", "-m", "tradefinder.core.optimize"]
# ==========================================================================
# REDIS (for real-time state, pub/sub, caching)
# ==========================================================================
redis: redis:
<<: *common <<: *common
image: redis:7-alpine image: redis:7-alpine
container_name: tf-redis container_name: tf-redis
volumes: volumes:
- ${DATA_ROOT:-/opt/trading/crypto}/redis:/data - ${DATA_ROOT:-./data}/redis:/data
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 10s interval: 10s
@@ -109,15 +87,27 @@ services:
retries: 3 retries: 3
command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"] command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
# ========================================================================== optimizer:
# BACKTESTER (runs on-demand) <<: [*common, *engine-image]
# ========================================================================== container_name: tf-optimizer
env_file:
- .env
environment:
- PYTHONUNBUFFERED=1
- DUCKDB_PATH=/data/engine/tradefinder.duckdb
volumes:
- ${DATA_ROOT:-./data}/engine:/data/engine
- ${DATA_ROOT:-./data}/optimizer:/data/optimizer
- ${DATA_ROOT:-./data}/shared:/data/shared
depends_on:
redis:
condition: service_healthy
profiles:
- optimizer
command: ["python", "-m", "tradefinder.core.optimize"]
backtester: backtester:
<<: *common <<: [*common, *engine-image]
build:
context: .
dockerfile: Dockerfile
target: engine
container_name: tf-backtester container_name: tf-backtester
env_file: env_file:
- .env - .env
@@ -125,11 +115,11 @@ services:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- DUCKDB_PATH=/data/engine/tradefinder.duckdb - DUCKDB_PATH=/data/engine/tradefinder.duckdb
volumes: volumes:
- ${DATA_ROOT:-/opt/trading/crypto}/engine:/data/engine - ${DATA_ROOT:-./data}/engine:/data/engine
- ${DATA_ROOT:-/opt/trading/crypto}/backtest:/data/backtest - ${DATA_ROOT:-./data}/backtest:/data/backtest
- ${DATA_ROOT:-/opt/trading/crypto}/shared:/data/shared - ${DATA_ROOT:-./data}/shared:/data/shared
profiles: profiles:
- backtest # Only runs when explicitly started - backtest
command: ["python", "-m", "tradefinder.core.backtest"] command: ["python", "-m", "tradefinder.core.backtest"]
networks: networks:
@@ -137,10 +127,9 @@ networks:
driver: bridge driver: bridge
name: tradefinder-network name: tradefinder-network
# Volume labels for clarity # Data Structure (default: ./data/)
# All data persisted under ${DATA_ROOT:-/opt/trading/crypto}/ # ├── engine/ # DuckDB database, order logs, positions
# ├── engine/ # DuckDB, order logs, positions # ├── optimizer/ # Optuna studies, best parameters
# ├── optimizer/ # Optuna studies, best params
# ├── backtest/ # Backtest results, reports # ├── backtest/ # Backtest results, reports
# ├── redis/ # Redis AOF persistence # ├── redis/ # Redis AOF persistence
# └── shared/ # Shared configs, FX rates cache # └── shared/ # Shared configs, FX rates cache

View File

@@ -58,6 +58,9 @@ dependencies = [
"structlog>=24.1.0", "structlog>=24.1.0",
"rich>=13.7.0", "rich>=13.7.0",
# Redis
"redis>=5.0.0",
# HTTP client # HTTP client
"httpx>=0.26.0", "httpx>=0.26.0",
] ]

View File

@@ -396,21 +396,18 @@ async def _determine_equity(settings: Settings, adapter: BinanceUSDMAdapter | No
def _register_signal_handlers(shutdown_event: asyncio.Event) -> None: def _register_signal_handlers(shutdown_event: asyncio.Event) -> None:
"""Register signal handlers that trigger a graceful shutdown.""" """Register signal handlers that trigger a graceful shutdown."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
def _handle(sig: signal.Signals) -> None: def _handle(sig: signal.Signals) -> None:
logger.info("Shutdown signal received", signal=sig.name) logger.info("Shutdown signal received", signal=sig.name)
shutdown_event.set() shutdown_event.set()
def _handle_signal(signum: int, frame: object) -> None:
shutdown_event.set()
for sig in (signal.SIGINT, signal.SIGTERM): for sig in (signal.SIGINT, signal.SIGTERM):
try: try:
loop.add_signal_handler(sig, partial(_handle, sig)) loop.add_signal_handler(sig, partial(_handle, sig))
except (NotImplementedError, RuntimeError): logger.debug("Registered async signal handler", signal=sig.name)
signal.signal(sig, _handle_signal) except (NotImplementedError, RuntimeError, OSError) as e:
logger.debug("Could not register async signal handler", signal=sig.name, error=str(e))
async def _sleep_or_cancel(delay: float, shutdown_event: asyncio.Event) -> None: async def _sleep_or_cancel(delay: float, shutdown_event: asyncio.Event) -> None:
@@ -431,3 +428,7 @@ async def _initialize_adapter(adapter: BinanceUSDMAdapter, symbols: list[str]) -
await adapter.configure_hedge_mode(True) await adapter.configure_hedge_mode(True)
for symbol in symbols: for symbol in symbols:
await adapter.configure_margin_type(symbol, MarginType.ISOLATED) await adapter.configure_margin_type(symbol, MarginType.ISOLATED)
if __name__ == "__main__":
main()

View File

@@ -27,22 +27,24 @@ class DataStorage:
storage.disconnect() storage.disconnect()
""" """
def __init__(self, db_path: Path) -> None: def __init__(self, db_path: Path, *, read_only: bool = False) -> None:
"""Initialize storage with database path. """Initialize storage with database path.
Args: Args:
db_path: Path to DuckDB database file db_path: Path to DuckDB database file
read_only: If True, open database in read-only mode (no locking)
""" """
self.db_path = db_path self.db_path = db_path
self._read_only = read_only
self._conn: duckdb.DuckDBPyConnection | None = None self._conn: duckdb.DuckDBPyConnection | None = None
def connect(self) -> None: def connect(self) -> None:
"""Connect to the database.""" """Connect to the database."""
# Ensure parent directory exists if not self._read_only:
self.db_path.parent.mkdir(parents=True, exist_ok=True) self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn = duckdb.connect(str(self.db_path)) self._conn = duckdb.connect(str(self.db_path), read_only=self._read_only)
logger.info("Connected to DuckDB", path=str(self.db_path)) logger.info("Connected to DuckDB", path=str(self.db_path), read_only=self._read_only)
def disconnect(self) -> None: def disconnect(self) -> None:
"""Close database connection.""" """Close database connection."""

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
import duckdb
import streamlit as st import streamlit as st
import structlog import structlog
@@ -33,9 +34,12 @@ def get_storage() -> DataStorage | None:
if not db_path.exists(): if not db_path.exists():
return None return None
storage = DataStorage(db_path) storage = DataStorage(db_path, read_only=True)
storage.connect() try:
return storage storage.connect()
return storage
except duckdb.IOException:
return None
@st.cache_data(ttl=30) @st.cache_data(ttl=30)
@@ -103,9 +107,16 @@ def render_database_status() -> None:
"""Render database connection status.""" """Render database connection status."""
st.subheader("Database Status") st.subheader("Database Status")
settings = get_settings()
db_path = settings.duckdb_path
if not db_path.exists():
st.warning("Database not initialized yet. Start the trading engine first.")
return
storage = get_storage() storage = get_storage()
if storage is None: if storage is None:
st.warning("Database not initialized yet. Start the trading engine first.") st.info(f"Database exists at {db_path} (engine has exclusive lock)")
return return
try: try: