Trae Assistant commited on
Commit
fe49021
·
0 Parent(s):

Enhance Quant Grid Master: Robustness, UI, and HF Deployment

Browse files
Files changed (5) hide show
  1. Dockerfile +24 -0
  2. README.md +64 -0
  3. app.py +269 -0
  4. requirements.txt +2 -0
  5. templates/index.html +527 -0
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ # Create a non-root user
4
+ RUN useradd -m -u 1000 user
5
+
6
+ WORKDIR /app
7
+
8
+ # Copy requirements first to leverage cache
9
+ COPY requirements.txt /app/requirements.txt
10
+
11
+ # Install dependencies
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Copy the rest of the application
15
+ COPY --chown=user:user . /app
16
+
17
+ # Switch to the non-root user
18
+ USER user
19
+
20
+ # Expose the port
21
+ EXPOSE 7860
22
+
23
+ # Run with Gunicorn for production-grade performance
24
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Quant Grid Master
3
+ emoji: 📈
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: 量化网格交易策略模拟器
9
+ ---
10
+
11
+ # Quant Grid Master (量化网格交易大师)
12
+
13
+ ## 项目简介 (Project Introduction)
14
+ **Quant Grid Master** 是一个专业的网格交易策略模拟与回测工具。专为量化交易爱好者和加密货币/股票投资者设计,帮助用户在投入真金白银之前,通过模拟数据验证网格策略的有效性。
15
+
16
+ 本项目具备高度的交互性和可视化能力,支持自定义行情生成和实时策略回测。
17
+
18
+ ## 核心功能 (Key Features)
19
+
20
+ 1. **动态行情模拟 (Market Simulation)**
21
+ * 基于几何布朗运动 (GBM) 生成逼真的价格走势。
22
+ * 支持自定义初始价格、波动率 (Volatility)、趋势 (Trend) 和时间跨度。
23
+
24
+ 2. **网格策略回测 (Grid Strategy Backtest)**
25
+ * 支持经典等差网格策略。
26
+ * 自定义参数:价格区间 (Upper/Lower Price)、网格数量 (Grid Num)、投入资金 (Investment)。
27
+ * 智能计算:自动模拟买入/卖出操作,计算网格套利利润 (Arbitrage Profit) 和浮动盈亏 (Unrealized P&L)。
28
+
29
+ 3. **专业可视化 (Professional Visualization)**
30
+ * **价格走势图**: 集成买卖点标记 (Buy/Sell Markers) 和网格线展示。
31
+ * **收益曲线**: 实时展示总资产变化与套利利润积累过程。
32
+ * **暗色极客主题**: 采用 Tailwind CSS 设计的 Tech/Dark 风格界面,沉浸式体验。
33
+
34
+ ## 技术栈 (Tech Stack)
35
+ * **Backend**: Python Flask (轻量级计算引擎)
36
+ * **Frontend**: Vue.js 3 + Tailwind CSS (响应式暗黑UI)
37
+ * **Charts**: Apache ECharts (高性能金融图表)
38
+ * **Deployment**: Docker (一键部署)
39
+
40
+ ## 快速开始 (Quick Start)
41
+
42
+ ### 1. 本地运行 (Local Run)
43
+ ```bash
44
+ # 进入项目目录
45
+ cd quant-grid-master
46
+
47
+ # 构建 Docker 镜像
48
+ docker build -t quant-grid-master .
49
+
50
+ # 运行容器
51
+ docker run -p 7860:7860 quant-grid-master
52
+ ```
53
+ 访问浏览器: `http://localhost:7860`
54
+
55
+ ### 2. Hugging Face Spaces 部署
56
+ 本项目已配置 `Dockerfile`,可直接上传至 Hugging Face Spaces (选择 Docker SDK)。
57
+
58
+ ## 商业价值 (Commercial Value)
59
+ * **教育培训**: 帮助新手理解“低吸高抛”的网格原理。
60
+ * **策略验证**: 在高波动率市场(如 Crypto)中寻找最佳网格参数。
61
+ * **流量入口**: 可作为交易所或量化社群的引流工具。
62
+
63
+ ## 免责声明 (Disclaimer)
64
+ 本工具仅供模拟和学习使用,不构成任何投资建议。市场有风险,投资需谨慎。
app.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import random
4
+ import math
5
+ import io
6
+ from datetime import datetime, timedelta
7
+ from flask import Flask, render_template, request, jsonify, send_file
8
+ from werkzeug.utils import secure_filename
9
+ from werkzeug.exceptions import RequestEntityTooLarge
10
+
11
+ app = Flask(__name__)
12
+ app.secret_key = os.urandom(24)
13
+
14
+ # Robustness: Max content length for uploads
15
+ app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # 5MB limit
16
+ ALLOWED_EXTENSIONS = {'json'}
17
+
18
+ def allowed_file(filename):
19
+ return '.' in filename and \
20
+ filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
21
+
22
+ class GridStrategy:
23
+ def __init__(self, lower_price, upper_price, grid_num, investment, current_price):
24
+ self.lower_price = float(lower_price)
25
+ self.upper_price = float(upper_price)
26
+ self.grid_num = int(grid_num)
27
+ self.investment = float(investment)
28
+ self.initial_price = float(current_price)
29
+
30
+ # Calculate grid lines
31
+ self.grids = []
32
+ step = (self.upper_price - self.lower_price) / self.grid_num
33
+ for i in range(self.grid_num + 1):
34
+ self.grids.append(self.lower_price + i * step)
35
+
36
+ self.grid_step = step
37
+ self.cash = self.investment
38
+ self.holdings = 0.0
39
+ self.trades = []
40
+ self.total_arbitrage_profit = 0.0
41
+
42
+ # Initial Position Building
43
+ # Ideally, if price is in the middle, we should hold some assets to sell as price goes up
44
+ # Simple Logic: Buy assets worth 50% of investment if price is within range
45
+ # Better Logic: Calculate required holdings based on how many grids are ABOVE current price (to sell)
46
+
47
+ # Let's use a "Geometric" approach or simple "Equal Difference" approach
48
+ # For this demo, we assume we start with 50/50 split if within range
49
+ if self.lower_price < self.initial_price < self.upper_price:
50
+ buy_amount = self.investment / 2
51
+ self.cash -= buy_amount
52
+ self.holdings = buy_amount / self.initial_price
53
+ self.trades.append({
54
+ 'type': 'INIT_BUY',
55
+ 'price': self.initial_price,
56
+ 'amount': self.holdings,
57
+ 'time': 'Start'
58
+ })
59
+
60
+ # Track last grid index crossed
61
+ self.last_grid_index = self._get_grid_index(self.initial_price)
62
+
63
+ def _get_grid_index(self, price):
64
+ if price <= self.lower_price:
65
+ return -1
66
+ if price >= self.upper_price:
67
+ return self.grid_num + 1
68
+ return int((price - self.lower_price) / self.grid_step)
69
+
70
+ def process_price(self, price, timestamp):
71
+ current_grid_index = self._get_grid_index(price)
72
+
73
+ # Price moved across grid lines
74
+ if current_grid_index != self.last_grid_index:
75
+ # Check direction and multiple grid crossings
76
+ diff = current_grid_index - self.last_grid_index
77
+
78
+ # Simple handling: Just process the boundary crossing closest to current price
79
+ # In a real engine, we'd handle gaps. Here we assume granular data or just take the new state.
80
+
81
+ # Logic:
82
+ # If index increases (price goes up): We crossed a line upwards -> SELL
83
+ # If index decreases (price goes down): We crossed a line downwards -> BUY
84
+
85
+ # Amount per grid: Total Investment / Grid Num (Simplified)
86
+ amount_per_grid_usdt = self.investment / self.grid_num
87
+
88
+ action = None
89
+ trade_price = price
90
+ trade_amount = 0
91
+
92
+ if diff > 0:
93
+ # Price Up -> Sell
94
+ # Only sell if we have holdings and we are within valid grid range
95
+ if self.holdings > 0 and 0 <= self.last_grid_index < self.grid_num:
96
+ # Sell one grid's worth
97
+ amount_to_sell = amount_per_grid_usdt / price
98
+ if self.holdings >= amount_to_sell:
99
+ self.holdings -= amount_to_sell
100
+ self.cash += amount_to_sell * price
101
+ trade_amount = amount_to_sell
102
+ action = 'SELL'
103
+
104
+ # Calculate profit per grid (approximate)
105
+ # Profit = Buy Price (lower grid) vs Sell Price (this grid)
106
+ # Roughly = grid_step * amount
107
+ self.total_arbitrage_profit += self.grid_step * amount_to_sell
108
+
109
+ elif diff < 0:
110
+ # Price Down -> Buy
111
+ # Only buy if we have cash and within range
112
+ if self.cash > amount_per_grid_usdt and 0 <= current_grid_index < self.grid_num:
113
+ amount_to_buy = amount_per_grid_usdt / price
114
+ self.cash -= amount_to_buy * price
115
+ self.holdings += amount_to_buy
116
+ trade_amount = amount_to_buy
117
+ action = 'BUY'
118
+
119
+ if action:
120
+ self.trades.append({
121
+ 'type': action,
122
+ 'price': price,
123
+ 'amount': trade_amount,
124
+ 'time': timestamp,
125
+ 'profit': self.total_arbitrage_profit
126
+ })
127
+
128
+ self.last_grid_index = current_grid_index
129
+
130
+ # Calculate current status
131
+ total_assets_value = self.cash + (self.holdings * price)
132
+ unrealized_pnl = total_assets_value - self.investment
133
+
134
+ return {
135
+ 'price': price,
136
+ 'total_value': total_assets_value,
137
+ 'cash': self.cash,
138
+ 'holdings_value': self.holdings * price,
139
+ 'arbitrage_profit': self.total_arbitrage_profit,
140
+ 'unrealized_pnl': unrealized_pnl,
141
+ 'timestamp': timestamp
142
+ }
143
+
144
+ @app.route('/')
145
+ def index():
146
+ return render_template('index.html')
147
+
148
+ @app.route('/api/generate_data', methods=['POST'])
149
+ def generate_data():
150
+ try:
151
+ data = request.json
152
+ days = int(data.get('days', 30))
153
+ start_price = float(data.get('start_price', 1000))
154
+ volatility = float(data.get('volatility', 0.02)) # Daily volatility
155
+ trend = float(data.get('trend', 0.000)) # Daily trend
156
+
157
+ prices = []
158
+ current_price = start_price
159
+ start_date = datetime.now() - timedelta(days=days)
160
+
161
+ # Generate hourly data
162
+ hours = days * 24
163
+
164
+ for i in range(hours):
165
+ # Geometric Brownian Motion step
166
+ change = random.normalvariate(trend/24, volatility/math.sqrt(24))
167
+ current_price = current_price * (1 + change)
168
+ timestamp = (start_date + timedelta(hours=i)).strftime('%Y-%m-%d %H:%M')
169
+ prices.append({'time': timestamp, 'price': round(current_price, 2)})
170
+
171
+ return jsonify({'prices': prices})
172
+ except Exception as e:
173
+ app.logger.error(f"Generate data error: {str(e)}")
174
+ return jsonify({'error': str(e)}), 500
175
+
176
+ @app.route('/api/simulate', methods=['POST'])
177
+ def simulate():
178
+ try:
179
+ data = request.json
180
+ prices = data.get('prices', [])
181
+ params = data.get('params', {})
182
+
183
+ if not prices or not params:
184
+ return jsonify({'error': 'Missing data'}), 400
185
+
186
+ strategy = GridStrategy(
187
+ lower_price=params.get('lower_price'),
188
+ upper_price=params.get('upper_price'),
189
+ grid_num=params.get('grid_num'),
190
+ investment=params.get('investment'),
191
+ current_price=prices[0]['price']
192
+ )
193
+
194
+ results = []
195
+ trades = []
196
+
197
+ for p in prices:
198
+ step_result = strategy.process_price(p['price'], p['time'])
199
+ results.append(step_result)
200
+
201
+ return jsonify({
202
+ 'results': results,
203
+ 'trades': strategy.trades,
204
+ 'summary': {
205
+ 'final_value': results[-1]['total_value'],
206
+ 'total_profit': results[-1]['total_value'] - params.get('investment'),
207
+ 'arbitrage_profit': strategy.total_arbitrage_profit,
208
+ 'grid_yield': (strategy.total_arbitrage_profit / params.get('investment')) * 100
209
+ }
210
+ })
211
+ except Exception as e:
212
+ app.logger.error(f"Simulation error: {str(e)}")
213
+ return jsonify({'error': str(e)}), 500
214
+
215
+ @app.route('/api/upload_config', methods=['POST'])
216
+ def upload_config():
217
+ # Robust file upload handling
218
+ if 'file' not in request.files:
219
+ return jsonify({'error': 'No file part'}), 400
220
+
221
+ file = request.files['file']
222
+
223
+ if file.filename == '':
224
+ return jsonify({'error': 'No selected file'}), 400
225
+
226
+ if file and allowed_file(file.filename):
227
+ try:
228
+ # Read file content
229
+ content = file.read()
230
+
231
+ # Check for binary content (null bytes)
232
+ if b'\0' in content:
233
+ return jsonify({'error': 'Binary file detected'}), 400
234
+
235
+ # Parse JSON
236
+ try:
237
+ config = json.loads(content.decode('utf-8'))
238
+ return jsonify({'message': 'Config uploaded successfully', 'config': config})
239
+ except UnicodeDecodeError:
240
+ return jsonify({'error': 'File encoding not supported (must be UTF-8)'}), 400
241
+ except json.JSONDecodeError:
242
+ return jsonify({'error': 'Invalid JSON format'}), 400
243
+
244
+ except Exception as e:
245
+ return jsonify({'error': f"Upload failed: {str(e)}"}), 500
246
+ else:
247
+ return jsonify({'error': 'Invalid file type (only .json allowed)'}), 400
248
+
249
+ @app.errorhandler(404)
250
+ def page_not_found(e):
251
+ # Return a JSON response for API calls, or render a template
252
+ if request.path.startswith('/api/'):
253
+ return jsonify({'error': 'Not found'}), 404
254
+ return render_template('index.html'), 200 # Fallback to SPA entry or create a 404 page
255
+
256
+ @app.errorhandler(500)
257
+ def internal_server_error(e):
258
+ return jsonify({'error': 'Internal Server Error'}), 500
259
+
260
+ @app.errorhandler(RequestEntityTooLarge)
261
+ def handle_file_too_large(e):
262
+ return jsonify({'error': 'File too large (max 5MB)'}), 413
263
+
264
+ @app.route('/health')
265
+ def health():
266
+ return jsonify({"status": "healthy"})
267
+
268
+ if __name__ == '__main__':
269
+ app.run(host='0.0.0.0', port=7860)
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ flask
2
+ gunicorn
templates/index.html ADDED
@@ -0,0 +1,527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Quant Grid Master - 量化网格交易大师</title>
7
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
10
+ <script>
11
+ tailwind.config = {
12
+ darkMode: 'class',
13
+ theme: {
14
+ extend: {
15
+ colors: {
16
+ gray: {
17
+ 900: '#1a1b1e',
18
+ 800: '#25262b',
19
+ 700: '#2c2e33',
20
+ 600: '#373a40',
21
+ },
22
+ primary: '#4dabf7',
23
+ success: '#40c057',
24
+ danger: '#fa5252'
25
+ }
26
+ }
27
+ }
28
+ }
29
+ </script>
30
+ <style>
31
+ [v-cloak] { display: none; }
32
+ body { background-color: #1a1b1e; color: #c1c2c5; }
33
+ .chart-container { height: 400px; width: 100%; }
34
+ .input-dark {
35
+ background-color: #2c2e33;
36
+ border: 1px solid #373a40;
37
+ color: #fff;
38
+ }
39
+ .input-dark:focus {
40
+ border-color: #4dabf7;
41
+ outline: none;
42
+ ring: 2px solid #4dabf7;
43
+ }
44
+ /* Scrollbar styling */
45
+ ::-webkit-scrollbar {
46
+ width: 8px;
47
+ height: 8px;
48
+ }
49
+ ::-webkit-scrollbar-track {
50
+ background: #1a1b1e;
51
+ }
52
+ ::-webkit-scrollbar-thumb {
53
+ background: #373a40;
54
+ border-radius: 4px;
55
+ }
56
+ ::-webkit-scrollbar-thumb:hover {
57
+ background: #4dabf7;
58
+ }
59
+ </style>
60
+ </head>
61
+ <body class="h-screen overflow-hidden flex flex-col font-sans">
62
+ <div id="app" v-cloak class="flex-1 flex flex-col h-full">
63
+ <!-- Header -->
64
+ <header class="bg-gray-800 border-b border-gray-700 p-4 flex justify-between items-center shadow-md z-10">
65
+ <div class="flex items-center gap-3">
66
+ <div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-cyan-400 rounded-lg flex items-center justify-center font-bold text-white shadow-lg shadow-blue-500/30">Q</div>
67
+ <h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-cyan-300">Quant Grid Master</h1>
68
+ <span class="text-xs px-2 py-0.5 rounded bg-gray-700 text-gray-400 border border-gray-600">Beta</span>
69
+ </div>
70
+ <div class="flex gap-4 text-sm text-gray-400 items-center">
71
+ <span class="hidden md:inline">量化网格交易模拟系统</span>
72
+ <a href="https://huggingface.co/spaces/duqing026/quant-grid-master" target="_blank" class="hover:text-white transition">
73
+ <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path></svg>
74
+ </a>
75
+ </div>
76
+ </header>
77
+
78
+ <!-- Main Content -->
79
+ <main class="flex-1 flex overflow-hidden">
80
+ <!-- Sidebar / Controls -->
81
+ <aside class="w-80 bg-gray-800 border-r border-gray-700 overflow-y-auto p-4 flex flex-col gap-6 custom-scrollbar">
82
+
83
+ <!-- Import/Export Tools -->
84
+ <div class="grid grid-cols-2 gap-2">
85
+ <button @click="triggerFileInput" class="py-1.5 px-3 bg-gray-700 hover:bg-gray-600 text-gray-300 text-xs rounded border border-gray-600 transition flex items-center justify-center gap-1">
86
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
87
+ 导入配置
88
+ </button>
89
+ <input type="file" ref="fileInput" class="hidden" accept=".json" @change="uploadConfig">
90
+
91
+ <button @click="exportConfig" class="py-1.5 px-3 bg-gray-700 hover:bg-gray-600 text-gray-300 text-xs rounded border border-gray-600 transition flex items-center justify-center gap-1">
92
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
93
+ 导出配置
94
+ </button>
95
+ </div>
96
+
97
+ <!-- Market Data Gen -->
98
+ <div class="space-y-3">
99
+ <h2 class="text-sm font-semibold text-gray-300 uppercase tracking-wider flex items-center gap-2">
100
+ <span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
101
+ 1. 市场数据生成
102
+ </h2>
103
+ <div class="grid grid-cols-2 gap-3">
104
+ <div>
105
+ <label class="text-xs text-gray-500 block mb-1">初始价格 (Start)</label>
106
+ <input type="number" v-model.number="dataConfig.start_price" class="w-full px-3 py-2 rounded text-sm input-dark transition focus:ring-1">
107
+ </div>
108
+ <div>
109
+ <label class="text-xs text-gray-500 block mb-1">持续天数 (Days)</label>
110
+ <input type="number" v-model.number="dataConfig.days" class="w-full px-3 py-2 rounded text-sm input-dark transition focus:ring-1">
111
+ </div>
112
+ <div>
113
+ <label class="text-xs text-gray-500 block mb-1">波动率 (Volatility)</label>
114
+ <input type="number" step="0.01" v-model.number="dataConfig.volatility" class="w-full px-3 py-2 rounded text-sm input-dark transition focus:ring-1">
115
+ </div>
116
+ <div>
117
+ <label class="text-xs text-gray-500 block mb-1">趋势 (Trend)</label>
118
+ <input type="number" step="0.001" v-model.number="dataConfig.trend" class="w-full px-3 py-2 rounded text-sm input-dark transition focus:ring-1">
119
+ </div>
120
+ </div>
121
+ <button @click="generateData" :disabled="loading" class="w-full py-2 bg-gray-700 hover:bg-gray-600 text-white rounded text-sm transition font-medium flex justify-center items-center gap-2 border border-gray-600">
122
+ <span v-if="loading" class="animate-spin">⟳</span>
123
+ <span v-else>⚡</span>
124
+ 生成模拟行情
125
+ </button>
126
+ </div>
127
+
128
+ <!-- Strategy Config -->
129
+ <div class="space-y-3 pt-4 border-t border-gray-700">
130
+ <h2 class="text-sm font-semibold text-gray-300 uppercase tracking-wider flex items-center gap-2">
131
+ <span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
132
+ 2. 网格策略参数
133
+ </h2>
134
+
135
+ <div>
136
+ <label class="text-xs text-gray-500 block mb-1">投入金额 (Investment USDT)</label>
137
+ <div class="relative">
138
+ <span class="absolute left-3 top-2 text-gray-500 text-sm">$</span>
139
+ <input type="number" v-model.number="strategyConfig.investment" class="w-full pl-6 pr-3 py-2 rounded text-sm input-dark font-mono text-yellow-500 transition focus:ring-1">
140
+ </div>
141
+ </div>
142
+
143
+ <div class="grid grid-cols-2 gap-3">
144
+ <div>
145
+ <label class="text-xs text-gray-500 block mb-1">区间下限 (Low)</label>
146
+ <input type="number" v-model.number="strategyConfig.lower_price" class="w-full px-3 py-2 rounded text-sm input-dark transition focus:ring-1">
147
+ </div>
148
+ <div>
149
+ <label class="text-xs text-gray-500 block mb-1">区间上限 (High)</label>
150
+ <input type="number" v-model.number="strategyConfig.upper_price" class="w-full px-3 py-2 rounded text-sm input-dark transition focus:ring-1">
151
+ </div>
152
+ </div>
153
+
154
+ <div>
155
+ <label class="text-xs text-gray-500 block mb-1">网格数量 (Grids)</label>
156
+ <div class="flex items-center gap-2">
157
+ <input type="range" min="2" max="100" v-model.number="strategyConfig.grid_num" class="flex-1 accent-blue-500 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer">
158
+ <input type="number" v-model.number="strategyConfig.grid_num" class="w-16 px-2 py-1 rounded text-sm input-dark text-center">
159
+ </div>
160
+ </div>
161
+
162
+ <button @click="runSimulation" :disabled="!hasData || loading" :class="{'opacity-50 cursor-not-allowed': !hasData || loading}" class="w-full py-2.5 bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white rounded text-sm font-bold shadow-lg shadow-blue-900/50 transition transform active:scale-95 flex justify-center items-center gap-2">
163
+ <span v-if="loading && hasData" class="animate-spin">⟳</span>
164
+ 开始回测 (Run Backtest)
165
+ </button>
166
+ </div>
167
+
168
+ <!-- Results Summary -->
169
+ <div v-if="summary" class="mt-auto bg-gray-900/80 p-4 rounded-lg border border-gray-700 space-y-3 shadow-inner backdrop-blur-sm">
170
+ <div class="flex justify-between items-center pb-2 border-b border-gray-700">
171
+ <span class="text-gray-400 text-xs">总收益 (Total Profit)</span>
172
+ <span :class="summary.total_profit >= 0 ? 'text-success' : 'text-danger'" class="font-mono font-bold text-lg">
173
+ ${ summary.total_profit >= 0 ? '+' : '' }${ summary.total_profit.toFixed(2) }
174
+ </span>
175
+ </div>
176
+ <div class="flex justify-between items-center">
177
+ <span class="text-gray-400 text-xs">网格套利 (Arbitrage)</span>
178
+ <span class="text-success font-mono text-sm">
179
+ +${ summary.arbitrage_profit.toFixed(2) }
180
+ </span>
181
+ </div>
182
+ <div class="flex justify-between items-center">
183
+ <span class="text-gray-400 text-xs">预估年化 (APY)</span>
184
+ <span class="text-blue-400 font-mono text-sm">
185
+ ${ (summary.grid_yield * (365/dataConfig.days)).toFixed(2) }%
186
+ </span>
187
+ </div>
188
+ </div>
189
+ </aside>
190
+
191
+ <!-- Charts Area -->
192
+ <section class="flex-1 bg-gray-900 p-4 flex flex-col gap-4 overflow-hidden relative">
193
+ <!-- Main Chart -->
194
+ <div class="flex-1 bg-gray-800 rounded-lg border border-gray-700 p-4 flex flex-col relative shadow-lg">
195
+ <h3 class="text-sm font-semibold text-gray-400 mb-2 flex justify-between items-center">
196
+ <div class="flex items-center gap-2">
197
+ <span class="w-1 h-4 bg-blue-500 rounded-sm"></span>
198
+ <span>价格走势与买卖点 (Price Action)</span>
199
+ </div>
200
+ <span v-if="trades.length" class="text-xs bg-gray-700 px-2 py-0.5 rounded text-white font-mono">Trades: ${ trades.length }</span>
201
+ </h3>
202
+ <div id="priceChart" class="flex-1 w-full min-h-0"></div>
203
+ <div v-if="!hasData" class="absolute inset-0 flex flex-col items-center justify-center text-gray-600 pointer-events-none gap-2">
204
+ <svg class="w-12 h-12 opacity-20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"></path></svg>
205
+ <span class="text-sm opacity-50">请点击左侧"生成模拟行情"开始</span>
206
+ </div>
207
+ </div>
208
+
209
+ <!-- Profit Chart -->
210
+ <div class="h-1/3 bg-gray-800 rounded-lg border border-gray-700 p-4 flex flex-col shadow-lg">
211
+ <h3 class="text-sm font-semibold text-gray-400 mb-2 flex items-center gap-2">
212
+ <span class="w-1 h-4 bg-green-500 rounded-sm"></span>
213
+ <span>收益曲线 (Profit Curve)</span>
214
+ </h3>
215
+ <div id="profitChart" class="flex-1 w-full min-h-0"></div>
216
+ </div>
217
+ </section>
218
+ </main>
219
+
220
+ <footer class="bg-gray-800 border-t border-gray-700 p-2 text-center text-xs text-gray-500">
221
+ &copy; 2024 Quant Grid Master. Powered by Flask & Vue 3.
222
+ </footer>
223
+ </div>
224
+
225
+ <script>
226
+ const { createApp, ref, onMounted, nextTick } = Vue
227
+
228
+ createApp({
229
+ delimiters: ['${', '}'],
230
+ setup() {
231
+ const loading = ref(false)
232
+ const hasData = ref(false)
233
+ const prices = ref([])
234
+ const trades = ref([])
235
+ const summary = ref(null)
236
+ const fileInput = ref(null)
237
+
238
+ const dataConfig = ref({
239
+ start_price: 1000,
240
+ days: 30,
241
+ volatility: 0.03,
242
+ trend: 0.000
243
+ })
244
+
245
+ const strategyConfig = ref({
246
+ lower_price: 800,
247
+ upper_price: 1200,
248
+ grid_num: 20,
249
+ investment: 10000
250
+ })
251
+
252
+ let priceChartInstance = null
253
+ let profitChartInstance = null
254
+
255
+ const initCharts = () => {
256
+ priceChartInstance = echarts.init(document.getElementById('priceChart'), 'dark', { backgroundColor: 'transparent' })
257
+ profitChartInstance = echarts.init(document.getElementById('profitChart'), 'dark', { backgroundColor: 'transparent' })
258
+
259
+ window.addEventListener('resize', () => {
260
+ priceChartInstance && priceChartInstance.resize()
261
+ profitChartInstance && profitChartInstance.resize()
262
+ })
263
+ }
264
+
265
+ const generateData = async () => {
266
+ loading.value = true
267
+ try {
268
+ const res = await fetch('/api/generate_data', {
269
+ method: 'POST',
270
+ headers: {'Content-Type': 'application/json'},
271
+ body: JSON.stringify(dataConfig.value)
272
+ })
273
+ if (!res.ok) throw new Error(await res.text())
274
+
275
+ const data = await res.json()
276
+ prices.value = data.prices
277
+ hasData.value = true
278
+
279
+ // Auto-set reasonable grid range based on generated data
280
+ const priceValues = prices.value.map(p => p.price)
281
+ const minP = Math.min(...priceValues)
282
+ const maxP = Math.max(...priceValues)
283
+ strategyConfig.value.lower_price = Math.floor(minP * 0.95)
284
+ strategyConfig.value.upper_price = Math.ceil(maxP * 1.05)
285
+
286
+ renderPriceChart(prices.value)
287
+
288
+ // Clear previous results
289
+ summary.value = null
290
+ trades.value = []
291
+ profitChartInstance.clear()
292
+ } catch (e) {
293
+ console.error(e)
294
+ alert('Error generating data: ' + e.message)
295
+ } finally {
296
+ loading.value = false
297
+ }
298
+ }
299
+
300
+ const runSimulation = async () => {
301
+ if (!hasData.value) return
302
+ loading.value = true
303
+ try {
304
+ const res = await fetch('/api/simulate', {
305
+ method: 'POST',
306
+ headers: {'Content-Type': 'application/json'},
307
+ body: JSON.stringify({
308
+ prices: prices.value,
309
+ params: strategyConfig.value
310
+ })
311
+ })
312
+ if (!res.ok) throw new Error(await res.text())
313
+
314
+ const data = await res.json()
315
+ trades.value = data.trades
316
+ summary.value = data.summary
317
+
318
+ renderPriceChart(prices.value, data.trades, strategyConfig.value)
319
+ renderProfitChart(data.results)
320
+ } catch (e) {
321
+ console.error(e)
322
+ alert('Simulation failed: ' + e.message)
323
+ } finally {
324
+ loading.value = false
325
+ }
326
+ }
327
+
328
+ // File Upload Logic
329
+ const triggerFileInput = () => {
330
+ fileInput.value.click()
331
+ }
332
+
333
+ const uploadConfig = async (event) => {
334
+ const file = event.target.files[0]
335
+ if (!file) return
336
+
337
+ // Client-side validation
338
+ if (file.size > 5 * 1024 * 1024) {
339
+ alert('File too large (Max 5MB)')
340
+ event.target.value = ''
341
+ return
342
+ }
343
+
344
+ const formData = new FormData()
345
+ formData.append('file', file)
346
+
347
+ loading.value = true
348
+ try {
349
+ const res = await fetch('/api/upload_config', {
350
+ method: 'POST',
351
+ body: formData
352
+ })
353
+ const result = await res.json()
354
+
355
+ if (!res.ok) {
356
+ throw new Error(result.error || 'Upload failed')
357
+ }
358
+
359
+ // Apply config
360
+ if (result.config) {
361
+ if (result.config.dataConfig) dataConfig.value = { ...dataConfig.value, ...result.config.dataConfig }
362
+ if (result.config.strategyConfig) strategyConfig.value = { ...strategyConfig.value, ...result.config.strategyConfig }
363
+ alert('配置导入成功 (Config Imported)')
364
+ }
365
+ } catch (e) {
366
+ alert(e.message)
367
+ } finally {
368
+ loading.value = false
369
+ event.target.value = '' // Reset input
370
+ }
371
+ }
372
+
373
+ const exportConfig = () => {
374
+ const config = {
375
+ dataConfig: dataConfig.value,
376
+ strategyConfig: strategyConfig.value,
377
+ exportDate: new Date().toISOString()
378
+ }
379
+
380
+ const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' })
381
+ const url = URL.createObjectURL(blob)
382
+ const a = document.createElement('a')
383
+ a.href = url
384
+ a.download = 'quant_grid_config.json'
385
+ document.body.appendChild(a)
386
+ a.click()
387
+ document.body.removeChild(a)
388
+ URL.revokeObjectURL(url)
389
+ }
390
+
391
+ const renderPriceChart = (priceData, tradeData = [], config = null) => {
392
+ const dates = priceData.map(p => p.time)
393
+ const values = priceData.map(p => p.price)
394
+
395
+ const markPoints = []
396
+ tradeData.forEach(t => {
397
+ markPoints.push({
398
+ name: t.type,
399
+ coord: [t.time, t.price],
400
+ value: t.type,
401
+ itemStyle: {
402
+ color: t.type === 'BUY' ? '#40c057' : '#fa5252'
403
+ },
404
+ symbol: t.type === 'BUY' ? 'arrowUp' : 'arrowDown',
405
+ symbolSize: 10
406
+ })
407
+ })
408
+
409
+ const markLines = []
410
+ if (config) {
411
+ // Show Top/Bottom lines
412
+ markLines.push({ yAxis: config.lower_price, lineStyle: { color: '#fa5252', type: 'dashed' }, label: { formatter: 'Low' } })
413
+ markLines.push({ yAxis: config.upper_price, lineStyle: { color: '#fa5252', type: 'dashed' }, label: { formatter: 'High' } })
414
+
415
+ // Optional: Show internal grids if not too many
416
+ if (config.grid_num <= 30) {
417
+ const step = (config.upper_price - config.lower_price) / config.grid_num
418
+ for(let i=1; i<config.grid_num; i++) {
419
+ markLines.push({
420
+ yAxis: config.lower_price + i*step,
421
+ lineStyle: { color: '#373a40', width: 1, type: 'dotted' },
422
+ label: { show: false }
423
+ })
424
+ }
425
+ }
426
+ }
427
+
428
+ const option = {
429
+ backgroundColor: 'transparent',
430
+ tooltip: { trigger: 'axis' },
431
+ grid: { left: '50', right: '20', top: '30', bottom: '30' },
432
+ xAxis: {
433
+ type: 'category',
434
+ data: dates,
435
+ boundaryGap: false,
436
+ axisLine: { lineStyle: { color: '#373a40' } },
437
+ axisLabel: { color: '#9ca3af' }
438
+ },
439
+ yAxis: {
440
+ type: 'value',
441
+ scale: true,
442
+ splitLine: { show: false },
443
+ axisLine: { show: false },
444
+ axisLabel: { color: '#9ca3af' }
445
+ },
446
+ series: [{
447
+ name: 'Price',
448
+ type: 'line',
449
+ data: values,
450
+ smooth: true,
451
+ showSymbol: false,
452
+ lineStyle: { width: 2, color: '#4dabf7' },
453
+ areaStyle: {
454
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
455
+ { offset: 0, color: 'rgba(77, 171, 247, 0.3)' },
456
+ { offset: 1, color: 'rgba(77, 171, 247, 0)' }
457
+ ])
458
+ },
459
+ markPoint: { data: markPoints },
460
+ markLine: {
461
+ data: markLines,
462
+ symbol: ['none', 'none']
463
+ }
464
+ }],
465
+ dataZoom: [{ type: 'inside' }, { type: 'slider', bottom: 0, height: 20, borderColor: 'transparent', backgroundColor: '#25262b' }]
466
+ }
467
+ priceChartInstance.setOption(option)
468
+ }
469
+
470
+ const renderProfitChart = (results) => {
471
+ const dates = results.map(r => r.timestamp)
472
+ const totalValues = results.map(r => r.total_value)
473
+ const arbitrageProfits = results.map(r => r.arbitrage_profit)
474
+
475
+ const option = {
476
+ backgroundColor: 'transparent',
477
+ tooltip: { trigger: 'axis' },
478
+ legend: { data: ['Total Assets Value', 'Arbitrage Profit'], textStyle: { color: '#9ca3af' } },
479
+ grid: { left: '50', right: '20', top: '30', bottom: '30' },
480
+ xAxis: {
481
+ type: 'category',
482
+ data: dates,
483
+ boundaryGap: false,
484
+ axisLine: { lineStyle: { color: '#373a40' } },
485
+ axisLabel: { color: '#9ca3af' }
486
+ },
487
+ yAxis: {
488
+ type: 'value',
489
+ scale: true,
490
+ splitLine: { show: true, lineStyle: { color: '#373a40' } },
491
+ axisLabel: { color: '#9ca3af' }
492
+ },
493
+ series: [
494
+ {
495
+ name: 'Total Assets Value',
496
+ type: 'line',
497
+ data: totalValues,
498
+ showSymbol: false,
499
+ itemStyle: { color: '#fff' }
500
+ },
501
+ {
502
+ name: 'Arbitrage Profit',
503
+ type: 'line',
504
+ data: arbitrageProfits,
505
+ showSymbol: false,
506
+ areaStyle: { opacity: 0.3 },
507
+ itemStyle: { color: '#40c057' }
508
+ }
509
+ ]
510
+ }
511
+ profitChartInstance.setOption(option)
512
+ }
513
+
514
+ onMounted(() => {
515
+ initCharts()
516
+ })
517
+
518
+ return {
519
+ loading, hasData, dataConfig, strategyConfig,
520
+ generateData, runSimulation, summary, trades,
521
+ fileInput, triggerFileInput, uploadConfig, exportConfig
522
+ }
523
+ }
524
+ }).mount('#app')
525
+ </script>
526
+ </body>
527
+ </html>