├── .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 | }
78 | >
79 | Add
80 |
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 | 
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 | [](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 | 
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 | [](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 |
--------------------------------------------------------------------------------