Spaces:
Sleeping
Sleeping
Trae Assistant commited on
Commit ·
fe49021
0
Parent(s):
Enhance Quant Grid Master: Robustness, UI, and HF Deployment
Browse files- Dockerfile +24 -0
- README.md +64 -0
- app.py +269 -0
- requirements.txt +2 -0
- 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 |
+
© 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>
|