├── .nvmrc ├── VERSION ├── .DS_Store ├── api ├── routes │ ├── __init__.py │ ├── bot_routes.py │ ├── config_routes.py │ └── status_routes.py ├── utils │ ├── __init__.py │ └── auth.py ├── __init__.py ├── websocket_manager.py └── app.py ├── frontend ├── src │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── App.test.tsx │ ├── index.css │ ├── reportWebVitals.ts │ ├── App.tsx │ ├── index.tsx │ ├── App.css │ ├── api │ │ └── client.ts │ ├── types.ts │ ├── hooks │ │ ├── useBotStatus.ts │ │ ├── useBotConfig.ts │ │ └── useLogStream.ts │ ├── components │ │ ├── LogViewer.tsx │ │ ├── ConfigManager.tsx │ │ ├── PositionsTable.tsx │ │ └── StatusOverview.tsx │ ├── pages │ │ └── Dashboard.tsx │ └── logo.svg ├── public │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── robots.txt │ ├── manifest.json │ └── index.html ├── .gitignore ├── tsconfig.json ├── package.json └── README.md ├── assets ├── .DS_Store ├── terminal-screenshot.png └── Screenshot 2025-09-24 at 15.57.28.png ├── requirements.txt ├── config.json ├── update_version.sh ├── Dockerfile ├── example.py ├── test_market_data.py ├── .gitignore ├── entrypoint.py ├── README_zh-Tw.md ├── README.md └── Delta.py /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.0.0 -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cgaspart/HL-Delta/HEAD/.DS_Store -------------------------------------------------------------------------------- /api/routes/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | API route modules for the Delta bot. 3 | """ -------------------------------------------------------------------------------- /api/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utility modules for the Delta bot API. 3 | """ -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /assets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cgaspart/HL-Delta/HEAD/assets/.DS_Store -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cgaspart/HL-Delta/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cgaspart/HL-Delta/HEAD/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cgaspart/HL-Delta/HEAD/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /assets/terminal-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cgaspart/HL-Delta/HEAD/assets/terminal-screenshot.png -------------------------------------------------------------------------------- /assets/Screenshot 2025-09-24 at 15.57.28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cgaspart/HL-Delta/HEAD/assets/Screenshot 2025-09-24 at 15.57.28.png -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | API module for Delta bot. 3 | """ 4 | 5 | from .app import start_api, stop_api 6 | 7 | __all__ = ['start_api', 'stop_api'] -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | hyperliquid-python-sdk>=0.10.0 2 | pandas>=2.0.0 3 | numpy>=1.24.0 4 | python-dotenv>=1.0.0 5 | aiohttp>=3.8.0 6 | fastapi>=0.100.0 7 | pydantic>=2.0.0 8 | uvicorn[standard]>=0.22.0 -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "general": { 3 | "debug": false, 4 | "tracked_coins": ["BTC", "ETH", "HYPE", "USDC"], 5 | "autostart": false 6 | }, 7 | "allocation": { 8 | "spot_pct": 70, 9 | "perp_pct": 30, 10 | "rebalance_threshold": 0.05 11 | }, 12 | "trading": { 13 | "refresh_interval_sec": 60 14 | }, 15 | "api": { 16 | "host": "0.0.0.0", 17 | "port": 8080, 18 | "enabled": true 19 | } 20 | } -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /update_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Version updater script for Delta bot 3 | 4 | # Check if a version argument was provided 5 | if [ -z "$1" ]; then 6 | echo "Usage: $0 " 7 | echo "Example: $0 1.1.0" 8 | exit 1 9 | fi 10 | 11 | NEW_VERSION=$1 12 | CURRENT_VERSION=$(cat VERSION) 13 | 14 | echo "Updating version from $CURRENT_VERSION to $NEW_VERSION..." 15 | 16 | # Update VERSION file 17 | echo "$NEW_VERSION" > VERSION 18 | 19 | echo "Version updated successfully!" 20 | echo "To build the new version, run: ./build.sh" -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Dashboard from './pages/Dashboard'; 3 | import { ThemeProvider, createTheme } from '@mui/material/styles'; 4 | import CssBaseline from '@mui/material/CssBaseline'; 5 | 6 | const darkTheme = createTheme({ 7 | palette: { 8 | mode: 'dark', 9 | }, 10 | }); 11 | 12 | const App: React.FC = () => { 13 | return ( 14 | 15 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default App; 22 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById('root') as HTMLElement 9 | ); 10 | root.render( 11 | 12 | 13 | 14 | ); 15 | 16 | // If you want to start measuring performance in your app, pass a function 17 | // to log results (for example: reportWebVitals(console.log)) 18 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 19 | reportWebVitals(); 20 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/api/client.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const apiClient = axios.create({ 4 | baseURL: process.env.REACT_APP_API_URL || 'http://localhost:8080/api', 5 | headers: { 6 | 'Content-Type': 'application/json', 7 | }, 8 | }); 9 | 10 | // Add a request interceptor to include the API key from environment variables 11 | apiClient.interceptors.request.use( 12 | (config) => { 13 | const apiKey = process.env.REACT_APP_API_KEY; 14 | if (apiKey) { 15 | config.headers['X-API-KEY'] = apiKey; 16 | } else { 17 | // If the API key is missing, we can block the request or let it fail on the server 18 | console.warn('API key is not set in the environment variables.'); 19 | } 20 | return config; 21 | }, 22 | (error) => { 23 | return Promise.reject(error); 24 | } 25 | ); 26 | 27 | export default apiClient; -------------------------------------------------------------------------------- /frontend/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface AccountInfo { 2 | address: string; 3 | total_value: number; 4 | margin_used: number | null; 5 | total_raw_usd: number | null; 6 | } 7 | 8 | export interface Position { 9 | coin: string; 10 | type: 'spot' | 'perp'; 11 | size: number; 12 | value?: number; 13 | entry_price?: number; 14 | position_value?: number; 15 | unrealized_pnl?: number; 16 | leverage?: number; 17 | liquidation_price?: number; 18 | funding?: number; 19 | hold?: number; 20 | } 21 | 22 | export interface FundingRate { 23 | hourly: number; 24 | yearly: number | null; 25 | } 26 | 27 | export interface BotStatus { 28 | running: boolean; 29 | positions: Position[]; 30 | funding_rates: Record; 31 | account: AccountInfo; 32 | pending_orders: number; 33 | } 34 | 35 | export interface ApiResponse { 36 | success: boolean; 37 | message: string; 38 | data?: T; 39 | } 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | # Set build arguments 4 | ARG VERSION=1.0.0 5 | 6 | # Set working directory 7 | WORKDIR /app 8 | 9 | # Add metadata labels 10 | LABEL maintainer="HyperVault Team " 11 | LABEL version="${VERSION}" 12 | LABEL description="HyperVault Delta Bot for automated delta-neutral trading" 13 | LABEL org.opencontainers.image.title="HyperVault Delta" 14 | LABEL org.opencontainers.image.version="${VERSION}" 15 | LABEL org.opencontainers.image.vendor="HyperVault" 16 | 17 | # Install system dependencies 18 | RUN apt-get update && \ 19 | apt-get install -y --no-install-recommends gcc libc6-dev && \ 20 | apt-get clean && \ 21 | rm -rf /var/lib/apt/lists/* 22 | 23 | # Copy requirements first to leverage Docker cache 24 | COPY requirements.txt . 25 | RUN pip install --no-cache-dir -r requirements.txt 26 | 27 | # Copy application code 28 | COPY . . 29 | 30 | # Set environment variables 31 | ENV PYTHONUNBUFFERED=1 32 | ENV API_HOST=0.0.0.0 33 | ENV API_PORT=8080 34 | ENV AUTOSTART_BOT=true 35 | ENV BOT_VERSION=${VERSION} 36 | ENV API_ENABLED=true 37 | 38 | # Expose API port 39 | EXPOSE 8080 40 | 41 | # Define entrypoint that runs the delta bot 42 | ENTRYPOINT ["python", "entrypoint.py"] -------------------------------------------------------------------------------- /frontend/src/hooks/useBotStatus.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | import apiClient from '../api/client'; 3 | import { BotStatus, ApiResponse } from '../types'; 4 | 5 | export const useBotStatus = () => { 6 | const [status, setStatus] = useState(null); 7 | const [error, setError] = useState(null); 8 | const [loading, setLoading] = useState(true); 9 | 10 | const fetchStatus = useCallback(async () => { 11 | try { 12 | setLoading(true); 13 | const response = await apiClient.get>('/status'); 14 | if (response.data.success && response.data.data) { 15 | setStatus(response.data.data); 16 | setError(null); 17 | } else { 18 | setError(response.data.message || 'Failed to fetch status'); 19 | } 20 | } catch (err: any) { 21 | setError(err.response?.data?.detail || err.message || 'An unknown error occurred'); 22 | } finally { 23 | setLoading(false); 24 | } 25 | }, []); 26 | 27 | useEffect(() => { 28 | fetchStatus(); 29 | const interval = setInterval(fetchStatus, 15000); // Refresh every 15 seconds 30 | 31 | return () => clearInterval(interval); 32 | }, [fetchStatus]); 33 | 34 | return { status, error, loading, refresh: fetchStatus }; 35 | }; 36 | -------------------------------------------------------------------------------- /api/utils/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Authentication utilities for the Delta bot API. 3 | """ 4 | 5 | import os 6 | import logging 7 | from fastapi import Request, HTTPException, Depends 8 | from fastapi.security import APIKeyHeader 9 | 10 | # Configure logging 11 | logger = logging.getLogger("DeltaAPI.Auth") 12 | 13 | # API key header name 14 | API_KEY_HEADER = APIKeyHeader(name="X-API-KEY") 15 | 16 | # Get API key from environment 17 | API_SECRET_KEY = os.environ.get("API_SECRET_KEY") 18 | 19 | async def verify_api_key(request: Request, api_key: str = Depends(API_KEY_HEADER)): 20 | """ 21 | Verify that the API key provided in the request header matches the expected value. 22 | 23 | Args: 24 | request: The incoming request 25 | api_key: The API key from the request header 26 | 27 | Raises: 28 | HTTPException: If the API key is missing or invalid 29 | """ 30 | if not API_SECRET_KEY: 31 | logger.error("API_SECRET_KEY not set in environment") 32 | raise HTTPException(status_code=500, detail="API authentication not configured") 33 | 34 | if api_key != API_SECRET_KEY: 35 | client_ip = request.client.host if request.client else "unknown" 36 | logger.warning(f"Invalid API key attempt from {client_ip}") 37 | raise HTTPException(status_code=401, detail="Invalid API key") 38 | 39 | return True -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.14.0", 7 | "@emotion/styled": "^11.14.1", 8 | "@mui/icons-material": "^7.3.2", 9 | "@mui/material": "^7.3.2", 10 | "@mui/system": "^7.3.2", 11 | "@testing-library/dom": "^10.4.1", 12 | "@testing-library/jest-dom": "^6.8.0", 13 | "@testing-library/react": "^16.3.0", 14 | "@testing-library/user-event": "^13.5.0", 15 | "@types/jest": "^27.5.2", 16 | "@types/node": "^16.18.126", 17 | "@types/react": "^19.1.13", 18 | "@types/react-dom": "^19.1.9", 19 | "axios": "^1.12.2", 20 | "react": "^19.1.1", 21 | "react-dom": "^19.1.1", 22 | "react-scripts": "5.0.1", 23 | "recharts": "^3.2.1", 24 | "typescript": "^4.9.5", 25 | "web-vitals": "^2.1.4" 26 | }, 27 | "scripts": { 28 | "start": "react-scripts start", 29 | "build": "react-scripts build", 30 | "test": "react-scripts test", 31 | "eject": "react-scripts eject" 32 | }, 33 | "eslintConfig": { 34 | "extends": [ 35 | "react-app", 36 | "react-app/jest" 37 | ] 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not op_mini all" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/components/LogViewer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import { Paper, Typography, Box, Chip } from '@mui/material'; 3 | import { useLogStream } from '../hooks/useLogStream'; 4 | 5 | const LogViewer: React.FC = () => { 6 | const { logs, isConnected } = useLogStream(); 7 | const scrollRef = useRef(null); 8 | 9 | useEffect(() => { 10 | // Auto-scroll to the bottom 11 | if (scrollRef.current) { 12 | scrollRef.current.scrollTop = scrollRef.current.scrollHeight; 13 | } 14 | }, [logs]); 15 | 16 | return ( 17 | 18 | 19 | 20 | Live Logs 21 | 22 | 27 | 28 | 42 | {logs.map((log, index) => ( 43 |
{log}
44 | ))} 45 |
46 |
47 | ); 48 | }; 49 | 50 | export default LogViewer; 51 | -------------------------------------------------------------------------------- /frontend/src/hooks/useBotConfig.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | import apiClient from '../api/client'; 3 | import { ApiResponse } from '../types'; 4 | 5 | interface BotConfig { 6 | tracked_coins: string[]; 7 | [key: string]: any; 8 | } 9 | 10 | export const useBotConfig = () => { 11 | const [config, setConfig] = useState(null); 12 | const [error, setError] = useState(null); 13 | const [loading, setLoading] = useState(true); 14 | 15 | const fetchConfig = useCallback(async () => { 16 | try { 17 | setLoading(true); 18 | const response = await apiClient.get>('/config'); 19 | if (response.data.success && response.data.data) { 20 | setConfig(response.data.data.config); 21 | setError(null); 22 | } else { 23 | setError(response.data.message || 'Failed to fetch config'); 24 | } 25 | } catch (err: any) { 26 | setError(err.response?.data?.detail || err.message || 'An unknown error occurred'); 27 | } finally { 28 | setLoading(false); 29 | } 30 | }, []); 31 | 32 | useEffect(() => { 33 | fetchConfig(); 34 | }, [fetchConfig]); 35 | 36 | const updateTrackedCoins = async (coins: string[]) => { 37 | try { 38 | await apiClient.post('/config/update', { tracked_coins: coins }); 39 | await fetchConfig(); // Refresh config after update 40 | } catch (err: any) { 41 | setError(err.response?.data?.detail || 'Failed to update config'); 42 | throw err; // Re-throw to be caught in the component 43 | } 44 | }; 45 | 46 | return { config, error, loading, refresh: fetchConfig, updateTrackedCoins }; 47 | }; 48 | -------------------------------------------------------------------------------- /api/websocket_manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import List 4 | from fastapi import WebSocket 5 | 6 | class ConnectionManager: 7 | """Manages active WebSocket connections for broadcasting messages.""" 8 | def __init__(self): 9 | self.active_connections: List[WebSocket] = [] 10 | 11 | async def connect(self, websocket: WebSocket): 12 | """Accept a new WebSocket connection.""" 13 | await websocket.accept() 14 | self.active_connections.append(websocket) 15 | 16 | def disconnect(self, websocket: WebSocket): 17 | """Disconnect a WebSocket.""" 18 | self.active_connections.remove(websocket) 19 | 20 | async def broadcast(self, message: str): 21 | """Broadcast a message to all active connections.""" 22 | for connection in self.active_connections: 23 | try: 24 | await connection.send_text(message) 25 | except Exception: 26 | # The connection might have closed, remove it. 27 | self.disconnect(connection) 28 | 29 | # Create a single instance of the manager to be used across the application 30 | manager = ConnectionManager() 31 | 32 | class WebSocketLogHandler(logging.Handler): 33 | """A custom logging handler that broadcasts log records to WebSockets.""" 34 | def __init__(self, manager_instance: ConnectionManager): 35 | super().__init__() 36 | self.manager = manager_instance 37 | 38 | def emit(self, record): 39 | """Emit a log record.""" 40 | try: 41 | msg = self.format(record) 42 | # Use asyncio.create_task to send the message without blocking the logger 43 | asyncio.create_task(self.manager.broadcast(msg)) 44 | except Exception: 45 | self.handleError(record) 46 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /frontend/src/hooks/useLogStream.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | 3 | export const useLogStream = () => { 4 | const [logs, setLogs] = useState([]); 5 | const [isConnected, setIsConnected] = useState(false); 6 | const websocket = useRef(null); 7 | 8 | useEffect(() => { 9 | // Use ws:// for http and wss:// for https 10 | const wsProtocol = window.location.protocol === 'https:-' ? 'wss' : 'ws'; 11 | const wsUrl = process.env.REACT_APP_WS_URL || `${wsProtocol}://${window.location.hostname}:8080/ws/logs`; 12 | 13 | const connect = () => { 14 | websocket.current = new WebSocket(wsUrl); 15 | 16 | websocket.current.onopen = () => { 17 | console.log('WebSocket connected'); 18 | setIsConnected(true); 19 | setLogs((prev) => [...prev, '-- WebSocket connection established --']); 20 | }; 21 | 22 | websocket.current.onmessage = (event) => { 23 | setLogs((prev) => [...prev, event.data]); 24 | }; 25 | 26 | websocket.current.onclose = () => { 27 | console.log('WebSocket disconnected'); 28 | setIsConnected(false); 29 | setLogs((prev) => [...prev, '-- WebSocket connection lost. Attempting to reconnect... --']); 30 | // Attempt to reconnect after a delay 31 | setTimeout(connect, 5000); 32 | }; 33 | 34 | websocket.current.onerror = (error) => { 35 | console.error('WebSocket error:', error); 36 | websocket.current?.close(); 37 | }; 38 | }; 39 | 40 | connect(); 41 | 42 | // Clean up the connection when the component unmounts 43 | return () => { 44 | if (websocket.current) { 45 | websocket.current.onclose = null; // prevent reconnect logic from firing on manual close 46 | websocket.current.close(); 47 | } 48 | }; 49 | }, []); 50 | 51 | return { logs, isConnected }; 52 | }; 53 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /frontend/src/pages/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Grid, 4 | AppBar, 5 | Toolbar, 6 | Typography, 7 | Container, 8 | CssBaseline, 9 | Box, 10 | Paper, 11 | CircularProgress, 12 | Alert, 13 | } from '@mui/material'; 14 | import StatusOverview from '../components/StatusOverview'; 15 | import PositionsTable from '../components/PositionsTable'; 16 | import ConfigManager from '../components/ConfigManager'; 17 | import LogViewer from '../components/LogViewer'; 18 | import { useBotStatus } from '../hooks/useBotStatus'; 19 | 20 | const Dashboard: React.FC = () => { 21 | const { status, error, loading } = useBotStatus(); 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | Delta Bot Dashboard 30 | 31 | 32 | 33 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | Current Positions 51 | 52 | {loading && !status ? ( 53 | 54 | ) : error ? ( 55 | {error} 56 | ) : status ? ( 57 | 58 | ) : ( 59 | No data available. 60 | )} 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | ); 74 | }; 75 | 76 | export default Dashboard; 77 | -------------------------------------------------------------------------------- /frontend/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/ConfigManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Paper, 4 | Typography, 5 | List, 6 | ListItem, 7 | ListItemText, 8 | IconButton, 9 | TextField, 10 | Button, 11 | Box, 12 | CircularProgress, 13 | Alert, 14 | } from '@mui/material'; 15 | import DeleteIcon from '@mui/icons-material/Delete'; 16 | import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; 17 | import { useBotConfig } from '../hooks/useBotConfig'; 18 | 19 | const ConfigManager: React.FC = () => { 20 | const { config, error, loading, updateTrackedCoins } = useBotConfig(); 21 | const [newCoin, setNewCoin] = useState(''); 22 | 23 | const handleAddCoin = async () => { 24 | if (newCoin && config && !config.tracked_coins.includes(newCoin.toUpperCase())) { 25 | const updatedCoins = [...config.tracked_coins, newCoin.toUpperCase()]; 26 | await updateTrackedCoins(updatedCoins); 27 | setNewCoin(''); 28 | } 29 | }; 30 | 31 | const handleRemoveCoin = async (coinToRemove: string) => { 32 | if (config) { 33 | const updatedCoins = config.tracked_coins.filter((c) => c !== coinToRemove); 34 | await updateTrackedCoins(updatedCoins); 35 | } 36 | }; 37 | 38 | if (loading) { 39 | return ; 40 | } 41 | 42 | if (error) { 43 | return {error}; 44 | } 45 | 46 | return ( 47 | 48 | 49 | Tracked Coins 50 | 51 | 52 | {config?.tracked_coins.map((coin) => ( 53 | handleRemoveCoin(coin)}> 57 | 58 | 59 | } 60 | > 61 | 62 | 63 | ))} 64 | 65 | 66 | setNewCoin(e.target.value)} 72 | sx={{ flexGrow: 1, mr: 1 }} 73 | /> 74 | 81 | 82 | 83 | ); 84 | }; 85 | 86 | export default ConfigManager; 87 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Example script showing how to use the Delta class with config.json 4 | """ 5 | 6 | import asyncio 7 | import os 8 | import sys 9 | import logging 10 | from Delta import Delta, logger 11 | 12 | # Ensure we have environment variables set 13 | if not os.getenv("HYPERLIQUID_PRIVATE_KEY") or not os.getenv("HYPERLIQUID_ADDRESS"): 14 | print("Error: Environment variables HYPERLIQUID_PRIVATE_KEY and HYPERLIQUID_ADDRESS must be set") 15 | print("Example:") 16 | print("export HYPERLIQUID_PRIVATE_KEY=your_private_key_here") 17 | print("export HYPERLIQUID_ADDRESS=your_eth_address_here") 18 | sys.exit(1) 19 | 20 | async def main(): 21 | try: 22 | # Create Delta instance with config.json 23 | delta = Delta(config_path="config.json") 24 | 25 | # Display account and position information 26 | delta.display_position_info() 27 | 28 | # Get funding rates for tracked coins 29 | from test_market_data import check_funding_rates, calculate_yearly_funding_rates 30 | 31 | print("\nFetching current funding rates...") 32 | funding_rates = await check_funding_rates() 33 | yearly_rates = calculate_yearly_funding_rates(funding_rates, delta.tracked_coins) 34 | 35 | print("\nCurrent Funding Rates:") 36 | for coin_name in delta.tracked_coins: 37 | if coin_name != "USDC" and coin_name in yearly_rates: 38 | yearly_rate = yearly_rates[coin_name] 39 | if yearly_rate is not None: 40 | print(f"{coin_name}: {yearly_rate:.4f}% APR") 41 | else: 42 | print(f"{coin_name}: N/A") 43 | 44 | # Check for the best funding rate 45 | best_coin = delta.get_best_yearly_funding_rate() 46 | if best_coin: 47 | print(f"\nBest funding rate coin: {best_coin} ({delta.coins[best_coin].perp.yearly_funding_rate:.4f}% APR)") 48 | 49 | # Example: Check allocation 50 | allocation_ok = delta.check_allocation() 51 | if not allocation_ok: 52 | print(f"\nAllocation mismatch from target {delta.spot_allocation_pct*100:.0f}% spot / {delta.perp_allocation_pct*100:.0f}% perp ratio") 53 | 54 | print("\nExample completed successfully") 55 | 56 | except Exception as e: 57 | logger.error(f"Error running example: {e}") 58 | raise 59 | 60 | if __name__ == "__main__": 61 | try: 62 | asyncio.run(main()) 63 | except KeyboardInterrupt: 64 | print("Interrupted by user") 65 | except Exception as e: 66 | print(f"Fatal error: {e}") -------------------------------------------------------------------------------- /api/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | API server for Delta bot. 3 | """ 4 | 5 | import os 6 | import asyncio 7 | import logging 8 | import uvicorn 9 | from fastapi import FastAPI, Depends, HTTPException, Request, WebSocket 10 | from fastapi.middleware.cors import CORSMiddleware 11 | 12 | from .utils.auth import verify_api_key 13 | from .routes import bot_routes, status_routes, config_routes 14 | from .websocket_manager import manager 15 | 16 | # Global instances 17 | app = FastAPI(title="Delta Bot API", description="API for controlling the Delta bot") 18 | server = None 19 | 20 | # Add a WebSocket endpoint for logs 21 | @app.websocket("/ws/logs") 22 | async def websocket_endpoint(websocket: WebSocket): 23 | await manager.connect(websocket) 24 | try: 25 | while True: 26 | # Keep the connection alive 27 | await websocket.receive_text() 28 | except Exception: 29 | manager.disconnect(websocket) 30 | 31 | 32 | bot_instance = None 33 | 34 | # Configure logging 35 | logger = logging.getLogger("DeltaAPI") 36 | 37 | # Set up CORS 38 | app.add_middleware( 39 | CORSMiddleware, 40 | allow_origins=["*"], 41 | allow_credentials=True, 42 | allow_methods=["*"], 43 | allow_headers=["*"], 44 | ) 45 | 46 | # Include routers 47 | app.include_router( 48 | bot_routes.router, 49 | prefix="/api/bot", 50 | tags=["bot"], 51 | dependencies=[Depends(verify_api_key)] 52 | ) 53 | 54 | app.include_router( 55 | status_routes.router, 56 | prefix="/api/status", 57 | tags=["status"], 58 | dependencies=[Depends(verify_api_key)] 59 | ) 60 | 61 | app.include_router( 62 | config_routes.router, 63 | prefix="/api/config", 64 | tags=["config"], 65 | dependencies=[Depends(verify_api_key)] 66 | ) 67 | 68 | @app.get("/api/health") 69 | async def health_check(): 70 | """Health check endpoint.""" 71 | return {"status": "ok"} 72 | 73 | async def start_api(bot, host="0.0.0.0", port=8080): 74 | """Start the API server.""" 75 | global bot_instance, server 76 | 77 | bot_instance = bot 78 | 79 | # Set the bot instance for the routers 80 | bot_routes.bot = bot 81 | status_routes.bot = bot 82 | config_routes.bot = bot 83 | 84 | config = uvicorn.Config(app, host=host, port=port, log_level="info") 85 | server = uvicorn.Server(config) 86 | 87 | # Start the server in a separate task 88 | asyncio.create_task(server.serve()) 89 | 90 | return server 91 | 92 | async def stop_api(): 93 | """Stop the API server.""" 94 | global server 95 | 96 | if server: 97 | logger.info("Stopping API server...") 98 | await server.shutdown() 99 | logger.info("API server stopped") 100 | 101 | # Export the app instance for use in other modules 102 | __all__ = ["app", "start_api", "stop_api"] -------------------------------------------------------------------------------- /frontend/src/components/PositionsTable.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Table, 4 | TableBody, 5 | TableCell, 6 | TableContainer, 7 | TableHead, 8 | TableRow, 9 | Paper, 10 | Typography, 11 | Chip, 12 | } from '@mui/material'; 13 | import { Position } from '../types'; 14 | 15 | interface PositionsTableProps { 16 | positions: Position[]; 17 | } 18 | 19 | const PositionsTable: React.FC = ({ positions }) => { 20 | if (positions.length === 0) { 21 | return No open positions.; 22 | } 23 | 24 | const formatPnl = (pnl: number) => ( 25 | = 0 ? 'success.main' : 'error.main'}> 26 | {pnl.toFixed(4)} 27 | 28 | ); 29 | 30 | return ( 31 | 32 | 33 | 34 | 35 | Coin 36 | Type 37 | Size 38 | Entry Price 39 | Position Value 40 | Unrealized PNL 41 | Leverage 42 | Liq. Price 43 | 44 | 45 | 46 | {positions.map((pos) => ( 47 | 48 | 49 | {pos.coin} 50 | 51 | 52 | 57 | 58 | {pos.size.toFixed(4)} 59 | 60 | {pos.entry_price ? `$${pos.entry_price.toFixed(4)}` : 'N/A'} 61 | 62 | 63 | {pos.position_value ? `$${pos.position_value.toFixed(2)}` : 'N/A'} 64 | 65 | 66 | {pos.unrealized_pnl ? formatPnl(pos.unrealized_pnl) : 'N/A'} 67 | 68 | {pos.leverage ? `${pos.leverage.toFixed(2)}x` : 'N/A'} 69 | 70 | {pos.liquidation_price ? `$${pos.liquidation_price.toFixed(2)}` : 'N/A'} 71 | 72 | 73 | ))} 74 | 75 |
76 |
77 | ); 78 | }; 79 | 80 | export default PositionsTable; 81 | -------------------------------------------------------------------------------- /test_market_data.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test script to check funding rates and market data on Hyperliquid 4 | without executing any trades. 5 | """ 6 | 7 | import asyncio 8 | import json 9 | import aiohttp 10 | from hyperliquid.info import Info 11 | 12 | async def check_funding_rates(): 13 | """Check current funding rates on Hyperliquid.""" 14 | info = Info() 15 | 16 | async with aiohttp.ClientSession() as session: 17 | async with session.post( 18 | "https://api.hyperliquid.xyz/info", 19 | json={"type": "predictedFundings"}, 20 | headers={"Content-Type": "application/json"} 21 | ) as response: 22 | predicted_fundings = await response.json() 23 | 24 | # Process predicted fundings data 25 | predicted_rates = {} 26 | for item in predicted_fundings: 27 | coin = item[0] 28 | venues = item[1] 29 | 30 | for venue in venues: 31 | venue_name = venue[0] 32 | if venue_name == "HlPerp": # Hyperliquid Perp 33 | predicted_rates[coin] = venue[1].get("fundingRate", "0") 34 | return predicted_rates 35 | 36 | def calculate_yearly_funding_rates(predicted_rates, coins=None): 37 | """ 38 | Calculate the yearly funding rate for specified coins. 39 | 40 | Args: 41 | predicted_rates (dict): Dictionary with coin symbols as keys and funding rates as values 42 | coins (list): List of coin symbols to calculate yearly rates for. Default: BTC, ETH, HYPE 43 | 44 | Returns: 45 | dict: Dictionary with coin symbols as keys and yearly funding rates as values 46 | """ 47 | if coins is None: 48 | coins = ["BTC", "ETH", "HYPE"] 49 | 50 | # Funding rate is per 1 hour, so multiply by 24 for daily and then by 365 for yearly 51 | yearly_rates = {} 52 | for coin in coins: 53 | if coin in predicted_rates: 54 | # Convert funding rate to float 55 | rate = float(predicted_rates[coin]) 56 | # Calculate yearly rate (24 funding periods per day * 365 days) 57 | yearly_rate = rate * 24 * 365 58 | # Convert to percentage 59 | yearly_percentage = yearly_rate * 100 60 | yearly_rates[coin] = yearly_percentage 61 | else: 62 | yearly_rates[coin] = None # Coin not found in predicted rates 63 | 64 | return yearly_rates 65 | 66 | async def main(): 67 | """Main function to run the test script.""" 68 | # Get predicted funding rates 69 | predicted_rates = await check_funding_rates() 70 | 71 | # Calculate yearly funding rates for BTC, ETH, HYPE 72 | yearly_rates = calculate_yearly_funding_rates(predicted_rates) 73 | 74 | # Display results 75 | print("\nCurrent 1-hour funding rates:") 76 | for coin in ["BTC", "ETH", "HYPE"]: 77 | if coin in predicted_rates: 78 | print(f"{coin}: {predicted_rates[coin]}") 79 | else: 80 | print(f"{coin}: Not available") 81 | 82 | print("\nYearly funding rates (%):") 83 | for coin, rate in yearly_rates.items(): 84 | if rate is not None: 85 | print(f"{coin}: {rate:.4f}%") 86 | else: 87 | print(f"{coin}: Not available") 88 | 89 | if __name__ == "__main__": 90 | asyncio.run(main()) 91 | 92 | -------------------------------------------------------------------------------- /frontend/src/components/StatusOverview.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Card, 4 | CardContent, 5 | Typography, 6 | Grid, 7 | Chip, 8 | CircularProgress, 9 | Alert, 10 | Button, 11 | Box, 12 | } from '@mui/material'; 13 | import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew'; 14 | import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; 15 | import { useBotStatus } from '../hooks/useBotStatus'; 16 | import apiClient from '../api/client'; 17 | 18 | const StatusOverview: React.FC = () => { 19 | const { status, error, loading, refresh } = useBotStatus(); 20 | 21 | const handleStart = async () => { 22 | try { 23 | await apiClient.post('/bot/start'); 24 | refresh(); 25 | } catch (err) { 26 | console.error('Failed to start bot', err); 27 | } 28 | }; 29 | 30 | const handleStop = async () => { 31 | try { 32 | await apiClient.post('/bot/stop'); 33 | refresh(); 34 | } catch (err) { 35 | console.error('Failed to stop bot', err); 36 | } 37 | }; 38 | 39 | const handleShutdown = async () => { 40 | if (window.confirm('Are you sure you want to shut down the entire backend? This will stop the bot and the API server.')) { 41 | try { 42 | // We don't need to wait for a response, as the server will be shutting down. 43 | apiClient.post('/bot/shutdown'); 44 | alert('Shutdown signal sent to backend. You may need to refresh the page later.'); 45 | } catch (err) { 46 | // This part may not even be reached if the server shuts down immediately. 47 | console.error('Failed to send shutdown signal', err); 48 | } 49 | } 50 | }; 51 | 52 | if (loading && !status) { 53 | return ; 54 | } 55 | 56 | if (error) { 57 | return {error}; 58 | } 59 | 60 | if (!status) { 61 | return No status data available.; 62 | } 63 | 64 | return ( 65 | 66 | 67 | 68 | 69 | 70 | Account Value 71 | 72 | 73 | ${status.account.total_value.toFixed(2)} 74 | 75 | 76 | 77 | Bot Status 78 | : } 80 | label={status.running ? 'Running' : 'Stopped'} 81 | color={status.running ? 'success' : 'error'} 82 | sx={{ mt: 1 }} 83 | /> 84 | 85 | 86 | 87 | 96 | 105 | 112 | 113 | 114 | 115 | 116 | 117 | ); 118 | }; 119 | 120 | export default StatusOverview; 121 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | -------------------------------------------------------------------------------- /entrypoint.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Delta Bot Entrypoint 4 | 5 | This script initializes and runs a Delta trading bot with an API server 6 | for remote control and monitoring. 7 | """ 8 | 9 | import os 10 | import logging 11 | import asyncio 12 | import signal 13 | import sys 14 | import json 15 | from dotenv import load_dotenv 16 | 17 | # Load environment variables first to get version info 18 | load_dotenv() 19 | 20 | # Bot version information 21 | __version__ = os.getenv("BOT_VERSION", "1.0.0") 22 | BOT_NAME = "Delta" 23 | 24 | # Import the Delta bot 25 | from Delta import Delta 26 | 27 | # Import API module (will be created later) 28 | from api import start_api, stop_api 29 | from api.websocket_manager import WebSocketLogHandler, manager 30 | 31 | # Global reference to the bot instance 32 | delta_bot = None 33 | 34 | 35 | async def graceful_shutdown(loop, bot_instance): 36 | """Graceful shutdown of the Delta bot and API server.""" 37 | logger = logging.getLogger("DeltaBot") 38 | logger.info("Shutting down gracefully...") 39 | 40 | # Stop the API server 41 | await stop_api() 42 | 43 | # Close all positions 44 | if bot_instance: 45 | await bot_instance.exit_program(close_positions=True) 46 | 47 | # Stop the asyncio loop 48 | tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] 49 | for task in tasks: 50 | task.cancel() 51 | 52 | await asyncio.gather(*tasks, return_exceptions=True) 53 | loop.stop() 54 | sys.exit(0) 55 | 56 | 57 | async def main(): 58 | """Main entry point for running the Delta bot.""" 59 | global delta_bot 60 | 61 | # Set up logging 62 | log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' 63 | log_datefmt = '%Y-%m-%d %H:%M:%S' 64 | 65 | logging.basicConfig( 66 | level=logging.INFO, 67 | format=log_format, 68 | datefmt=log_datefmt, 69 | handlers=[ 70 | logging.StreamHandler(), 71 | logging.FileHandler("delta.log") 72 | ] 73 | ) 74 | 75 | # Add the WebSocket handler to the root logger 76 | ws_handler = WebSocketLogHandler(manager) 77 | ws_handler.setFormatter(logging.Formatter(log_format, datefmt=log_datefmt)) 78 | logging.getLogger().addHandler(ws_handler) 79 | 80 | logger = logging.getLogger("DeltaBot") 81 | 82 | logger.info(f"Initializing {BOT_NAME} bot v{__version__}...") 83 | 84 | # Create the Delta bot instance 85 | delta_bot = Delta() 86 | 87 | # Get the current asyncio loop 88 | loop = asyncio.get_running_loop() 89 | 90 | # Register signal handlers for graceful shutdown 91 | for sig in (signal.SIGINT, signal.SIGTERM): 92 | loop.add_signal_handler( 93 | sig, lambda: asyncio.create_task(graceful_shutdown(loop, delta_bot)) 94 | ) 95 | 96 | # Load API configuration from environment 97 | api_host = os.getenv("API_HOST", "0.0.0.0") 98 | api_port = int(os.getenv("API_PORT", "8080")) 99 | api_enabled = os.getenv("API_ENABLED", "true").lower() in ("true", "1", "yes") 100 | 101 | # Start API server if enabled 102 | if api_enabled and os.environ.get('API_SECRET_KEY'): 103 | logger.info(f"Starting API server on {api_host}:{api_port}") 104 | await start_api(delta_bot, host=api_host, port=api_port) 105 | logger.info("API server started") 106 | else: 107 | logger.warning("API server is disabled (either API_SECRET_KEY not set or API_ENABLED=false)") 108 | 109 | # Check the autostart setting from the configuration loaded by the bot 110 | autostart_from_config = delta_bot.config.get("general", {}).get("autostart", True) 111 | 112 | if autostart_from_config: 113 | logger.info(f"Autostarting {BOT_NAME} based on config.json...") 114 | await delta_bot.start() 115 | else: 116 | logger.info(f"{BOT_NAME} initialized in standby mode (autostart is false in config.json).") 117 | logger.info("API server is running. Use the frontend or API to start the bot manually.") 118 | # Keep the main task running to keep the API server alive 119 | try: 120 | while True: 121 | await asyncio.sleep(3600) # Sleep for a long time, or until shutdown is triggered 122 | except asyncio.CancelledError: 123 | logger.info("Standby mode cancelled.") 124 | 125 | 126 | if __name__ == "__main__": 127 | try: 128 | print(f"\n{BOT_NAME} Bot v{__version__} - HyperVault Trading Bots") 129 | print("=" * 50) 130 | asyncio.run(main()) 131 | except KeyboardInterrupt: 132 | logging.getLogger("DeltaBot").info("Application terminated by user") 133 | except Exception as e: 134 | logging.getLogger("DeltaBot").error(f"Application error: {e}") -------------------------------------------------------------------------------- /api/routes/bot_routes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Bot control routes for the Delta bot API. 3 | """ 4 | 5 | import logging 6 | import asyncio 7 | from fastapi import APIRouter, HTTPException 8 | from pydantic import BaseModel 9 | from typing import Optional, Dict, Any 10 | 11 | # Logger 12 | logger = logging.getLogger("BotRoutes") 13 | 14 | # Router 15 | router = APIRouter() 16 | 17 | # Bot instance (set at runtime) 18 | bot = None 19 | 20 | class BotResponse(BaseModel): 21 | success: bool 22 | message: str 23 | data: Optional[Dict[str, Any]] = None 24 | 25 | @router.post("/start") 26 | async def start_bot(): 27 | """Start the bot's main trading loop as a background task.""" 28 | if not bot: 29 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 30 | 31 | try: 32 | if hasattr(bot, 'start') and not bot._is_running: 33 | # Run the main loop as a background task so the API call can return immediately 34 | asyncio.create_task(bot.start()) 35 | 36 | return BotResponse( 37 | success=True, 38 | message="Bot started successfully and is running in the background." 39 | ) 40 | else: 41 | return BotResponse( 42 | success=False, 43 | message="Bot is already running" 44 | ) 45 | except Exception as e: 46 | logger.error(f"Error starting bot: {e}") 47 | raise HTTPException(status_code=500, detail=str(e)) 48 | 49 | @router.post("/stop") 50 | async def stop_bot(): 51 | """Stop the bot's trading operations.""" 52 | if not bot: 53 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 54 | 55 | try: 56 | # Stop the bot but keep the API running 57 | await bot.close_all_delta_positions() 58 | 59 | return BotResponse( 60 | success=True, 61 | message="Bot stopped successfully" 62 | ) 63 | except Exception as e: 64 | logger.error(f"Error stopping bot: {e}") 65 | raise HTTPException(status_code=500, detail=str(e)) 66 | 67 | @router.post("/close-position/{coin}") 68 | async def close_position(coin: str): 69 | """Close a specific position.""" 70 | if not bot: 71 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 72 | 73 | try: 74 | # Check if the coin is valid 75 | if coin not in bot.tracked_coins: 76 | raise HTTPException(status_code=400, detail=f"Invalid coin: {coin}") 77 | 78 | # Close the position 79 | result = await bot.close_delta_position(coin) 80 | 81 | return BotResponse( 82 | success=True, 83 | message=f"Closed position for {coin}", 84 | data={"result": result} 85 | ) 86 | except Exception as e: 87 | logger.error(f"Error closing position for {coin}: {e}") 88 | raise HTTPException(status_code=500, detail=str(e)) 89 | 90 | @router.post("/create-position/{coin}") 91 | async def create_position(coin: str): 92 | """Create a position for a specific coin.""" 93 | if not bot: 94 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 95 | 96 | try: 97 | # Check if the coin is valid 98 | if coin not in bot.tracked_coins: 99 | raise HTTPException(status_code=400, detail=f"Invalid coin: {coin}") 100 | 101 | # Create the position 102 | result = await bot.create_delta_position(coin) 103 | 104 | return BotResponse( 105 | success=True, 106 | message=f"Created position for {coin}", 107 | data={"result": result} 108 | ) 109 | except Exception as e: 110 | logger.error(f"Error creating position for {coin}: {e}") 111 | raise HTTPException(status_code=500, detail=str(e)) 112 | 113 | @router.get("/state") 114 | async def get_bot_state(): 115 | """Get the current state of the bot.""" 116 | if not bot: 117 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 118 | 119 | try: 120 | # Return basic bot state 121 | return BotResponse( 122 | success=True, 123 | message="Got bot state", 124 | data={"running": hasattr(bot, '_is_running') and bot._is_running} 125 | ) 126 | except Exception as e: 127 | logger.error(f"Error getting bot state: {e}") 128 | raise HTTPException(status_code=500, detail=str(e)) 129 | 130 | @router.post("/shutdown") 131 | async def shutdown_bot(): 132 | """Triggers a graceful shutdown of the entire bot backend.""" 133 | logger.info("Shutdown endpoint called. Triggering graceful shutdown.") 134 | 135 | # Send a SIGTERM signal to the current process to trigger the graceful shutdown 136 | import os 137 | import signal 138 | os.kill(os.getpid(), signal.SIGTERM) 139 | 140 | return BotResponse( 141 | success=True, 142 | message="Shutdown signal sent. The backend is shutting down." 143 | ) -------------------------------------------------------------------------------- /api/routes/config_routes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration routes for the Delta bot API. 3 | """ 4 | 5 | import json 6 | import logging 7 | from fastapi import APIRouter, HTTPException, Body 8 | from pydantic import BaseModel 9 | from typing import Optional, Dict, Any, List 10 | 11 | # Logger 12 | logger = logging.getLogger("ConfigRoutes") 13 | 14 | # Router 15 | router = APIRouter() 16 | 17 | # Bot instance (set at runtime) 18 | bot = None 19 | 20 | class ConfigResponse(BaseModel): 21 | success: bool 22 | message: str 23 | data: Optional[Dict[str, Any]] = None 24 | 25 | @router.get("") 26 | async def get_config(): 27 | """Get the current configuration of the bot.""" 28 | if not bot: 29 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 30 | 31 | try: 32 | # Return the current configuration 33 | config = {} 34 | 35 | # Only return serializable configuration 36 | if hasattr(bot, 'config'): 37 | config = bot.config.copy() 38 | 39 | # Additional configuration from the bot (excluding private keys) 40 | if hasattr(bot, 'tracked_coins'): 41 | config['tracked_coins'] = bot.tracked_coins 42 | 43 | return ConfigResponse( 44 | success=True, 45 | message="Configuration retrieved successfully", 46 | data={"config": config} 47 | ) 48 | except Exception as e: 49 | logger.error(f"Error getting configuration: {e}") 50 | raise HTTPException(status_code=500, detail=str(e)) 51 | 52 | @router.post("/update") 53 | async def update_config( 54 | updates: Dict[str, Any] = Body(..., description="Configuration updates") 55 | ): 56 | """Update the bot's configuration.""" 57 | if not bot: 58 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 59 | 60 | try: 61 | # Validate the updates 62 | for key, value in updates.items(): 63 | # Don't update the private key 64 | if key == "private_key" or key == "address": 65 | continue 66 | 67 | # Apply the update 68 | if key == "tracked_coins" and isinstance(value, list): 69 | bot.tracked_coins = value 70 | elif hasattr(bot, 'config') and isinstance(bot.config, dict): 71 | bot.config[key] = value 72 | 73 | return ConfigResponse( 74 | success=True, 75 | message="Configuration updated successfully", 76 | data={"updated_keys": [k for k in updates.keys() if k not in ["private_key", "address"]]} 77 | ) 78 | except Exception as e: 79 | logger.error(f"Error updating configuration: {e}") 80 | raise HTTPException(status_code=500, detail=str(e)) 81 | 82 | @router.get("/tracked-coins") 83 | async def get_tracked_coins(): 84 | """Get the list of tracked coins.""" 85 | if not bot: 86 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 87 | 88 | try: 89 | return ConfigResponse( 90 | success=True, 91 | message="Tracked coins retrieved successfully", 92 | data={"tracked_coins": bot.tracked_coins} 93 | ) 94 | except Exception as e: 95 | logger.error(f"Error getting tracked coins: {e}") 96 | raise HTTPException(status_code=500, detail=str(e)) 97 | 98 | @router.post("/add-coin/{coin}") 99 | async def add_tracked_coin(coin: str): 100 | """Add a coin to the tracked coins list.""" 101 | if not bot: 102 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 103 | 104 | try: 105 | # Check if the coin is already tracked 106 | if coin in bot.tracked_coins: 107 | return ConfigResponse( 108 | success=False, 109 | message=f"Coin {coin} is already tracked" 110 | ) 111 | 112 | # Add the coin to the tracked coins list 113 | bot.tracked_coins.append(coin) 114 | 115 | return ConfigResponse( 116 | success=True, 117 | message=f"Added {coin} to tracked coins", 118 | data={"tracked_coins": bot.tracked_coins} 119 | ) 120 | except Exception as e: 121 | logger.error(f"Error adding tracked coin: {e}") 122 | raise HTTPException(status_code=500, detail=str(e)) 123 | 124 | @router.post("/remove-coin/{coin}") 125 | async def remove_tracked_coin(coin: str): 126 | """Remove a coin from the tracked coins list.""" 127 | if not bot: 128 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 129 | 130 | try: 131 | # Check if the coin is in the tracked coins list 132 | if coin not in bot.tracked_coins: 133 | return ConfigResponse( 134 | success=False, 135 | message=f"Coin {coin} is not in the tracked coins list" 136 | ) 137 | 138 | # Remove the coin from the tracked coins list 139 | bot.tracked_coins.remove(coin) 140 | 141 | return ConfigResponse( 142 | success=True, 143 | message=f"Removed {coin} from tracked coins", 144 | data={"tracked_coins": bot.tracked_coins} 145 | ) 146 | except Exception as e: 147 | logger.error(f"Error removing tracked coin: {e}") 148 | raise HTTPException(status_code=500, detail=str(e)) -------------------------------------------------------------------------------- /api/routes/status_routes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Status routes for the Delta bot API. 3 | """ 4 | 5 | import logging 6 | from fastapi import APIRouter, HTTPException 7 | from pydantic import BaseModel 8 | from typing import Optional, Dict, Any, List 9 | 10 | # Logger 11 | logger = logging.getLogger("StatusRoutes") 12 | 13 | # Router 14 | router = APIRouter() 15 | 16 | # Bot instance (set at runtime) 17 | bot = None 18 | 19 | class StatusResponse(BaseModel): 20 | success: bool 21 | message: str 22 | data: Optional[Dict[str, Any]] = None 23 | 24 | @router.get("") 25 | async def get_status(): 26 | """Get the current status of the bot and its positions.""" 27 | if not bot: 28 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 29 | 30 | try: 31 | positions = [] 32 | 33 | # Extract position information 34 | for coin_name, coin_info in bot.coins.items(): 35 | if coin_info.spot and hasattr(coin_info.spot, 'position') and coin_info.spot.position: 36 | # Has a spot position 37 | spot_position = coin_info.spot.position 38 | positions.append({ 39 | "coin": coin_name, 40 | "type": "spot", 41 | "size": spot_position.get("total", 0), 42 | "value": spot_position.get("entry_ntl", 0), 43 | "hold": spot_position.get("hold", 0) 44 | }) 45 | 46 | if coin_info.perp and hasattr(coin_info.perp, 'position') and coin_info.perp.position: 47 | # Has a perp position 48 | perp_position = coin_info.perp.position 49 | positions.append({ 50 | "coin": coin_name, 51 | "type": "perp", 52 | "size": perp_position.get("size", 0), 53 | "entry_price": perp_position.get("entry_price", 0), 54 | "position_value": perp_position.get("position_value", 0), 55 | "unrealized_pnl": perp_position.get("unrealized_pnl", 0), 56 | "leverage": perp_position.get("leverage", 0), 57 | "liquidation_price": perp_position.get("liquidation_price", 0), 58 | "funding": perp_position.get("cum_funding", 0) 59 | }) 60 | 61 | # Get funding rates if available 62 | funding_rates = {} 63 | for coin_name, coin_info in bot.coins.items(): 64 | if coin_info.perp and hasattr(coin_info.perp, 'funding_rate') and coin_info.perp.funding_rate: 65 | funding_rates[coin_name] = { 66 | "hourly": coin_info.perp.funding_rate, 67 | "yearly": coin_info.perp.yearly_funding_rate if hasattr(coin_info.perp, 'yearly_funding_rate') else None 68 | } 69 | 70 | # Account information 71 | account_info = { 72 | "address": bot.address, 73 | "total_value": bot.account_value, 74 | "margin_used": bot.total_margin_used if hasattr(bot, 'total_margin_used') else None, 75 | "total_raw_usd": bot.total_raw_usd if hasattr(bot, 'total_raw_usd') else None 76 | } 77 | 78 | # Return all status information 79 | return StatusResponse( 80 | success=True, 81 | message="Bot status retrieved successfully", 82 | data={ 83 | "running": hasattr(bot, '_is_running') and bot._is_running, 84 | "positions": positions, 85 | "funding_rates": funding_rates, 86 | "account": account_info, 87 | "pending_orders": len(bot.pending_orders) if hasattr(bot, 'pending_orders') else 0 88 | } 89 | ) 90 | except Exception as e: 91 | logger.error(f"Error getting bot status: {e}") 92 | raise HTTPException(status_code=500, detail=str(e)) 93 | 94 | @router.get("/funding-rates") 95 | async def get_funding_rates(): 96 | """Get the current funding rates for all tracked coins.""" 97 | if not bot: 98 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 99 | 100 | try: 101 | # Fetch current funding rates 102 | await bot.check_hourly_funding_rates() 103 | 104 | funding_rates = {} 105 | for coin_name, coin_info in bot.coins.items(): 106 | if coin_info.perp and hasattr(coin_info.perp, 'funding_rate') and coin_info.perp.funding_rate: 107 | funding_rates[coin_name] = { 108 | "hourly": coin_info.perp.funding_rate, 109 | "yearly": coin_info.perp.yearly_funding_rate if hasattr(coin_info.perp, 'yearly_funding_rate') else None 110 | } 111 | 112 | return StatusResponse( 113 | success=True, 114 | message="Funding rates retrieved successfully", 115 | data={"funding_rates": funding_rates} 116 | ) 117 | except Exception as e: 118 | logger.error(f"Error getting funding rates: {e}") 119 | raise HTTPException(status_code=500, detail=str(e)) 120 | 121 | @router.get("/positions") 122 | async def get_positions(): 123 | """Get all current positions.""" 124 | if not bot: 125 | raise HTTPException(status_code=500, detail="Bot instance not initialized") 126 | 127 | try: 128 | positions = [] 129 | 130 | # Extract position information 131 | for coin_name, coin_info in bot.coins.items(): 132 | if coin_info.spot and hasattr(coin_info.spot, 'position') and coin_info.spot.position: 133 | # Has a spot position 134 | spot_position = coin_info.spot.position 135 | positions.append({ 136 | "coin": coin_name, 137 | "type": "spot", 138 | "size": spot_position.get("total", 0), 139 | "value": spot_position.get("entry_ntl", 0), 140 | "hold": spot_position.get("hold", 0) 141 | }) 142 | 143 | if coin_info.perp and hasattr(coin_info.perp, 'position') and coin_info.perp.position: 144 | # Has a perp position 145 | perp_position = coin_info.perp.position 146 | positions.append({ 147 | "coin": coin_name, 148 | "type": "perp", 149 | "size": perp_position.get("size", 0), 150 | "entry_price": perp_position.get("entry_price", 0), 151 | "position_value": perp_position.get("position_value", 0), 152 | "unrealized_pnl": perp_position.get("unrealized_pnl", 0), 153 | "leverage": perp_position.get("leverage", 0), 154 | "liquidation_price": perp_position.get("liquidation_price", 0), 155 | "funding": perp_position.get("cum_funding", 0) 156 | }) 157 | 158 | return StatusResponse( 159 | success=True, 160 | message="Positions retrieved successfully", 161 | data={"positions": positions} 162 | ) 163 | except Exception as e: 164 | logger.error(f"Error getting positions: {e}") 165 | raise HTTPException(status_code=500, detail=str(e)) -------------------------------------------------------------------------------- /README_zh-Tw.md: -------------------------------------------------------------------------------- 1 | # HyperVault Delta Bot v1.0.0 2 | 3 | A delta-neutral trading bot for HyperLiquid exchange, designed to create and manage delta-neutral positions across spot and perpetual markets. 4 | 5 | ![Delta Bot in Action](./assets/terminal-screenshot.png) 6 | *Delta Bot taking a position on HyperLiquid Exchange* 7 | 8 | ## Features 9 | 10 | - Implements delta-neutral trading strategies across spot and perpetual markets 11 | - Automatically identifies the best funding rates for optimal yield 12 | - Monitors and rebalances positions to maintain delta neutrality 13 | - RESTful API for remote control and monitoring 14 | - Handles order tracking and management 15 | - Periodic checks to find better opportunities based on funding rates 16 | - Graceful shutdown with position closing 17 | - Comprehensive logging 18 | 19 | ## Configuration 20 | 21 | The bot uses a combination of configuration files and environment variables: 22 | 23 | ### 1. **Configuration File** (config.json) - Primary Configuration 24 | 25 | The preferred way to configure the bot is through the `config.json` file, which controls most of the bot's behavior: 26 | 27 | ```json 28 | { 29 | "general": { 30 | "debug": false, 31 | "tracked_coins": ["BTC", "ETH", "HYPE", "USDC"], 32 | "autostart": true 33 | }, 34 | "allocation": { 35 | "spot_pct": 70, 36 | "perp_pct": 30, 37 | "rebalance_threshold": 0.05 38 | }, 39 | "trading": { 40 | "refresh_interval_sec": 60 41 | }, 42 | "api": { 43 | "host": "0.0.0.0", 44 | "port": 8080, 45 | "enabled": true 46 | } 47 | } 48 | ``` 49 | 50 | Configuration sections: 51 | - **General settings:** 52 | - `debug`: Enable detailed debug logging 53 | - `tracked_coins`: List of coins to track and trade 54 | - `autostart`: Whether to start trading automatically 55 | - **Allocation settings:** 56 | - `spot_pct`: Percentage of capital to allocate to spot positions (e.g., 70%) 57 | - `perp_pct`: Percentage of capital to allocate to perpetual positions (e.g., 30%) 58 | - `rebalance_threshold`: Threshold for rebalancing positions (e.g., 0.05 = 5%) 59 | - **Trading settings:** 60 | - `refresh_interval_sec`: Interval for refreshing positions in seconds 61 | - **API settings:** 62 | - `host`: Host for the API server 63 | - `port`: Port for the API server 64 | - `enabled`: Whether the API server is enabled 65 | 66 | ### 2. **Environment Variables** - Required for Authentication 67 | 68 | These environment variables are used for authentication with HyperLiquid and must be set: 69 | 70 | - `HYPERLIQUID_PRIVATE_KEY`: Your private key for trading on HyperLiquid 71 | - `HYPERLIQUID_ADDRESS`: Your Ethereum address for HyperLiquid 72 | 73 | Example using environment variables: 74 | ```bash 75 | export HYPERLIQUID_PRIVATE_KEY={PRIVATE_KEY} 76 | export HYPERLIQUID_ADDRESS={SUB_ACCOUNT_TRADING_ADDRESS} 77 | ``` 78 | 79 | ## Quick Start 80 | 81 | 1. Clone the repository 82 | 2. Set up environment variables for authentication 83 | 3. Customize `config.json` to match your desired trading parameters 84 | 4. Run the example script to test your configuration: 85 | ```bash 86 | python example.py 87 | ``` 88 | - 系統會檢查 config.json 中的 autostart 設定,如果設為 true(預設值),機器人會自動開始交易。 89 | 90 | ## 一旦啟動,系統會進入主要監控循環: 91 | - 定期檢查:每 60 秒(可在配置中調整)檢查一次 92 | - 資金費率監控:在每小時的第 50 分鐘檢查資金費率 93 | - 自動下單條件:當找到年化收益率 ≥ 5% 的機會時會自動創建 delta-neutral 部位 94 | 95 | ## 系統會自動執行以下操作: 96 | - 創建部位:同時買入現貨和做空永續合約 97 | - 切換部位:當當前部位收益率低於 5% 且有更好機會時,會自動關閉舊部位並創建新部位 98 | - 訂單追蹤:自動監控訂單執行狀態 99 | 100 | **注意 1**:如果想要手動控制而非自動交易,可以在 `config.json` 中將 autostart 設為 false,然後通過 API 端點手動控制機器人的啟動和停止。 101 | 102 | **注意 2**:當您設定 "autostart": false 時,後端服務會正常啟動,API伺服器也會運行,但交易機器人本身的核心邏輯會處於「待命」狀態。 103 | 104 | 日誌中的 Call start() manually to begin. 這句話的意思是「請手動呼叫 start()函數來開始運作」。這裡的「呼叫」並不是指在終端機輸入一個新的指令,而是指透過 API 來向正在運行的後端程式下達「開始」的指令。 105 | 1. 啟動後端服務 (python entrypoint.py)。 106 | 2. 啟動前端服務 (cd frontend && npm start)。 107 | 3. 在瀏覽器中打開 http://localhost:3000。 108 | 4. 在儀表板右上角的「Account Overview」區塊,您會看到一個綠色的 "Start" 按鈕。 109 | 5. 點擊這個 "Start" 按鈕。 110 | 111 | 點擊按鈕後,前端會發送一個 POST 請求到後端的 /api/bot/start 端點,後端收到請求後就會呼叫 start() 函數,您的機器人便會開始執行交易邏輯。 112 | 113 | 5. Start the bot: 114 | ```bash 115 | python Delta.py 116 | ``` 117 | 118 | ## Building 119 | 120 | Build the Docker image with: 121 | 122 | ```bash 123 | ./build.sh 124 | ``` 125 | 126 | This will create two images: 127 | - `hypervault-tradingbot:delta` (latest version) 128 | - `hypervault-tradingbot:delta-1.0.0` (versioned tag) 129 | 130 | ## API Endpoints 131 | 132 | The bot provides a RESTful API for remote control and monitoring: 133 | 134 | ### Bot Control 135 | - `GET /api/bot/state`: Get the current state of the bot 136 | - `POST /api/bot/start`: Start the bot's trading operations 137 | - `POST /api/bot/stop`: Stop the bot's trading operations 138 | - `POST /api/bot/close-position/{coin}`: Close a specific position 139 | - `POST /api/bot/create-position/{coin}`: Create a position for a specific coin 140 | 141 | ### Status and Monitoring 142 | - `GET /api/status`: Get the current status of the bot and its positions 143 | - `GET /api/status/funding-rates`: Get the current funding rates for all tracked coins 144 | - `GET /api/status/positions`: Get all current positions 145 | 146 | ### Configuration 147 | - `GET /api/config`: Get the current configuration of the bot 148 | - `POST /api/config/update`: Update the bot's configuration 149 | - `GET /api/config/tracked-coins`: Get the list of tracked coins 150 | - `POST /api/config/add-coin/{coin}`: Add a coin to the tracked coins list 151 | - `POST /api/config/remove-coin/{coin}`: Remove a coin from the tracked coins list 152 | 153 | ## Usage 154 | 155 | 1. Create a `.env` file from `.env.example` with your credentials 156 | 2. Adjust `config.json` to match your desired trading parameters 157 | 3. Build and run the Docker container: 158 | 159 | ```bash 160 | docker run -d \ 161 | --name delta-bot \ 162 | -p 8080:8080 \ 163 | --env-file .env \ 164 | hypervault-tradingbot:delta-1.0.0 165 | ``` 166 | 167 | ## 本專案配備前端UI讓使用者更方便地追蹤bot運作 168 | 169 | - 首先開啟新的terminal執行 `python entrypoint.py` 來啟動後端 (必須在 `localhost:8080` 上運行) 170 | - 接著再開啟另外一個terminal,導航到 `frontend` 目錄,然後執行 `npm start` (網址通常是 `http://localhost:3000`)。 171 | - 為了將前端連接到 API,請在 `frontend` 目錄內創建一個 `.env` 文件,並添加以下行: 172 | ``` 173 | REACT_APP_API_KEY=your_api_key 174 | ``` 175 | 如果您的 API 需要金鑰,請將 `your_api_key` 替換為您的實際 API 金鑰。如果 API 不需要金鑰,您可以將其留空。 176 | 177 | ## The HyperVault Trading Ecosystem (Coming Soon!) 178 | 179 | The Delta bot is part of the comprehensive HyperVault trading ecosystem. Our full platform will allow you to: 180 | 181 | - Deploy multiple bots with a single click 182 | - Leverage our Machine Learning engine to automatically optimize your trading configurations 183 | - Access specialized bots including this Delta-Neutral bot and our Market Making bots 184 | - Monitor your performance through our advanced dashboard featuring: 185 | - Real-time position management 186 | - Earnings visualization and analytics 187 | - Latest position tracking and performance metrics 188 | 189 | HyperVault is designed for both new traders seeking simplified automation and experienced traders demanding powerful customization. 190 | 191 | ## Delta-Neutral Strategy 192 | 193 | The Delta bot implements a capital-efficient strategy: 194 | - Long spot positions to earn the funding rate 195 | - Short perpetual futures positions to hedge price risk 196 | - Automatically switches to better opportunities when funding rates change 197 | 198 | The system targets a 70/30 spot-to-perp allocation ratio for optimal capital efficiency. 199 | 200 | ## Versioning 201 | 202 | ### Current Version: 1.0.0 203 | 204 | **Release Notes:** 205 | - Initial release with core delta-neutral functionality 206 | - Full integration with HyperVault Trading Bots platform 207 | - API-based control and monitoring 208 | - Automatic detection of best funding opportunities 209 | 210 | ## Star History 211 | 212 | [![Star History Chart](https://api.star-history.com/svg?repos=cgaspart/HL-Delta&type=Date)](https://www.star-history.com/#cgaspart/HL-Delta&Date) 213 | 214 | ## License 215 | 216 | MIT License 217 | 218 | Copyright (c) 2024 219 | 220 | Permission is hereby granted, free of charge, to any person obtaining a copy 221 | of this software and associated documentation files (the "Software"), to deal 222 | in the Software without restriction, including without limitation the rights 223 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 224 | copies of the Software, and to permit persons to whom the Software is 225 | furnished to do so, subject to the following conditions: 226 | 227 | The above copyright notice and this permission notice shall be included in all 228 | copies or substantial portions of the Software. 229 | 230 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 231 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 232 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 233 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 234 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 235 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 236 | SOFTWARE. 237 | 238 | ## Disclaimer 239 | 240 | This software is for educational purposes only. Use at your own risk. Trading cryptocurrencies involves significant risk of loss and is not suitable for all investors. 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HyperVault Delta Bot v1.0.0 2 | 3 | A delta-neutral trading bot for HyperLiquid exchange, designed to create and manage delta-neutral positions across spot and perpetual markets. 4 | 5 | ![Delta Bot in Action](./assets/terminal-screenshot.png) 6 | *Delta Bot taking a position on HyperLiquid Exchange* 7 | 8 | ## Features 9 | 10 | - Implements delta-neutral trading strategies across spot and perpetual markets 11 | - Automatically identifies the best funding rates for optimal yield 12 | - Monitors and rebalances positions to maintain delta neutrality 13 | - RESTful API for remote control and monitoring 14 | - Handles order tracking and management 15 | - Periodic checks to find better opportunities based on funding rates 16 | - Graceful shutdown with position closing 17 | - Comprehensive logging 18 | 19 | ## Configuration 20 | 21 | The bot uses a combination of configuration files and environment variables: 22 | 23 | ### 1. **Configuration File** (config.json) - Primary Configuration 24 | 25 | The preferred way to configure the bot is through the `config.json` file, which controls most of the bot's behavior: 26 | 27 | ```json 28 | { 29 | "general": { 30 | "debug": false, 31 | "tracked_coins": ["BTC", "ETH", "HYPE", "USDC"], 32 | "autostart": true 33 | }, 34 | "allocation": { 35 | "spot_pct": 70, 36 | "perp_pct": 30, 37 | "rebalance_threshold": 0.05 38 | }, 39 | "trading": { 40 | "refresh_interval_sec": 60 41 | }, 42 | "api": { 43 | "host": "0.0.0.0", 44 | "port": 8080, 45 | "enabled": true 46 | } 47 | } 48 | ``` 49 | 50 | Configuration sections: 51 | - **General settings:** 52 | - `debug`: Enable detailed debug logging 53 | - `tracked_coins`: List of coins to track and trade 54 | - `autostart`: Whether to start trading automatically 55 | - **Allocation settings:** 56 | - `spot_pct`: Percentage of capital to allocate to spot positions (e.g., 70%) 57 | - `perp_pct`: Percentage of capital to allocate to perpetual positions (e.g., 30%) 58 | - `rebalance_threshold`: Threshold for rebalancing positions (e.g., 0.05 = 5%) 59 | - **Trading settings:** 60 | - `refresh_interval_sec`: Interval for refreshing positions in seconds 61 | - **API settings:** 62 | - `host`: Host for the API server 63 | - `port`: Port for the API server 64 | - `enabled`: Whether the API server is enabled 65 | 66 | ### 2. **Environment Variables** - Required for Authentication and API Security 67 | 68 | These environment variables are used for authentication with HyperLiquid and for securing the bot's API. They must be set in your environment or in a `.env` file. 69 | 70 | - `HYPERLIQUID_PRIVATE_KEY`: Your private key for trading on HyperLiquid. 71 | - `HYPERLIQUID_ADDRESS`: Your Ethereum address for HyperLiquid. 72 | - `API_SECRET_KEY`: A secret key of your choice to protect the bot's API endpoints. The frontend must use this same key to authenticate. 73 | 74 | Example using environment variables: 75 | ```bash 76 | export HYPERLIQUID_PRIVATE_KEY={PRIVATE_KEY} 77 | export HYPERLIQUID_ADDRESS={SUB_ACCOUNT_TRADING_ADDRESS} 78 | export API_SECRET_KEY={YOUR_SECRET_KEY} 79 | ``` 80 | 81 | ## Quick Start 82 | 83 | 1. Clone the repository 84 | 2. Set up environment variables for authentication 85 | 3. Customize `config.json` to match your desired trading parameters 86 | 4. Run the example script to test your configuration: 87 | ```bash 88 | python example.py 89 | ``` 90 | - The system checks the `autostart` setting in `config.json`. If set to `true` (the default), the bot will automatically start trading. 91 | 92 | ## Once started, the system enters the main monitoring loop: 93 | - **Periodic checks:** Every 60 seconds (configurable), the bot checks for opportunities. 94 | - **Funding Rate Monitoring:** At the 50th minute of each hour, it monitors funding rates. 95 | - **Automatic Order Condition:** When an opportunity with an annualized yield of ≥ 5% is found, it automatically creates a delta-neutral position. 96 | 97 | ## The system automatically performs the following actions: 98 | - **Position Creation:** Simultaneously buys spot and shorts perpetual contracts. 99 | - **Position Switching:** If the current position's yield drops below 5% and a better opportunity is available, it closes the old position and opens a new one. 100 | - **Order Tracking:** Automatically monitors order execution status. 101 | 102 | **Note 1:** If you prefer manual control, set `autostart` to `false` in `config.json`. You can then start and stop the bot using API endpoints. 103 | 104 | **Note 2:** When `autostart` is `false`, the backend service and API server will run, but the bot's core trading logic will remain in a "standby" state. The log message `Call start() manually to begin.` indicates that you need to trigger the bot via the API. 105 | 106 | 1. Start the backend service: `python entrypoint.py`. 107 | 2. Start the frontend service: `cd frontend && npm start`. 108 | 3. Open your browser to `http://localhost:3000`. 109 | 4. In the "Account Overview" section on the dashboard, click the green "Start" button. 110 | 111 | Clicking "Start" sends a `POST` request to the `/api/bot/start` endpoint, which calls the `start()` function and activates the bot's trading logic. 112 | 113 | 5. Start the bot: 114 | ```bash 115 | python Delta.py 116 | ``` 117 | 118 | ## Building 119 | 120 | Build the Docker image with: 121 | 122 | ```bash 123 | ./build.sh 124 | ``` 125 | 126 | This will create two images: 127 | - `hypervault-tradingbot:delta` (latest version) 128 | - `hypervault-tradingbot:delta-1.0.0` (versioned tag) 129 | 130 | ## API Endpoints 131 | 132 | The bot provides a RESTful API for remote control and monitoring: 133 | 134 | ### Bot Control 135 | - `GET /api/bot/state`: Get the current state of the bot 136 | - `POST /api/bot/start`: Start the bot's trading operations 137 | - `POST /api/bot/stop`: Stop the bot's trading operations 138 | - `POST /api/bot/close-position/{coin}`: Close a specific position 139 | - `POST /api/bot/create-position/{coin}`: Create a position for a specific coin 140 | 141 | ### Status and Monitoring 142 | - `GET /api/status`: Get the current status of the bot and its positions 143 | - `GET /api/status/funding-rates`: Get the current funding rates for all tracked coins 144 | - `GET /api/status/positions`: Get all current positions 145 | 146 | ### Configuration 147 | - `GET /api/config`: Get the current configuration of the bot 148 | - `POST /api/config/update`: Update the bot's configuration 149 | - `GET /api/config/tracked-coins`: Get the list of tracked coins 150 | - `POST /api/config/add-coin/{coin}`: Add a coin to the tracked coins list 151 | - `POST /api/config/remove-coin/{coin}`: Remove a coin from the tracked coins list 152 | 153 | ## Usage 154 | 155 | 1. Create a `.env` file from `.env.example` with your credentials 156 | 2. Adjust `config.json` to match your desired trading parameters 157 | 3. Build and run the Docker container: 158 | 159 | ```bash 160 | docker run -d \ 161 | --name delta-bot \ 162 | -p 8080:8080 \ 163 | --env-file .env \ 164 | hypervault-tradingbot:delta-1.0.0 165 | ``` 166 | 167 | ## Frontend UI for Bot Monitoring 168 | 169 | This project includes a frontend UI to help you track the bot's performance more easily. 170 | 171 | - First, open a new terminal and run `python entrypoint.py` to start the backend (must be running on `localhost:8080`). 172 | - Next, open another terminal, navigate to the `frontend` directory, and run `npm start` (the URL is typically `http://localhost:3000`). 173 | - To connect the frontend to the API, create a `.env` file inside the `frontend` directory. This file must contain the API key that matches the `API_SECRET_KEY` used by the backend. 174 | ``` 175 | REACT_APP_API_KEY=your_secret_key 176 | ``` 177 | Replace `your_secret_key` with the same value you set for `API_SECRET_KEY` in the backend environment. 178 | 179 | ## The HyperVault Trading Ecosystem (Coming Soon!) 180 | 181 | The Delta bot is part of the comprehensive HyperVault trading ecosystem. Our full platform will allow you to: 182 | 183 | - Deploy multiple bots with a single click 184 | - Leverage our Machine Learning engine to automatically optimize your trading configurations 185 | - Access specialized bots including this Delta-Neutral bot and our Market Making bots 186 | - Monitor your performance through our advanced dashboard featuring: 187 | - Real-time position management 188 | - Earnings visualization and analytics 189 | - Latest position tracking and performance metrics 190 | 191 | HyperVault is designed for both new traders seeking simplified automation and experienced traders demanding powerful customization. 192 | 193 | ## Delta-Neutral Strategy 194 | 195 | The Delta bot implements a capital-efficient strategy: 196 | - Long spot positions to earn the funding rate 197 | - Short perpetual futures positions to hedge price risk 198 | - Automatically switches to better opportunities when funding rates change 199 | 200 | The system targets a 70/30 spot-to-perp allocation ratio for optimal capital efficiency. 201 | 202 | ## Versioning 203 | 204 | ### Current Version: 1.0.0 205 | 206 | **Release Notes:** 207 | - Initial release with core delta-neutral functionality 208 | - Full integration with HyperVault Trading Bots platform 209 | - API-based control and monitoring 210 | - Automatic detection of best funding opportunities 211 | 212 | ## Star History 213 | 214 | [![Star History Chart](https://api.star-history.com/svg?repos=cgaspart/HL-Delta&type=Date)](https://www.star-history.com/#cgaspart/HL-Delta&Date) 215 | 216 | ## License 217 | 218 | MIT License 219 | 220 | Copyright (c) 2024 221 | 222 | Permission is hereby granted, free of charge, to any person obtaining a copy 223 | of this software and associated documentation files (the "Software"), to deal 224 | in the Software without restriction, including without limitation the rights 225 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 226 | copies of the Software, and to permit persons to whom the Software is 227 | furnished to do so, subject to the following conditions: 228 | 229 | The above copyright notice and this permission notice shall be included in all 230 | copies or substantial portions of the Software. 231 | 232 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 233 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 234 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 235 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 236 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 237 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 238 | SOFTWARE. 239 | 240 | ## Disclaimer 241 | 242 | This software is for educational purposes only. Use at your own risk. Trading cryptocurrencies involves significant risk of loss and is not suitable for all investors. 243 | -------------------------------------------------------------------------------- /Delta.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | HL-Delta: Automated trading system for Hyperliquid 4 | """ 5 | 6 | import os 7 | import logging 8 | import asyncio 9 | import time 10 | import json 11 | from hyperliquid.exchange import Exchange 12 | from hyperliquid.info import Info 13 | from hyperliquid.utils import constants 14 | import eth_account 15 | from eth_account.signers.local import LocalAccount 16 | from dataclasses import dataclass, field 17 | from typing import Dict, Optional, List, Any, Tuple 18 | 19 | # ANSI color codes for colored terminal output 20 | class Colors: 21 | RESET = "\033[0m" 22 | RED = "\033[91m" # Error messages 23 | GREEN = "\033[92m" # Success messages 24 | YELLOW = "\033[93m" # Warnings and important highlights 25 | BLUE = "\033[94m" # Info messages 26 | BOLD = "\033[1m" # Bold text for headers 27 | 28 | logging.basicConfig( 29 | level=logging.INFO, 30 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 31 | handlers=[ 32 | logging.StreamHandler(), 33 | logging.FileHandler("delta.log") 34 | ] 35 | ) 36 | logger = logging.getLogger("HL-Delta") 37 | 38 | 39 | @dataclass 40 | class SpotMarket: 41 | name: str 42 | token_id: str 43 | index: int 44 | sz_decimals: int 45 | wei_decimals: int 46 | is_canonical: bool 47 | full_name: str 48 | evm_contract: Optional[Dict] = None 49 | deployer_trading_fee_share: str = "0.0" 50 | position: Dict[str, Any] = field(default_factory=dict) 51 | tick_size: float = 0 52 | 53 | @dataclass 54 | class PerpMarket: 55 | name: str 56 | sz_decimals: int 57 | max_leverage: int 58 | index: int 59 | position: Dict[str, Any] = field(default_factory=dict) 60 | funding_rate: Optional[float] = None 61 | yearly_funding_rate: Optional[float] = None 62 | tick_size: float = 0 63 | 64 | @dataclass 65 | class CoinInfo: 66 | name: str 67 | spot: Optional[SpotMarket] = None 68 | perp: Optional[PerpMarket] = None 69 | 70 | @dataclass 71 | class PendingDeltaOrder: 72 | coin_name: str 73 | spot_oid: Optional[int] = None 74 | perp_oid: Optional[int] = None 75 | creation_time: float = field(default_factory=time.time) 76 | last_check_time: float = field(default_factory=time.time) 77 | spot_filled: bool = False 78 | perp_filled: bool = False 79 | max_wait_time: int = 300 # 5 minutes in seconds 80 | is_closing_position: bool = False # Flag to indicate if this is for closing a position 81 | 82 | 83 | class Delta: 84 | def __init__(self, config_path="config.json"): 85 | self.config_path = config_path 86 | self.config = self._load_config() 87 | try: 88 | # Initialize tracked coins from config 89 | self.tracked_coins = self.config["general"]["tracked_coins"] 90 | self.coins: Dict[str, CoinInfo] = {} 91 | self.pending_orders: List[PendingDeltaOrder] = [] 92 | self._is_running = False # Track if the bot is actively running 93 | 94 | # Set debug mode from config 95 | if self.config["general"].get("debug", False): 96 | logger.setLevel(logging.DEBUG) 97 | logger.debug("Debug mode enabled") 98 | 99 | # Load credentials from environment variables 100 | private_key = self._get_required_env("HYPERLIQUID_PRIVATE_KEY") 101 | self.address = self._get_required_env("HYPERLIQUID_ADDRESS") 102 | 103 | self.account: LocalAccount = eth_account.Account.from_key(private_key) 104 | self.exchange = Exchange(self.account, constants.MAINNET_API_URL, vault_address=self.address) 105 | self.info = Info(constants.MAINNET_API_URL, skip_ws=True) 106 | self.api_url = constants.MAINNET_API_URL 107 | 108 | self.user_state = self.info.user_state(self.address) 109 | self.spot_user_state = self.info.spot_user_state(self.address) 110 | self.perp_user_state = self.account_balance = float(self.user_state['crossMarginSummary'].get('accountValue', 0)) 111 | self.margin_summary = self.user_state["marginSummary"] 112 | 113 | # Load market data 114 | spot_meta = self.info.spot_meta() 115 | spot_coins = spot_meta["tokens"] 116 | 117 | perp_meta = self.info.meta() 118 | perp_coins = perp_meta["universe"] 119 | 120 | for coin_name in self.tracked_coins: 121 | self.coins[coin_name] = CoinInfo(name=coin_name) 122 | 123 | for spot_coin in spot_coins: 124 | if coin_name == "BTC" and spot_coin["name"] == "UBTC": 125 | self.coins[coin_name].spot = SpotMarket( 126 | name=spot_coin["name"], 127 | token_id=spot_coin["tokenId"], 128 | index=spot_coin["index"], 129 | sz_decimals=spot_coin["szDecimals"], 130 | wei_decimals=spot_coin["weiDecimals"], 131 | is_canonical=spot_coin["isCanonical"], 132 | full_name=spot_coin["fullName"], 133 | evm_contract=spot_coin.get("evmContract"), 134 | deployer_trading_fee_share=spot_coin["deployerTradingFeeShare"], 135 | tick_size=1 136 | ) 137 | elif coin_name == "ETH" and spot_coin["name"] == "UETH": 138 | self.coins[coin_name].spot = SpotMarket( 139 | name=spot_coin["name"], 140 | token_id=spot_coin["tokenId"], 141 | index=spot_coin["index"], 142 | sz_decimals=spot_coin["szDecimals"], 143 | wei_decimals=spot_coin["weiDecimals"], 144 | is_canonical=spot_coin["isCanonical"], 145 | full_name=spot_coin["fullName"], 146 | evm_contract=spot_coin.get("evmContract"), 147 | deployer_trading_fee_share=spot_coin["deployerTradingFeeShare"], 148 | tick_size=0.1 149 | ) 150 | elif coin_name == spot_coin["name"]: 151 | self.coins[coin_name].spot = SpotMarket( 152 | name=spot_coin["name"], 153 | token_id=spot_coin["tokenId"], 154 | index=spot_coin["index"], 155 | sz_decimals=spot_coin["szDecimals"], 156 | wei_decimals=spot_coin["weiDecimals"], 157 | is_canonical=spot_coin["isCanonical"], 158 | full_name=spot_coin["fullName"], 159 | evm_contract=spot_coin.get("evmContract"), 160 | deployer_trading_fee_share=spot_coin["deployerTradingFeeShare"], 161 | tick_size=0.001 162 | ) 163 | 164 | for perp_coin in perp_coins: 165 | if perp_coin["name"] == coin_name: 166 | self.coins[coin_name].perp = PerpMarket( 167 | name=perp_coin["name"], 168 | sz_decimals=perp_coin["szDecimals"], 169 | max_leverage=perp_coin["maxLeverage"], 170 | index=perp_coins.index(perp_coin), 171 | tick_size=self.coins[coin_name].spot.tick_size 172 | ) 173 | 174 | self.total_raw_usd = float(self.margin_summary["totalRawUsd"]) 175 | self.account_value = float(self.margin_summary["accountValue"]) 176 | self.total_margin_used = float(self.margin_summary["totalMarginUsed"]) 177 | 178 | # Initialize allocation targets from config 179 | self.spot_allocation_pct = self.config["allocation"]["spot_pct"] / 100.0 180 | self.perp_allocation_pct = self.config["allocation"]["perp_pct"] / 100.0 181 | self.rebalance_threshold = self.config["allocation"]["rebalance_threshold"] 182 | 183 | # Refresh interval 184 | self.refresh_interval_sec = self.config["trading"].get("refresh_interval_sec", 60) 185 | 186 | # Load positions 187 | for position in self.user_state.get("assetPositions", []): 188 | if position["type"] == "oneWay" and "position" in position: 189 | pos = position["position"] 190 | coin_name = pos["coin"] 191 | if coin_name in self.coins and self.coins[coin_name].perp: 192 | self.coins[coin_name].perp.position = { 193 | "size": float(pos["szi"]), 194 | "entry_price": float(pos["entryPx"]), 195 | "position_value": float(pos["positionValue"]), 196 | "unrealized_pnl": float(pos["unrealizedPnl"]), 197 | "leverage": pos["leverage"]["value"], 198 | "liquidation_price": float(pos["liquidationPx"]), 199 | "cum_funding": pos["cumFunding"]["allTime"] 200 | } 201 | 202 | for balance in self.spot_user_state.get("balances", []): 203 | if float(balance["total"]) > 0: 204 | coin_name = balance["coin"] 205 | 206 | if coin_name == "UBTC": 207 | coin_name = "BTC" 208 | elif coin_name == "UETH": 209 | coin_name = "ETH" 210 | 211 | if coin_name in self.coins and self.coins[coin_name].spot: 212 | self.coins[coin_name].spot.position = { 213 | "total": float(balance["total"]), 214 | "hold": float(balance["hold"]), 215 | "entry_ntl": float(balance["entryNtl"]) 216 | } 217 | 218 | logger.info(f"Initialized with account: {self.address[:8]}...") 219 | logger.info(f"Total account value: ${self.account_value}") 220 | except Exception as e: 221 | logger.error(f"Failed to initialize clients: {e}") 222 | raise RuntimeError("Client initialization failed") from e 223 | 224 | def _get_required_env(self, env_name): 225 | """Get a required environment variable or raise an informative error.""" 226 | value = os.getenv(env_name) 227 | if not value or value == "your_private_key_here" or value == "your_eth_address_here": 228 | raise ValueError(f"{env_name} environment variable not set or has default placeholder value") 229 | return value 230 | 231 | def _load_config(self): 232 | """Load configuration from config.json""" 233 | try: 234 | with open(self.config_path, 'r') as f: 235 | config = json.load(f) 236 | logger.info(f"Loaded configuration from {self.config_path}") 237 | return config 238 | except FileNotFoundError: 239 | logger.error(f"Configuration file {self.config_path} not found") 240 | raise 241 | except json.JSONDecodeError: 242 | logger.error(f"Error parsing JSON in {self.config_path}") 243 | raise 244 | except Exception as e: 245 | logger.error(f"Unexpected error loading config: {e}") 246 | raise 247 | 248 | def _get_spot_account_USDC(self): 249 | spot_user_state = self.info.spot_user_state(self.address) 250 | for balance in spot_user_state["balances"]: 251 | if balance["coin"] == "USDC": 252 | return float(balance["total"]) 253 | return 0 254 | 255 | def _get_spot_price(self, coin_name): 256 | mid_price = self.info.all_mids() 257 | for key, value in mid_price.items(): 258 | if key == coin_name: 259 | return float(value) 260 | return 0 261 | 262 | def _get_perp_price(self, coin_name): 263 | mid_price = self.info.all_mids() 264 | for key, value in mid_price.items(): 265 | if key == coin_name: 266 | return float(value) 267 | return 0 268 | 269 | def round_size(self, coin_name: str, is_spot: bool, size: float) -> float: 270 | if size <= 0: 271 | return 0 272 | if is_spot: 273 | return round(size, self.coins[coin_name].spot.sz_decimals) 274 | else: 275 | return round(size, self.coins[coin_name].perp.sz_decimals) 276 | 277 | def round_price(self, coin_name: str, price: float) -> float: 278 | if coin_name not in self.coins: 279 | return price 280 | 281 | tick_size = self.coins[coin_name].spot.tick_size 282 | if tick_size <= 0: 283 | return price 284 | 285 | return round(price / tick_size) * tick_size 286 | 287 | def _calculate_optimal_spot_size(self, coin_name): 288 | spot_price = self._get_spot_price(coin_name) 289 | USDC_balance = self._get_spot_account_USDC() 290 | 291 | # Use only up to 90% of available USDC to account for fees and price fluctuations 292 | available_usdc = USDC_balance * 0.9 293 | 294 | # Ensure we have sufficient USDC (at least $10 worth) 295 | if available_usdc < 10: 296 | logger.warning(f"Insufficient USDC balance for spot purchase: ${available_usdc:.2f}") 297 | return 0 298 | 299 | # Calculate size based on available USDC and current price 300 | size = available_usdc / spot_price 301 | 302 | # Set minimum size threshold (e.g. $10 worth) 303 | min_size_value = 10 / spot_price 304 | 305 | if size < min_size_value: 306 | logger.warning(f"Calculated spot size too small: {size} (min: {min_size_value})") 307 | return 0 308 | 309 | rounded_size = self.round_size(coin_name, True, size) 310 | 311 | # Log the calculation for debugging 312 | logger.info(f"Calculated optimal spot size for {coin_name}: {size} -> rounded to {rounded_size} (USDC: ${available_usdc:.2f}, price: ${spot_price:.2f})") 313 | 314 | return rounded_size 315 | 316 | def _calculate_optimal_perp_size(self, coin_name): 317 | # For delta-neutral, perp size should match spot size 318 | spot_size = self._calculate_optimal_spot_size(coin_name) 319 | 320 | # If spot size is zero, perp size should also be zero 321 | if spot_size <= 0: 322 | return 0 323 | 324 | # Round perp size according to the coin's perp sz_decimals 325 | rounded_size = self.round_size(coin_name, False, spot_size) 326 | 327 | # Log the calculation for debugging 328 | logger.info(f"Calculated optimal perp size for {coin_name}: {spot_size} -> rounded to {rounded_size}") 329 | 330 | return rounded_size 331 | 332 | def _get_total_spot_account_value(self): 333 | total_spot_value = self._get_spot_account_USDC() 334 | for coin_name, coin_info in self.coins.items(): 335 | if coin_info.spot and hasattr(coin_info.spot, 'position') and coin_info.spot.position and "total" in coin_info.spot.position: 336 | total_spot_value += coin_info.spot.position["total"] * self._get_spot_price(coin_name) 337 | return total_spot_value 338 | 339 | def _get_spot_account_value(self): 340 | spot_user_state = self.info.spot_user_state(self.address) 341 | for balance in spot_user_state["balances"]: 342 | print(balance) 343 | 344 | def spot_perp_repartition(self): 345 | spot_value = self._get_total_spot_account_value() 346 | perp_value = self.perp_user_state 347 | return spot_value / (spot_value + perp_value) 348 | 349 | def has_delta_neutral_position(self, coin_name, error_margin=0.05): 350 | if coin_name not in self.coins: 351 | logger.warning(f"Coin {coin_name} not found in tracked coins") 352 | return False, 0, 0, 0 353 | 354 | coin_info = self.coins[coin_name] 355 | 356 | if not (coin_info.perp and coin_info.spot): 357 | logger.debug(f"{coin_name} doesn't have both perp and spot markets") 358 | return False, 0, 0, 0 359 | 360 | perp_size = 0 361 | if coin_info.perp.position: 362 | perp_size = coin_info.perp.position.get("size", 0) 363 | 364 | spot_size = 0 365 | if coin_info.spot.position: 366 | spot_size = coin_info.spot.position.get("total", 0) 367 | 368 | if perp_size == 0 or spot_size == 0: 369 | return False, perp_size, spot_size, 0 370 | 371 | is_proper_direction = perp_size < 0 and spot_size > 0 372 | 373 | abs_perp_size = abs(perp_size) 374 | size_diff = abs(abs_perp_size - spot_size) 375 | 376 | larger_size = max(abs_perp_size, spot_size) 377 | diff_percentage = (size_diff / larger_size) * 100 if larger_size > 0 else 0 378 | 379 | is_within_margin = diff_percentage <= (error_margin * 100) 380 | 381 | is_delta_neutral = is_proper_direction and is_within_margin 382 | 383 | return is_delta_neutral, perp_size, spot_size, diff_percentage 384 | 385 | def get_best_yearly_funding_rate(self): 386 | best_rate = 0 387 | best_coin = None 388 | for coin_name, coin_info in self.coins.items(): 389 | if coin_info.perp and coin_info.perp.yearly_funding_rate: 390 | if coin_info.perp.yearly_funding_rate > best_rate: 391 | best_rate = coin_info.perp.yearly_funding_rate 392 | best_coin = coin_name 393 | return best_coin 394 | 395 | def _extract_and_track_order_ids(self, pending_order, spot_order_result, perp_order_result, coin_name, operation_type=""): 396 | """Helper method to extract order IDs from order responses and track their status. 397 | 398 | Args: 399 | pending_order: The PendingDeltaOrder object to update 400 | spot_order_result: The result from the spot order API call 401 | perp_order_result: The result from the perp order API call 402 | coin_name: Name of the coin 403 | operation_type: Type of operation (opening/closing) for logging 404 | 405 | Returns: 406 | bool: True if tracking started or orders filled, False otherwise 407 | """ 408 | # Extract order IDs from spot order response 409 | if spot_order_result and spot_order_result.get('status') == 'ok': 410 | spot_response = spot_order_result.get('response', {}).get('data', {}).get('statuses', [{}])[0] 411 | if 'filled' in spot_response: 412 | pending_order.spot_filled = True 413 | pending_order.spot_oid = int(spot_response['filled']['oid']) 414 | logger.info(f"Spot {operation_type} order for {coin_name} filled immediately with oid: {pending_order.spot_oid}") 415 | elif 'resting' in spot_response: 416 | pending_order.spot_oid = int(spot_response['resting']['oid']) 417 | logger.info(f"Spot {operation_type} order for {coin_name} resting with oid: {pending_order.spot_oid}") 418 | 419 | # Extract order IDs from perp order response 420 | if perp_order_result and perp_order_result.get('status') == 'ok': 421 | perp_response = perp_order_result.get('response', {}).get('data', {}).get('statuses', [{}])[0] 422 | if 'filled' in perp_response: 423 | pending_order.perp_filled = True 424 | pending_order.perp_oid = int(perp_response['filled']['oid']) 425 | logger.info(f"Perp {operation_type} order for {coin_name} filled immediately with oid: {pending_order.perp_oid}") 426 | elif 'resting' in perp_response: 427 | pending_order.perp_oid = int(perp_response['resting']['oid']) 428 | logger.info(f"Perp {operation_type} order for {coin_name} resting with oid: {pending_order.perp_oid}") 429 | 430 | # Determine if we need to track these orders or if they're already complete 431 | if (pending_order.spot_oid or pending_order.perp_oid) and (not pending_order.spot_filled or not pending_order.perp_filled): 432 | self.pending_orders.append(pending_order) 433 | logger.info(f"Added pending {operation_type} position for {coin_name} to tracking") 434 | return True 435 | elif pending_order.spot_filled and pending_order.perp_filled: 436 | # If both orders filled immediately, we don't need to track 437 | logger.info(f"Both {operation_type} orders for {coin_name} filled immediately") 438 | return True 439 | else: 440 | logger.warning(f"Failed to create or track {operation_type} orders for {coin_name}") 441 | return False 442 | 443 | async def create_delta_position(self, coin_name): 444 | if coin_name not in self.coins: 445 | logger.warning(f"Coin {coin_name} not found in tracked coins") 446 | return False 447 | 448 | coin_info = self.coins[coin_name] 449 | 450 | if not (coin_info.perp and coin_info.spot): 451 | logger.warning(f"{coin_name} doesn't have both perp and spot markets") 452 | return False 453 | 454 | # Check if we already have a delta-neutral position for this coin 455 | is_delta_neutral, perp_size, spot_size, _ = self.has_delta_neutral_position(coin_name) 456 | if is_delta_neutral: 457 | logger.info(f"Already have a delta-neutral position for {coin_name} - perp: {perp_size}, spot: {spot_size}") 458 | return True 459 | 460 | try: 461 | price = self._get_spot_price(coin_name) 462 | if price <= 0: 463 | logger.error(f"Invalid price for {coin_name}: {price}") 464 | return False 465 | 466 | # Get optimal sizes for spot and perp 467 | spot_size = self._calculate_optimal_spot_size(coin_name) 468 | if spot_size <= 0: 469 | logger.error(f"Calculated spot size for {coin_name} is not positive: {spot_size}") 470 | return False 471 | 472 | perp_size = spot_size # For delta-neutral, perp size equals spot size 473 | 474 | # Validate the sizes after rounding 475 | if spot_size <= 0 or perp_size <= 0: 476 | logger.error(f"Invalid position size after rounding for {coin_name}: spot={spot_size}, perp={perp_size}") 477 | return False 478 | 479 | # Calculate minimum size based on $10 value 480 | min_size_value = 10 / price 481 | if spot_size < min_size_value: 482 | logger.warning(f"Calculated position size for {coin_name} is too small: {spot_size} < {min_size_value}") 483 | logger.warning(f"Current price: ${price}, minimum position value: $10") 484 | return False 485 | 486 | # Ensure we have enough USDC for this purchase 487 | required_usdc = spot_size * price 488 | available_usdc = self._get_spot_account_USDC() 489 | if required_usdc > available_usdc * 0.95: # Leave 5% buffer 490 | logger.warning(f"Insufficient USDC for {coin_name} position: need ${required_usdc:.2f}, have ${available_usdc:.2f}") 491 | return False 492 | 493 | tick_size = coin_info.spot.tick_size 494 | spot_limit_price = self.round_price(coin_name, price + tick_size) 495 | perp_limit_price = self.round_price(coin_name, price - tick_size) 496 | 497 | # Create a new pending order to track 498 | pending_order = PendingDeltaOrder(coin_name=coin_name, is_closing_position=False) 499 | spot_order_result = None 500 | perp_order_result = None 501 | 502 | logger.info(f"Creating spot buy limit order for {coin_name}: {spot_size} @ {spot_limit_price}") 503 | if coin_name == "BTC": 504 | spot_name = "UBTC" 505 | elif coin_name == "ETH": 506 | spot_name = "UETH" 507 | else: 508 | spot_name = coin_name 509 | 510 | spot_pair = f"{spot_name}/USDC" 511 | 512 | spot_order_result = self.exchange.order(spot_pair, True, spot_size, spot_limit_price, {"limit": {"tif": "Gtc"}}) 513 | logger.info(f"Spot order result: {spot_order_result}") 514 | 515 | logger.info(f"Creating perp short limit order for {coin_name}: {-perp_size} @ {perp_limit_price}") 516 | perp_order_result = self.exchange.order(coin_name, False, perp_size, perp_limit_price, {"limit": {"tif": "Gtc"}}) 517 | logger.info(f"Perp order result: {perp_order_result}") 518 | 519 | # Use the shared helper method to track orders 520 | return self._extract_and_track_order_ids( 521 | pending_order, 522 | spot_order_result, 523 | perp_order_result, 524 | coin_name, 525 | "opening" 526 | ) 527 | 528 | except Exception as e: 529 | logger.error(f"Error creating delta-neutral position for {coin_name}: {e}") 530 | return False 531 | 532 | async def check_pending_orders(self): 533 | """Check the status of all pending orders and handle accordingly.""" 534 | if not self.pending_orders: 535 | return 536 | 537 | current_time = time.time() 538 | orders_to_remove = [] 539 | 540 | for pending_order in self.pending_orders: 541 | # Skip if checked recently (less than 30 seconds ago) 542 | if current_time - pending_order.last_check_time < 30: 543 | continue 544 | 545 | pending_order.last_check_time = current_time 546 | 547 | operation_type = "closing" if pending_order.is_closing_position else "opening" 548 | logger.info(f"Checking pending {operation_type} delta position for {pending_order.coin_name}") 549 | 550 | # Check if both orders are already filled 551 | if pending_order.spot_filled and pending_order.perp_filled: 552 | logger.info(f"Both {operation_type} orders for {pending_order.coin_name} are filled, removing from pending") 553 | orders_to_remove.append(pending_order) 554 | continue 555 | 556 | # Check if we've waited too long 557 | if current_time - pending_order.creation_time > pending_order.max_wait_time: 558 | logger.warning(f"{operation_type.capitalize()} orders for {pending_order.coin_name} have been pending for too long, cancelling") 559 | 560 | if not pending_order.spot_filled and pending_order.spot_oid: 561 | try: 562 | if pending_order.coin_name == "BTC": 563 | spot_name = "UBTC" 564 | elif pending_order.coin_name == "ETH": 565 | spot_name = "UETH" 566 | else: 567 | spot_name = pending_order.coin_name 568 | 569 | spot_pair = f"{spot_name}/USDC" 570 | cancel_result = self.exchange.cancel(spot_pair, pending_order.spot_oid) 571 | logger.info(f"Cancelled spot {operation_type} order for {pending_order.coin_name}: {cancel_result}") 572 | except Exception as e: 573 | logger.error(f"Error cancelling spot {operation_type} order for {pending_order.coin_name}: {e}") 574 | 575 | if not pending_order.perp_filled and pending_order.perp_oid: 576 | try: 577 | cancel_result = self.exchange.cancel(pending_order.coin_name, pending_order.perp_oid) 578 | logger.info(f"Cancelled perp {operation_type} order for {pending_order.coin_name}: {cancel_result}") 579 | except Exception as e: 580 | logger.error(f"Error cancelling perp {operation_type} order for {pending_order.coin_name}: {e}") 581 | 582 | orders_to_remove.append(pending_order) 583 | 584 | # Only try to recreate a delta position if we were opening, not closing 585 | if not pending_order.is_closing_position: 586 | logger.info(f"Recreating delta position for {pending_order.coin_name}") 587 | await self.create_delta_position(pending_order.coin_name) 588 | else: 589 | logger.info(f"Not attempting to recreate closing position for {pending_order.coin_name}") 590 | continue 591 | 592 | # Check current order status from exchange 593 | try: 594 | # Check spot order status if not already filled 595 | if not pending_order.spot_filled and pending_order.spot_oid: 596 | if pending_order.coin_name == "BTC": 597 | spot_name = "UBTC" 598 | elif pending_order.coin_name == "ETH": 599 | spot_name = "UETH" 600 | else: 601 | spot_name = pending_order.coin_name 602 | 603 | # Check if spot order is filled by checking order status 604 | spot_order_response = self.exchange.info.query_order_by_oid(self.address, pending_order.spot_oid) 605 | logger.debug(f"Spot order status response: {spot_order_response}") 606 | 607 | # Check if the order is still open based on the status field 608 | if spot_order_response.get('status') == 'order': 609 | order_data = spot_order_response.get('order', {}) 610 | if order_data.get('status') != 'open': 611 | # Order is not open anymore, assume filled 612 | logger.info(f"Spot {operation_type} order for {pending_order.coin_name} is no longer open, marking as filled") 613 | pending_order.spot_filled = True 614 | else: 615 | # If the response doesn't contain the order, it might have been filled 616 | logger.info(f"Spot {operation_type} order for {pending_order.coin_name} not found, marking as filled") 617 | pending_order.spot_filled = True 618 | 619 | # Check perp order status if not already filled 620 | if not pending_order.perp_filled and pending_order.perp_oid: 621 | # Check if perp order is filled by checking order status 622 | perp_order_response = self.exchange.info.query_order_by_oid(self.address, pending_order.perp_oid) 623 | logger.debug(f"Perp order status response: {perp_order_response}") 624 | 625 | # Check if the order is still open based on the status field 626 | if perp_order_response.get('status') == 'order': 627 | order_data = perp_order_response.get('order', {}) 628 | if order_data.get('status') != 'open': 629 | # Order is not open anymore, assume filled 630 | logger.info(f"Perp {operation_type} order for {pending_order.coin_name} is no longer open, marking as filled") 631 | pending_order.perp_filled = True 632 | else: 633 | # If the response doesn't contain the order, it might have been filled 634 | logger.info(f"Perp {operation_type} order for {pending_order.coin_name} not found, marking as filled") 635 | pending_order.perp_filled = True 636 | 637 | # If both are now filled, remove from pending 638 | if pending_order.spot_filled and pending_order.perp_filled: 639 | logger.info(f"Both {operation_type} orders for {pending_order.coin_name} are now filled, removing from pending") 640 | orders_to_remove.append(pending_order) 641 | 642 | except Exception as e: 643 | logger.error(f"Error checking {operation_type} order status for {pending_order.coin_name}: {e}") 644 | 645 | # Remove processed orders 646 | for order in orders_to_remove: 647 | self.pending_orders.remove(order) 648 | 649 | def close_delta_position(self, coin_name): 650 | if coin_name not in self.coins: 651 | logger.warning(f"Coin {coin_name} not found in tracked coins") 652 | return False 653 | 654 | coin_info = self.coins[coin_name] 655 | 656 | if not (coin_info.perp and coin_info.spot): 657 | logger.warning(f"{coin_name} doesn't have both perp and spot markets") 658 | return False 659 | 660 | try: 661 | # Check if we have positions to close 662 | is_delta_neutral, perp_size, spot_size, _ = self.has_delta_neutral_position(coin_name) 663 | 664 | if not is_delta_neutral: 665 | logger.warning(f"No delta-neutral position for {coin_name} to close") 666 | return False 667 | 668 | price = self._get_spot_price(coin_name) 669 | if price <= 0: 670 | logger.error(f"Invalid price for {coin_name}: {price}") 671 | return False 672 | 673 | # For closing, we reverse the orders: 674 | # - Sell the spot position 675 | # - Buy back (cover) the short perp position 676 | 677 | tick_size = coin_info.spot.tick_size 678 | spot_limit_price = self.round_price(coin_name, price - tick_size) # Sell slightly below market 679 | perp_limit_price = self.round_price(coin_name, price + tick_size) # Buy slightly above market 680 | 681 | # Create a new pending order to track 682 | pending_order = PendingDeltaOrder(coin_name=coin_name, is_closing_position=True) 683 | spot_order_result = None 684 | perp_order_result = None 685 | 686 | # For spot, we need to sell what we have 687 | if spot_size > 0: 688 | # Get actual available balance (total minus any amount on hold) 689 | available_spot_size = spot_size 690 | if coin_info.spot.position and "hold" in coin_info.spot.position: 691 | available_spot_size = spot_size - coin_info.spot.position["hold"] 692 | 693 | # Ensure positive size and proper rounding 694 | if available_spot_size <= 0: 695 | logger.warning(f"No available balance for {coin_name} - total: {spot_size}, hold: {coin_info.spot.position.get('hold', 0)}") 696 | return False 697 | 698 | # Round to the proper number of decimals for this spot market 699 | rounded_spot_size = self.round_size(coin_name, True, available_spot_size) 700 | 701 | logger.info(f"Creating spot sell limit order for {coin_name}: {rounded_spot_size} @ {spot_limit_price} (from available: {available_spot_size})") 702 | 703 | if coin_name == "BTC": 704 | spot_name = "UBTC" 705 | elif coin_name == "ETH": 706 | spot_name = "UETH" 707 | else: 708 | spot_name = coin_name 709 | 710 | spot_pair = f"{spot_name}/USDC" 711 | 712 | # For sell orders, side is False (sell) 713 | spot_order_result = self.exchange.order(spot_pair, False, rounded_spot_size, spot_limit_price, {"limit": {"tif": "Gtc"}}) 714 | logger.info(f"Spot sell order result: {spot_order_result}") 715 | 716 | # For perp, we need to buy back our short position 717 | if perp_size < 0: 718 | # Convert negative size to positive for buy order 719 | buy_size = abs(perp_size) 720 | logger.info(f"Creating perp buy limit order to close short for {coin_name}: {buy_size} @ {perp_limit_price}") 721 | 722 | # For buy orders, side is True (buy) 723 | perp_order_result = self.exchange.order(coin_name, True, buy_size, perp_limit_price, {"limit": {"tif": "Gtc"}}) 724 | logger.info(f"Perp buy order result: {perp_order_result}") 725 | 726 | # Use the shared helper method to track orders 727 | return self._extract_and_track_order_ids( 728 | pending_order, 729 | spot_order_result, 730 | perp_order_result, 731 | coin_name, 732 | "closing" 733 | ) 734 | 735 | except Exception as e: 736 | logger.error(f"Error closing delta-neutral position for {coin_name}: {e}") 737 | return False 738 | 739 | async def close_all_delta_positions(self): 740 | """Close all active delta-neutral positions across all tracked coins.""" 741 | logger.info("Attempting to close all delta-neutral positions...") 742 | 743 | closed_positions = 0 744 | for coin_name in self.tracked_coins: 745 | if coin_name == "USDC": 746 | continue 747 | 748 | is_delta_neutral, _, _, _ = self.has_delta_neutral_position(coin_name) 749 | if is_delta_neutral: 750 | logger.info(f"Closing delta-neutral position for {coin_name}...") 751 | result = self.close_delta_position(coin_name) 752 | if result: 753 | logger.info(f"Successfully closed delta-neutral position for {coin_name}") 754 | closed_positions += 1 755 | else: 756 | logger.warning(f"Failed to close delta-neutral position for {coin_name}") 757 | 758 | if closed_positions > 0: 759 | logger.info(f"Successfully closed {closed_positions} delta-neutral positions") 760 | else: 761 | logger.info("No delta-neutral positions were closed") 762 | 763 | return closed_positions > 0 764 | 765 | async def exit_program(self, close_positions=True): 766 | """Exit the program and optionally close all positions.""" 767 | logger.info("Exiting program...") 768 | 769 | # Set running state to False 770 | self._is_running = False 771 | 772 | if close_positions: 773 | logger.info("Closing all positions...") 774 | await self.close_all_delta_positions() 775 | 776 | logger.info("Exited") 777 | return True 778 | 779 | async def execute_best_delta_strategy(self): 780 | best_coin = self.get_best_yearly_funding_rate() 781 | if not best_coin: 782 | logger.warning("No coin with positive funding rate found") 783 | return False 784 | 785 | is_delta_neutral, _, _, _ = self.has_delta_neutral_position(best_coin) 786 | if is_delta_neutral: 787 | logger.info(f"Already have delta-neutral position for {best_coin}") 788 | return False 789 | 790 | logger.info(f"Creating delta-neutral position for {best_coin} with best funding rate: {self.coins[best_coin].perp.yearly_funding_rate:.4f}%") 791 | return await self.create_delta_position(best_coin) 792 | 793 | def check_allocation(self): 794 | ratio = self.spot_perp_repartition() 795 | lower_bound = self.spot_allocation_pct - self.rebalance_threshold 796 | upper_bound = self.spot_allocation_pct + self.rebalance_threshold 797 | 798 | if ratio < lower_bound: 799 | spot_value = self._get_total_spot_account_value() 800 | perp_value = self.perp_user_state 801 | amount_to_transfer = (perp_value * self.spot_allocation_pct - spot_value * self.perp_allocation_pct) / 1.0 802 | logger.info(f"Allocation mismatch: {ratio:.2f} (target: {self.spot_allocation_pct:.2f})") 803 | logger.info(f"Recommended transfer from perp to spot: ${amount_to_transfer:.2f}") 804 | return False 805 | elif ratio > upper_bound: 806 | spot_value = self._get_total_spot_account_value() 807 | perp_value = self.perp_user_state 808 | amount_to_transfer = (spot_value * self.perp_allocation_pct - perp_value * self.spot_allocation_pct) / 1.0 809 | logger.info(f"Allocation mismatch: {ratio:.2f} (target: {self.spot_allocation_pct:.2f})") 810 | logger.info(f"Recommended transfer from spot to perp: ${amount_to_transfer:.2f}") 811 | return False 812 | return True 813 | 814 | def display_position_info(self): 815 | """Display detailed information about tracked coins and positions.""" 816 | logger.info(f"\n{Colors.BOLD}Tracked Coins Information:{Colors.RESET}") 817 | for coin_name, coin_info in self.coins.items(): 818 | if coin_name == "USDC": 819 | continue 820 | 821 | logger.info(f"\n{Colors.BOLD}{Colors.YELLOW}{coin_name} Markets:{Colors.RESET}") 822 | 823 | is_delta_neutral, perp_size, spot_size, diff_percentage = self.has_delta_neutral_position(coin_name) 824 | 825 | status_color = Colors.GREEN if is_delta_neutral else Colors.RED 826 | status_text = "✅ DELTA NEUTRAL" if is_delta_neutral else "❌ NOT DELTA NEUTRAL" 827 | logger.info(f" Delta Status: {status_color}{status_text}{Colors.RESET}") 828 | 829 | if perp_size != 0 or spot_size != 0: 830 | logger.info(f" Perp Size: {Colors.BLUE}{perp_size:.4f}{Colors.RESET}") 831 | logger.info(f" Spot Size: {Colors.GREEN}{spot_size:.4f}{Colors.RESET}") 832 | diff_color = Colors.GREEN if diff_percentage < 5 else Colors.YELLOW if diff_percentage < 10 else Colors.RED 833 | logger.info(f" Difference: {diff_color}{diff_percentage:.2f}%{Colors.RESET}") 834 | 835 | if coin_info.perp: 836 | logger.info(f" {Colors.BOLD}Perpetual Market:{Colors.RESET}") 837 | logger.info(f" Index: {coin_info.perp.index}") 838 | logger.info(f" Size Decimals: {coin_info.perp.sz_decimals}") 839 | logger.info(f" Max Leverage: {coin_info.perp.max_leverage}x") 840 | logger.info(f" Tick Size: {coin_info.perp.tick_size}") 841 | 842 | if coin_info.perp.funding_rate is not None: 843 | logger.info(f" Current Funding Rate: {Colors.GREEN}{coin_info.perp.funding_rate:.8f}{Colors.RESET}") 844 | 845 | # Color funding rate based on value 846 | rate_color = Colors.RED 847 | if coin_info.perp.yearly_funding_rate >= 20: 848 | rate_color = Colors.GREEN + Colors.BOLD 849 | elif coin_info.perp.yearly_funding_rate >= 10: 850 | rate_color = Colors.GREEN 851 | elif coin_info.perp.yearly_funding_rate >= 5: 852 | rate_color = Colors.YELLOW 853 | 854 | logger.info(f" Yearly Funding Rate: {rate_color}{coin_info.perp.yearly_funding_rate:.4f}%{Colors.RESET}") 855 | 856 | if coin_info.perp.position: 857 | pos = coin_info.perp.position 858 | logger.info(f" Position: {Colors.BLUE}{pos['size']:.4f}{Colors.RESET} @ ${Colors.YELLOW}{pos['entry_price']:.2f}{Colors.RESET}") 859 | logger.info(f" Position Value: ${Colors.GREEN}{pos['position_value']:.2f}{Colors.RESET}") 860 | 861 | # Color PnL based on profit/loss 862 | pnl_color = Colors.GREEN if pos['unrealized_pnl'] > 0 else Colors.RED 863 | logger.info(f" Unrealized PnL: {pnl_color}${pos['unrealized_pnl']:.2f}{Colors.RESET}") 864 | 865 | logger.info(f" Leverage: {Colors.YELLOW}{pos['leverage']}x{Colors.RESET}") 866 | logger.info(f" Liquidation Price: ${Colors.RED}{pos['liquidation_price']:.2f}{Colors.RESET}") 867 | logger.info(f" Cumulative Funding: {pos['cum_funding']}") 868 | else: 869 | logger.info(f" Position: {Colors.RED}None{Colors.RESET}") 870 | 871 | if coin_info.spot: 872 | logger.info(f" {Colors.BOLD}Spot Market:{Colors.RESET}") 873 | logger.info(f" Name: {coin_info.spot.name} ({coin_info.spot.full_name})") 874 | logger.info(f" Token ID: {coin_info.spot.token_id}") 875 | logger.info(f" Index: {coin_info.spot.index}") 876 | logger.info(f" Size Decimals: {coin_info.spot.sz_decimals}") 877 | logger.info(f" Wei Decimals: {coin_info.spot.wei_decimals}") 878 | logger.info(f" Tick Size: {coin_info.spot.tick_size}") 879 | if coin_info.spot.position: 880 | pos = coin_info.spot.position 881 | logger.info(f" Balance: {Colors.GREEN}{pos['total']:.4f}{Colors.RESET}") 882 | logger.info(f" On Hold: {Colors.YELLOW}{pos['hold']:.4f}{Colors.RESET}") 883 | logger.info(f" Entry Value: ${Colors.GREEN}{pos['entry_ntl']:.2f}{Colors.RESET}") 884 | else: 885 | logger.info(f" Position: {Colors.RED}None{Colors.RESET}") 886 | 887 | ratio = self.spot_perp_repartition() 888 | ratio_color = Colors.GREEN if 0.665 <= ratio <= 0.735 else Colors.YELLOW if 0.6 <= ratio <= 0.8 else Colors.RED 889 | logger.info(f"Spot Perp Repartition: {ratio_color}{ratio:.4f}{Colors.RESET} (target: {Colors.GREEN}0.7{Colors.RESET})") 890 | 891 | allocation_ok = self.check_allocation() 892 | if allocation_ok == False: 893 | logger.info(f"{Colors.RED}Portfolio allocation is not within target ratio (70% spot / 30% perp){Colors.RESET}") 894 | 895 | # Show the best funding rate coin (but don't try to create a position here) 896 | best_coin = self.get_best_yearly_funding_rate() 897 | if best_coin: 898 | rate_color = Colors.RED 899 | rate = self.coins[best_coin].perp.yearly_funding_rate 900 | if rate >= 20: 901 | rate_color = Colors.GREEN + Colors.BOLD 902 | elif rate >= 10: 903 | rate_color = Colors.GREEN 904 | elif rate >= 5: 905 | rate_color = Colors.YELLOW 906 | 907 | logger.info(f"Best funding rate coin: {Colors.YELLOW}{best_coin}{Colors.RESET} with rate {rate_color}{rate:.4f}%{Colors.RESET}") 908 | 909 | async def check_hourly_funding_rates(self): 910 | """ 911 | Checks funding rates at 10 minutes before each hour. 912 | If current annual yield < 5%, find a better delta position. 913 | """ 914 | try: 915 | # Get current time 916 | now = time.localtime() 917 | 918 | # Only run this function at 10 minutes before the hour (e.g., 8:50, 9:50, etc.) 919 | if now.tm_min != 50: 920 | return 921 | 922 | logger.info(f"\n{Colors.BOLD}Running scheduled check for better funding rates (10 minutes before the hour){Colors.RESET}") 923 | 924 | # Get current funding rates 925 | from test_market_data import check_funding_rates, calculate_yearly_funding_rates 926 | 927 | funding_rates = await check_funding_rates() 928 | yearly_rates = calculate_yearly_funding_rates(funding_rates, self.tracked_coins) 929 | 930 | # Update funding rates in our coin data structure 931 | for coin_name, rate in funding_rates.items(): 932 | if coin_name in self.coins and self.coins[coin_name].perp: 933 | self.coins[coin_name].perp.funding_rate = float(rate) 934 | 935 | for coin_name, rate in yearly_rates.items(): 936 | if coin_name in self.coins and self.coins[coin_name].perp: 937 | self.coins[coin_name].perp.yearly_funding_rate = rate 938 | rate_color = Colors.RED 939 | if rate >= 20: 940 | rate_color = Colors.GREEN + Colors.BOLD 941 | elif rate >= 10: 942 | rate_color = Colors.GREEN 943 | elif rate >= 5: 944 | rate_color = Colors.YELLOW 945 | logger.info(f"Updated {Colors.YELLOW}{coin_name}{Colors.RESET} yearly funding rate: {rate_color}{rate:.4f}%{Colors.RESET}") 946 | 947 | # Refresh user state to get latest positions 948 | try: 949 | self.user_state = self.info.user_state(self.address) 950 | self.spot_user_state = self.info.spot_user_state(self.address) 951 | 952 | # Update perp positions 953 | for position in self.user_state.get("assetPositions", []): 954 | if position["type"] == "oneWay" and "position" in position: 955 | pos = position["position"] 956 | coin_name = pos["coin"] 957 | if coin_name in self.coins and self.coins[coin_name].perp: 958 | self.coins[coin_name].perp.position = { 959 | "size": float(pos["szi"]), 960 | "entry_price": float(pos["entryPx"]), 961 | "position_value": float(pos["positionValue"]), 962 | "unrealized_pnl": float(pos["unrealizedPnl"]), 963 | "leverage": pos["leverage"]["value"], 964 | "liquidation_price": float(pos["liquidationPx"]), 965 | "cum_funding": pos["cumFunding"]["allTime"] 966 | } 967 | 968 | # Update spot positions 969 | for balance in self.spot_user_state.get("balances", []): 970 | if float(balance["total"]) > 0: 971 | coin_name = balance["coin"] 972 | 973 | if coin_name == "UBTC": 974 | coin_name = "BTC" 975 | elif coin_name == "UETH": 976 | coin_name = "ETH" 977 | 978 | if coin_name in self.coins and self.coins[coin_name].spot: 979 | self.coins[coin_name].spot.position = { 980 | "total": float(balance["total"]), 981 | "hold": float(balance["hold"]), 982 | "entry_ntl": float(balance["entryNtl"]) 983 | } 984 | 985 | logger.info(f"{Colors.GREEN}Successfully refreshed position data{Colors.RESET}") 986 | 987 | # Display detailed position information in hourly check 988 | self.display_position_info() 989 | 990 | except Exception as e: 991 | logger.error(f"{Colors.RED}Error refreshing position data: {e}{Colors.RESET}") 992 | 993 | # Find current active delta neutral position 994 | current_position_coin = None 995 | for coin_name in self.tracked_coins: 996 | if coin_name == "USDC": 997 | continue 998 | 999 | is_delta_neutral, perp_size, spot_size, _ = self.has_delta_neutral_position(coin_name) 1000 | if is_delta_neutral: 1001 | current_position_coin = coin_name 1002 | logger.info(f"{Colors.GREEN}Found active delta-neutral position on {Colors.YELLOW}{coin_name}{Colors.GREEN} with perp size {Colors.BLUE}{perp_size}{Colors.GREEN} and spot size {Colors.GREEN}{spot_size}{Colors.RESET}") 1003 | break 1004 | 1005 | if not current_position_coin: 1006 | logger.info(f"{Colors.YELLOW}No active delta-neutral position found.{Colors.RESET}") 1007 | # Find the best coin and create a new position if its rate is >= 5% 1008 | best_coin = self.get_best_yearly_funding_rate() 1009 | if best_coin and self.coins[best_coin].perp.yearly_funding_rate >= 5.0: 1010 | logger.info(f"{Colors.GREEN}Creating new delta-neutral position for {Colors.YELLOW}{best_coin}{Colors.GREEN} with rate {Colors.GREEN}{self.coins[best_coin].perp.yearly_funding_rate:.4f}%{Colors.RESET}") 1011 | await self.create_delta_position(best_coin) 1012 | else: 1013 | logger.info(f"{Colors.YELLOW}No coin with funding rate >= 5% found. Waiting until next check.{Colors.RESET}") 1014 | return 1015 | 1016 | # Check if current position has yield < 5% 1017 | current_yield = self.coins[current_position_coin].perp.yearly_funding_rate 1018 | rate_color = Colors.RED 1019 | if current_yield >= 20: 1020 | rate_color = Colors.GREEN + Colors.BOLD 1021 | elif current_yield >= 10: 1022 | rate_color = Colors.GREEN 1023 | elif current_yield >= 5: 1024 | rate_color = Colors.YELLOW 1025 | 1026 | logger.info(f"Current delta-neutral position: {Colors.YELLOW}{current_position_coin}{Colors.RESET} with yield: {rate_color}{current_yield:.4f}%{Colors.RESET}") 1027 | 1028 | if current_yield is None or current_yield < 5.0: 1029 | logger.info(f"{Colors.YELLOW}Current yield for {current_position_coin} is below 5% (or None). Looking for better options...{Colors.RESET}") 1030 | 1031 | # Find coin with highest funding rate 1032 | best_coin = self.get_best_yearly_funding_rate() 1033 | 1034 | if not best_coin or (best_coin and self.coins[best_coin].perp.yearly_funding_rate < 5.0): 1035 | logger.info(f"{Colors.YELLOW}No coin with funding rate >= 5% found. Keeping current position for now.{Colors.RESET}") 1036 | return 1037 | 1038 | # Make sure the best coin is different from current coin and has better rate 1039 | if best_coin == current_position_coin: 1040 | logger.info(f"{Colors.YELLOW}{current_position_coin} still has the best funding rate but it's below 5%.{Colors.RESET}") 1041 | return 1042 | 1043 | best_rate = self.coins[best_coin].perp.yearly_funding_rate 1044 | best_rate_color = Colors.RED 1045 | if best_rate >= 20: 1046 | best_rate_color = Colors.GREEN + Colors.BOLD 1047 | elif best_rate >= 10: 1048 | best_rate_color = Colors.GREEN 1049 | elif best_rate >= 5: 1050 | best_rate_color = Colors.YELLOW 1051 | 1052 | logger.info(f"{Colors.GREEN}Found better coin: {Colors.YELLOW}{best_coin}{Colors.GREEN} with yield: {best_rate_color}{best_rate:.4f}%{Colors.RESET}") 1053 | 1054 | # Close current position and open new one 1055 | logger.info(f"{Colors.YELLOW}Closing current position on {current_position_coin}...{Colors.RESET}") 1056 | close_result = self.close_delta_position(current_position_coin) 1057 | 1058 | if close_result: 1059 | logger.info(f"{Colors.GREEN}Successfully initiated closing of position on {current_position_coin}{Colors.RESET}") 1060 | # Wait for closing orders to be processed 1061 | close_pending = True 1062 | max_wait = 180 # 3 minutes max wait 1063 | start_time = time.time() 1064 | 1065 | while close_pending and time.time() - start_time < max_wait: 1066 | logger.info(f"{Colors.YELLOW}Waiting for closing orders to complete...{Colors.RESET}") 1067 | # Check pending orders 1068 | await self.check_pending_orders() 1069 | 1070 | # Check if any pending orders are for the current coin and are closing 1071 | close_pending = any(order.coin_name == current_position_coin and order.is_closing_position for order in self.pending_orders) 1072 | 1073 | if close_pending: 1074 | await asyncio.sleep(10) # Wait 10 seconds before checking again 1075 | 1076 | if close_pending: 1077 | logger.warning(f"{Colors.YELLOW}Closing position on {current_position_coin} is taking too long. Will continue with opening new position.{Colors.RESET}") 1078 | 1079 | logger.info(f"{Colors.GREEN}Creating new delta-neutral position for {best_coin}...{Colors.RESET}") 1080 | create_result = await self.create_delta_position(best_coin) 1081 | 1082 | if create_result: 1083 | logger.info(f"{Colors.GREEN}Successfully initiated new delta-neutral position on {best_coin}{Colors.RESET}") 1084 | else: 1085 | logger.error(f"{Colors.RED}Failed to create new delta-neutral position on {best_coin}{Colors.RESET}") 1086 | else: 1087 | logger.error(f"{Colors.RED}Failed to close position on {current_position_coin}{Colors.RESET}") 1088 | else: 1089 | logger.info(f"{Colors.GREEN}Current yield for {current_position_coin} is above 5%. No action needed.{Colors.RESET}") 1090 | 1091 | except Exception as e: 1092 | logger.error(f"{Colors.RED}Error checking hourly funding rates: {e}{Colors.RESET}", exc_info=True) 1093 | 1094 | async def start(self): 1095 | """Start the bot's execution loop.""" 1096 | # Check if bot should autostart 1097 | if not self.config["general"].get("autostart", True): 1098 | logger.info("Autostart disabled in config. Call start() manually to begin.") 1099 | return 1100 | 1101 | if self._is_running: 1102 | logger.info("Bot is already running") 1103 | return 1104 | 1105 | self._is_running = True 1106 | logger.info("Starting Delta bot...") 1107 | 1108 | logger.info(f"{Colors.BOLD}Account Summary:{Colors.RESET}") 1109 | logger.info(f" Total Value: ${Colors.GREEN}{self.total_raw_usd:.2f}{Colors.RESET}") 1110 | logger.info(f" Account Value: ${Colors.GREEN}{self.account_value:.2f}{Colors.RESET}") 1111 | logger.info(f" Margin Used: ${Colors.YELLOW}{self.total_margin_used:.2f}{Colors.RESET}") 1112 | logger.info(f" Perp Account Value: ${Colors.BLUE}{self.perp_user_state:.2f}{Colors.RESET}") 1113 | logger.info(f" Spot USDC Value: ${Colors.GREEN}{self._get_spot_account_USDC():.2f}{Colors.RESET}") 1114 | logger.info(f" Spot Account Value: ${Colors.BLUE}{self._get_total_spot_account_value():.2f}{Colors.RESET}") 1115 | 1116 | from test_market_data import check_funding_rates, calculate_yearly_funding_rates 1117 | 1118 | funding_rates = await check_funding_rates() 1119 | yearly_rates = calculate_yearly_funding_rates(funding_rates, self.tracked_coins) 1120 | 1121 | for coin_name, rate in funding_rates.items(): 1122 | if coin_name in self.coins and self.coins[coin_name].perp: 1123 | self.coins[coin_name].perp.funding_rate = float(rate) 1124 | 1125 | for coin_name, rate in yearly_rates.items(): 1126 | if coin_name in self.coins and self.coins[coin_name].perp: 1127 | self.coins[coin_name].perp.yearly_funding_rate = rate 1128 | 1129 | self.display_position_info() 1130 | 1131 | allocation_ok = self.check_allocation() 1132 | if allocation_ok == False: 1133 | logger.info(f"{Colors.RED}Portfolio allocation is not within target ratio (70% spot / 30% perp){Colors.RESET}") 1134 | 1135 | # Check if we should create a new delta-neutral position 1136 | best_coin = self.get_best_yearly_funding_rate() 1137 | if best_coin: 1138 | rate = self.coins[best_coin].perp.yearly_funding_rate 1139 | rate_color = Colors.RED 1140 | if rate >= 20: 1141 | rate_color = Colors.GREEN + Colors.BOLD 1142 | elif rate >= 10: 1143 | rate_color = Colors.GREEN 1144 | elif rate >= 5: 1145 | rate_color = Colors.YELLOW 1146 | 1147 | logger.info(f"{Colors.YELLOW}Best funding rate coin for new position: {Colors.YELLOW}{best_coin} with rate {rate_color}{rate:.4f}%{Colors.RESET}") 1148 | 1149 | # First check if we have any existing delta-neutral positions we need to close 1150 | existing_positions_found = False 1151 | for coin_name in self.tracked_coins: 1152 | if coin_name == "USDC" or coin_name == best_coin: 1153 | continue 1154 | 1155 | is_delta_neutral, perp_size, spot_size, _ = self.has_delta_neutral_position(coin_name) 1156 | if is_delta_neutral: 1157 | existing_positions_found = True 1158 | logger.info(f"{Colors.YELLOW}Found existing delta-neutral position on {Colors.BLUE}{coin_name}{Colors.YELLOW}, closing before creating new position{Colors.RESET}") 1159 | close_result = self.close_delta_position(coin_name) 1160 | if close_result: 1161 | logger.info(f"{Colors.GREEN}Successfully initiated closing of existing position on {Colors.BLUE}{coin_name}{Colors.RESET}") 1162 | else: 1163 | logger.warning(f"{Colors.RED}Failed to close existing position on {Colors.BLUE}{coin_name}{Colors.RESET}") 1164 | logger.warning(f"{Colors.RED}Skipping creation of new position until existing positions are closed{Colors.RESET}") 1165 | break 1166 | 1167 | # Check if best coin already has a position 1168 | is_delta_neutral, perp_size, spot_size, _ = self.has_delta_neutral_position(best_coin) 1169 | 1170 | # Only proceed if we don't have existing positions or if best coin already has a position 1171 | if (not existing_positions_found or is_delta_neutral) and rate >= 5.0: 1172 | if not is_delta_neutral: 1173 | logger.info(f"{Colors.GREEN}Creating delta-neutral position for {Colors.YELLOW}{best_coin}...{Colors.RESET}") 1174 | result = await self.execute_best_delta_strategy() 1175 | if result: 1176 | logger.info(f"{Colors.GREEN}Successfully created delta-neutral position for {Colors.YELLOW}{best_coin}{Colors.RESET}") 1177 | else: 1178 | logger.warning(f"{Colors.RED}Failed to create delta-neutral position for {Colors.YELLOW}{best_coin}{Colors.RESET}") 1179 | else: 1180 | logger.info(f"{Colors.GREEN}Already have a delta-neutral position for {Colors.YELLOW}{best_coin}{Colors.RESET}") 1181 | elif is_delta_neutral: 1182 | logger.info(f"{Colors.GREEN}Already have a delta-neutral position for {Colors.YELLOW}{best_coin}{Colors.RESET}") 1183 | else: 1184 | logger.info(f"{Colors.YELLOW}Best funding rate ({rate:.4f}%) is below 5% threshold, not creating position{Colors.RESET}") 1185 | 1186 | # Main loop 1187 | while True: 1188 | try: 1189 | # Check pending orders 1190 | await self.check_pending_orders() 1191 | 1192 | # Check hourly funding rates (runs only at HH:50) 1193 | await self.check_hourly_funding_rates() 1194 | 1195 | # Add other periodic tasks here 1196 | 1197 | # Sleep for a bit - use config refresh interval 1198 | await asyncio.sleep(self.refresh_interval_sec) 1199 | except KeyboardInterrupt: 1200 | logger.info(f"{Colors.YELLOW}Keyboard interrupt detected in main loop{Colors.RESET}") 1201 | break 1202 | except Exception as e: 1203 | logger.error(f"{Colors.RED}Error in main loop: {e}{Colors.RESET}", exc_info=True) 1204 | await asyncio.sleep(60) # Sleep longer on error 1205 | 1206 | 1207 | def setup_signal_handlers(delta_instance): 1208 | """Set up signal handlers for graceful shutdown.""" 1209 | import signal 1210 | import sys 1211 | 1212 | # Global flag to indicate shutdown is in progress 1213 | shutdown_in_progress = False 1214 | 1215 | def signal_handler(sig, frame): 1216 | nonlocal shutdown_in_progress 1217 | 1218 | if shutdown_in_progress: 1219 | logger.info("Forced exit requested. Exiting immediately.") 1220 | sys.exit(1) 1221 | 1222 | shutdown_in_progress = True 1223 | logger.info(f"Received signal {sig}, shutting down...") 1224 | logger.info("Press Ctrl+C again to force immediate exit") 1225 | 1226 | # Don't exit here, just set the flag for the main loop to check 1227 | # The main loop handles the graceful shutdown 1228 | # This causes KeyboardInterrupt to be raised in the main event loop 1229 | raise KeyboardInterrupt() 1230 | 1231 | signal.signal(signal.SIGINT, signal_handler) # Handle Ctrl+C 1232 | signal.signal(signal.SIGTERM, signal_handler) # Handle termination signal 1233 | 1234 | logger.info("Signal handlers set up for graceful shutdown") 1235 | 1236 | 1237 | async def main(): 1238 | delta = None 1239 | try: 1240 | delta = Delta("config.json") 1241 | setup_signal_handlers(delta) 1242 | await delta.start() 1243 | except KeyboardInterrupt: 1244 | logger.info("Keyboard interrupt detected, closing positions...") 1245 | if delta: 1246 | try: 1247 | # Make sure to close positions before exiting 1248 | logger.info("Attempting to close all positions...") 1249 | await delta.close_all_delta_positions() 1250 | logger.info("Position closing complete") 1251 | await delta.exit_program(close_positions=False) # Already closed positions above 1252 | except Exception as e: 1253 | logger.error(f"Error during shutdown: {e}", exc_info=True) 1254 | except Exception as e: 1255 | logger.error(f"Error running Delta: {e}", exc_info=True) 1256 | if delta: 1257 | try: 1258 | await delta.exit_program(close_positions=True) 1259 | except Exception as shutdown_e: 1260 | logger.error(f"Error during shutdown: {shutdown_e}", exc_info=True) 1261 | 1262 | 1263 | if __name__ == "__main__": 1264 | try: 1265 | asyncio.run(main()) 1266 | except KeyboardInterrupt: 1267 | # This will catch the KeyboardInterrupt at the top level after signal handling 1268 | logger.info("Exiting due to keyboard interrupt") 1269 | except SystemExit: 1270 | # Handle the SystemExit exception from sys.exit() in the signal handler 1271 | pass 1272 | except Exception as e: 1273 | logger.error(f"Fatal error: {e}", exc_info=True) 1274 | --------------------------------------------------------------------------------