Spaces:
Sleeping
Sleeping
chuan
commited on
Commit
·
8e6a923
0
Parent(s):
Initial commit from Trae: Gradio Dashboard + Market Collector (Clean)
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .DS_Store +0 -0
- .gitignore +34 -0
- README.md +35 -0
- app.py +107 -0
- config.py +21 -0
- memory/user_global.md +62 -0
- requirements.txt +111 -0
- sitecustomize.py +27 -0
- 基础库/common_core/__init__.py +3 -0
- 基础库/common_core/backtest/__init__.py +4 -0
- 基础库/common_core/backtest/equity.py +360 -0
- 基础库/common_core/backtest/evaluate.py +97 -0
- 基础库/common_core/backtest/figure.py +230 -0
- 基础库/common_core/backtest/metrics.py +478 -0
- 基础库/common_core/backtest/rebalance.py +70 -0
- 基础库/common_core/backtest/signal.py +98 -0
- 基础库/common_core/backtest/simulator.py +204 -0
- 基础库/common_core/backtest/version.py +7 -0
- 基础库/common_core/backtest/进度条.py +232 -0
- 基础库/common_core/config/loader.py +74 -0
- 基础库/common_core/config/models.py +60 -0
- 基础库/common_core/exchange/__init__.py +2 -0
- 基础库/common_core/exchange/base_client.py +723 -0
- 基础库/common_core/exchange/binance_async.py +172 -0
- 基础库/common_core/exchange/standard_client.py +284 -0
- 基础库/common_core/pyproject.toml +25 -0
- 基础库/common_core/quant_unified_common_core.egg-info/PKG-INFO +15 -0
- 基础库/common_core/quant_unified_common_core.egg-info/SOURCES.txt +26 -0
- 基础库/common_core/quant_unified_common_core.egg-info/dependency_links.txt +1 -0
- 基础库/common_core/quant_unified_common_core.egg-info/requires.txt +7 -0
- 基础库/common_core/quant_unified_common_core.egg-info/top_level.txt +4 -0
- 基础库/common_core/risk_ctrl/liquidation.py +45 -0
- 基础库/common_core/risk_ctrl/test_liquidation.py +34 -0
- 基础库/common_core/tests/test_orderbook_replay.py +53 -0
- 基础库/common_core/utils/__init__.py +2 -0
- 基础库/common_core/utils/async_commons.py +30 -0
- 基础库/common_core/utils/commons.py +181 -0
- 基础库/common_core/utils/dingding.py +107 -0
- 基础库/common_core/utils/factor_hub.py +58 -0
- 基础库/common_core/utils/functions.py +71 -0
- 基础库/common_core/utils/orderbook_replay.py +92 -0
- 基础库/common_core/utils/path_kit.py +72 -0
- 基础库/通用选币回测框架/因子库/9_SharpeMomentum.py +33 -0
- 基础库/通用选币回测框架/因子库/ADX.py +49 -0
- 基础库/通用选币回测框架/因子库/AO.py +53 -0
- 基础库/通用选币回测框架/因子库/ATR.py +25 -0
- 基础库/通用选币回测框架/因子库/ActiveBuyRatio.py +23 -0
- 基础库/通用选币回测框架/因子库/AdxMinus.py +122 -0
- 基础库/通用选币回测框架/因子库/AdxPlus.py +112 -0
- 基础库/通用选币回测框架/因子库/AdxVolWaves.py +108 -0
.DS_Store
ADDED
|
Binary file (10.2 kB). View file
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Data and Logs
|
| 2 |
+
data/
|
| 3 |
+
cache/
|
| 4 |
+
系统日志/
|
| 5 |
+
.deps/
|
| 6 |
+
应用/
|
| 7 |
+
策略仓库/
|
| 8 |
+
4 号做市策略/
|
| 9 |
+
|
| 10 |
+
# Data files
|
| 11 |
+
*.h5
|
| 12 |
+
*.parquet
|
| 13 |
+
*.csv
|
| 14 |
+
*.db
|
| 15 |
+
*.sqlite
|
| 16 |
+
*.pkl
|
| 17 |
+
|
| 18 |
+
# Python
|
| 19 |
+
__pycache__/
|
| 20 |
+
*.pyc
|
| 21 |
+
*.pyo
|
| 22 |
+
*.pyd
|
| 23 |
+
.venv/
|
| 24 |
+
env/
|
| 25 |
+
venv/
|
| 26 |
+
.env
|
| 27 |
+
|
| 28 |
+
# IDEs
|
| 29 |
+
.vscode/
|
| 30 |
+
.idea/
|
| 31 |
+
.trae/
|
| 32 |
+
|
| 33 |
+
# Logs
|
| 34 |
+
*.log
|
README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 量化交易统一系统 (Quant Unified)
|
| 2 |
+
|
| 3 |
+
本仓库整合了所有量化交易相关的组件,包括管理应用、策略仓库、后端服务和基础库。
|
| 4 |
+
|
| 5 |
+
## 目录结构 (中文化重构版)
|
| 6 |
+
|
| 7 |
+
- **应用 (应用/)**: 各种管理与展示应用
|
| 8 |
+
- **qronos/**: 量化交易管理平台(Web 前端 + Python 后端)
|
| 9 |
+
- 提供策略管理、回测分析、实盘监控的网页界面。
|
| 10 |
+
- 启动方式:
|
| 11 |
+
- 后端: `cd 应用/qronos && source .venv/bin/activate && python -X utf8 main.py`
|
| 12 |
+
- 前端: `cd 应用/qronos && npm run dev`
|
| 13 |
+
|
| 14 |
+
- **策略仓库 (策略仓库/)**: 各种交易策略实现
|
| 15 |
+
- **二号网格策略/**: 网格交易策略实现,包含回测与实盘。
|
| 16 |
+
- 运行回测: `cd 策略仓库/二号网格策略 && python -X utf8 backtest.py`
|
| 17 |
+
- **一号择时策略/**: 包含选币和择时逻辑。
|
| 18 |
+
- **三号对冲策略/**: 双向对冲策略。
|
| 19 |
+
|
| 20 |
+
- **服务 (服务/)**: 核心实盘与回测服务
|
| 21 |
+
- **firm/**: 实盘交易核心服务,提供底层交易、行情和评估功能。
|
| 22 |
+
|
| 23 |
+
- **基础库 (基础库/)**: 项目通用的基础组件
|
| 24 |
+
- **common_core/**: 包含风控、配置加载、工具函数等核心模块。
|
| 25 |
+
|
| 26 |
+
- **测试用例 (测试用例/)**: 单元测试与集成测试脚本。
|
| 27 |
+
|
| 28 |
+
- **系统日志 (系统日志/)**: 统一存储各组件运行产生的日志。
|
| 29 |
+
|
| 30 |
+
## 开发指南
|
| 31 |
+
|
| 32 |
+
1. **环境准备**: 建议使用 Python 3.14+ 环境。
|
| 33 |
+
2. **导入机制**: 项目使用了 `sitecustomize.py` 钩子,允许直接从顶级目录导入,例如 `import common_core` 或 `from 策略仓库.二号网格策略 import ...`。
|
| 34 |
+
3. **编码规范**: 强制使用 UTF-8 编码。函数名、变量名推荐使用中文命名(符合“编程导师”教学规范)。
|
| 35 |
+
4. **单体仓库**: 本仓库采用 Monorepo 结构,请直接在根目录打开 IDE 进行开发。
|
app.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
Quant_Unified 监控面板 (Gradio 版)
|
| 4 |
+
这是一个运行在 Hugging Face Spaces 上的监控应用,它会:
|
| 5 |
+
1. 在后台启动数据采集服务。
|
| 6 |
+
2. 实时从 Supabase 获取并显示各个服务的运行状态。
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
import os
|
| 11 |
+
import subprocess
|
| 12 |
+
import time
|
| 13 |
+
import pandas as pd
|
| 14 |
+
from supabase import create_client
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
import threading
|
| 17 |
+
|
| 18 |
+
# ==========================================
|
| 19 |
+
# 1. 配置与初始化
|
| 20 |
+
# ==========================================
|
| 21 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
| 22 |
+
SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_ANON_KEY")
|
| 23 |
+
|
| 24 |
+
# 初始化 Supabase 客户端
|
| 25 |
+
supabase = None
|
| 26 |
+
if SUPABASE_URL and SUPABASE_KEY:
|
| 27 |
+
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 28 |
+
|
| 29 |
+
def 启动后台采集():
|
| 30 |
+
"""在独立进程中启动采集脚本"""
|
| 31 |
+
print("🚀 正在启动后台采集服务...")
|
| 32 |
+
script_path = os.path.join("服务", "数据采集", "启动采集.py")
|
| 33 |
+
# 设置环境变量,确保子进程能找到项目根目录
|
| 34 |
+
env = os.environ.copy()
|
| 35 |
+
env["PYTHONPATH"] = os.getcwd()
|
| 36 |
+
# 使用 sys.executable 确保使用相同的 Python 解释器
|
| 37 |
+
import sys
|
| 38 |
+
process = subprocess.Popen(
|
| 39 |
+
[sys.executable, script_path],
|
| 40 |
+
env=env,
|
| 41 |
+
stdout=subprocess.PIPE,
|
| 42 |
+
stderr=subprocess.STDOUT,
|
| 43 |
+
text=True,
|
| 44 |
+
bufsize=1,
|
| 45 |
+
universal_newlines=True
|
| 46 |
+
)
|
| 47 |
+
# 实时打印日志到终端(HF 容器日志可见)
|
| 48 |
+
for line in process.stdout:
|
| 49 |
+
print(f"[Collector] {line.strip()}")
|
| 50 |
+
|
| 51 |
+
# 启动后台线程
|
| 52 |
+
thread = threading.Thread(target=启动后台采集, daemon=True)
|
| 53 |
+
thread.start()
|
| 54 |
+
|
| 55 |
+
# ==========================================
|
| 56 |
+
# 2. UI 逻辑
|
| 57 |
+
# ==========================================
|
| 58 |
+
def 获取监控数据():
|
| 59 |
+
"""从 Supabase 获取 service_status 表的所有数据"""
|
| 60 |
+
if not supabase:
|
| 61 |
+
return pd.DataFrame([{"错误": "未配置 SUPABASE_URL 或 KEY"}])
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
response = supabase.table("service_status").select("*").execute()
|
| 65 |
+
data = response.data
|
| 66 |
+
if not data:
|
| 67 |
+
return pd.DataFrame([{"信息": "目前没有服务在运行"}])
|
| 68 |
+
|
| 69 |
+
# 转换为 DataFrame 方便展示
|
| 70 |
+
df = pd.DataFrame(data)
|
| 71 |
+
# 简单处理下时间格式
|
| 72 |
+
if "updated_at" in df.columns:
|
| 73 |
+
df["更新时间"] = pd.to_datetime(df["updated_at"]).dt.strftime('%Y-%m-%d %H:%M:%S')
|
| 74 |
+
df = df.drop(columns=["updated_at"])
|
| 75 |
+
|
| 76 |
+
# 重命名列名,让高中生也能看懂
|
| 77 |
+
rename_map = {
|
| 78 |
+
"service_name": "服务名称",
|
| 79 |
+
"status": "状态",
|
| 80 |
+
"cpu_percent": "CPU使用率(%)",
|
| 81 |
+
"memory_percent": "内存使用率(%)",
|
| 82 |
+
"details": "详细信息"
|
| 83 |
+
}
|
| 84 |
+
df = df.rename(columns=rename_map)
|
| 85 |
+
return df
|
| 86 |
+
except Exception as e:
|
| 87 |
+
return pd.DataFrame([{"错误": f"获取数据失败: {str(e)}"}])
|
| 88 |
+
|
| 89 |
+
# ==========================================
|
| 90 |
+
# 3. 构建 Gradio 界面
|
| 91 |
+
# ==========================================
|
| 92 |
+
with gr.Blocks(title="Quant_Unified 监控中心", theme=gr.themes.Soft()) as demo:
|
| 93 |
+
gr.Markdown("# 🚀 Quant_Unified 量化系统监控中心")
|
| 94 |
+
gr.Markdown("实时展示部署在云端的采集服务状态。数据通过 Supabase 同步。")
|
| 95 |
+
|
| 96 |
+
with gr.Row():
|
| 97 |
+
status_table = gr.DataFrame(label="服务状态列表", value=获取监控数据, every=5) # 每5秒刷新一次
|
| 98 |
+
|
| 99 |
+
with gr.Row():
|
| 100 |
+
refresh_btn = gr.Button("手动刷新")
|
| 101 |
+
refresh_btn.click(获取监控数据, outputs=status_table)
|
| 102 |
+
|
| 103 |
+
gr.Markdown("---")
|
| 104 |
+
gr.Markdown("💡 **提示**:如果状态显示为 'ok',说明后台采集正在正常工作。")
|
| 105 |
+
|
| 106 |
+
if __name__ == "__main__":
|
| 107 |
+
demo.launch(server_name="0.0.0.0", server_port=7860)
|
config.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
Quant_Unified 全局配置
|
| 4 |
+
用于统一管理跨服务的常量参数
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
# ==========================================
|
| 10 |
+
# 1. 行情采集配置
|
| 11 |
+
# ==========================================
|
| 12 |
+
|
| 13 |
+
# 深度图档位 (支持 5, 10, 20, 50, 100)
|
| 14 |
+
# 注意: 修改此值会影响采集器订阅的数据流以及数据存储的结构
|
| 15 |
+
# 警告: 档位越高,数据量越大,带宽消耗越高
|
| 16 |
+
DEPTH_LEVEL = 20
|
| 17 |
+
|
| 18 |
+
# ==========================================
|
| 19 |
+
# 2. 路径配置
|
| 20 |
+
# ==========================================
|
| 21 |
+
# 可以在这里统一定义数据根目录等
|
memory/user_global.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
trigger: always_on
|
| 3 |
+
alwaysApply: true
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# 核心角色与最高指令 (Core Identity & Prime Directives)
|
| 7 |
+
|
| 8 |
+
## 1. 身份定位:双重人格
|
| 9 |
+
你拥有双重身份,必须同时满足以下要求:
|
| 10 |
+
* **顶级全栈架构师 (The Architect)**:你只写业界最先进 (SOTA)、最优雅、性能最强及其“干净”的代码。代码风格对标 Apple/Google 首席工程师。
|
| 11 |
+
* **金牌编程导师 (The Mentor)**:你的用户是一名**只会中文的高中生**。
|
| 12 |
+
* **教学义务**:你必须用“人话”和类比解释一切。
|
| 13 |
+
* **术语禁忌**:遇到专业术语(如 Docker, IPC, AOT, Hydration 等)或英文缩写,**必须**立即展开解释其含义和作用,严禁直接堆砌名词。
|
| 14 |
+
|
| 15 |
+
## 2. 核心原则
|
| 16 |
+
* **拒绝降级**:即使面对高中生,你也必须交付 **SOTA (业界顶尖)** 的技术方案。不要因为用户是初学者就提供简化版或过时的垃圾代码(MVP)。如果技术太难,你的任务是把它**解释清楚**,而不是把它**做烂**。
|
| 17 |
+
* **拒绝假数据**:**永远不允许**使用模拟数据 (Mock Data)。必须连接真实接口、数据库或文件系统。
|
| 18 |
+
* **显式运行**:**严禁静默运行**。任何脚本或程序的启动,必须在终端(Terminal)中有实时的日志输出。用户必须看到程序在“动”。
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
# 代码规范与工程标准 (Coding Standards)
|
| 23 |
+
|
| 24 |
+
## 1. 中文化编程 (教学辅助)
|
| 25 |
+
为了降低高中生的认知负荷,在**不导致语法错误**且**不影响运行**的前提下,强制执行:
|
| 26 |
+
* **中文命名**:函数名、变量名、类名**尽可能使用中文**。
|
| 27 |
+
* *Good*: `def 计算移动平均线(价格列表):`
|
| 28 |
+
* *Bad*: `def calc_ma(price_list):`
|
| 29 |
+
* **中文注释**:每个代码文件开头必须包含**中文文件头**,用通俗语言解释“这个文件是干嘛的”。代码内部逻辑必须通过中文注释解释“为什么这么写”。
|
| 30 |
+
|
| 31 |
+
## 2. 前端标准 (React & UI)
|
| 32 |
+
* **React 编译器优先**:代码必须兼容并开启 **React Compiler**。避免使用过时的 `useMemo`/`useCallback` 手动优化(除非编译器无法处理),让代码更干净。
|
| 33 |
+
* **Apple 级审美**:默认扮演 Apple 顶级 UI 工程师。界面必须具有极致的审美、流畅的动画(Framer Motion)和高级的交互感。
|
| 34 |
+
* **TypeScript**:零容忍报错。自动修复所有红线,类型定义必须精准。
|
| 35 |
+
* **错误自愈**:编写前端自动化测试或脚本时,自动调用 `playwright` MCP 修复报错。
|
| 36 |
+
|
| 37 |
+
## 3. Python 标准
|
| 38 |
+
* **执行环境**:默认使用 `python -X utf8` 运行,确保中文处理无乱码。
|
| 39 |
+
* **异常处理**:绝不“吞掉”错误。必须使用卫语句 (Guard Clauses) 提前拦截异常。
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
# 自动化工作流 (Automated Workflow)
|
| 44 |
+
|
| 45 |
+
## 1. 环境与执行 (每次行动前检查)
|
| 46 |
+
1. **虚拟环境**:项目若无 venv,**优先**自动创建并激活。
|
| 47 |
+
2. **文件占用**:删除或写入文件前,检查句柄占用 (Handle check)。
|
| 48 |
+
3. **Git 自动化**:自主判断代码节点。认为有必要时(如完成一个功能模块),**自动执行 Git 提交**,无需频繁请示。
|
| 49 |
+
|
| 50 |
+
## 2. 记忆与凭证
|
| 51 |
+
* **长期记忆**:自动使用 `memory` MCP 存储项目关键信息。
|
| 52 |
+
* **凭证管理**:记住关键密码(如 PostgreSQL 密码 `587376`),需要时自动填充,不要重复问用户。
|
| 53 |
+
|
| 54 |
+
---
|
| 55 |
+
|
| 56 |
+
# 沟通协议 (Communication Protocol)
|
| 57 |
+
|
| 58 |
+
* **思考与输出**:你可以用英文思考(Thinking Process),但**最终回复必须完全使用中文**。
|
| 59 |
+
* **解释风格**:
|
| 60 |
+
* *场景*:解释 `Redis`。
|
| 61 |
+
* *错误*:“Redis 是一个基于内存的 Key-Value 存储系统。”
|
| 62 |
+
* *正确*:“Redis 就像是电脑的‘内存条’,也就是个**快取区**。我们要存东西时,先放这里,因为读写速度极快,比存到硬盘(数据库)里快几千倍。适合用来存那些大家频繁要看的数据。”
|
requirements.txt
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
aiodns==3.6.0
|
| 2 |
+
aiohappyeyeballs==2.6.1
|
| 3 |
+
aiohttp==3.13.2
|
| 4 |
+
aiosignal==1.4.0
|
| 5 |
+
aiosqlite==0.21.0
|
| 6 |
+
annotated-doc==0.0.4
|
| 7 |
+
annotated-types==0.7.0
|
| 8 |
+
anyio==4.12.0
|
| 9 |
+
attrs==25.4.0
|
| 10 |
+
beautifulsoup4==4.14.3
|
| 11 |
+
bleach==6.3.0
|
| 12 |
+
ccxt==4.5.26
|
| 13 |
+
certifi==2025.11.12
|
| 14 |
+
cffi==2.0.0
|
| 15 |
+
charset-normalizer==3.4.4
|
| 16 |
+
click==8.3.1
|
| 17 |
+
colorama==0.4.6
|
| 18 |
+
contourpy==1.3.3
|
| 19 |
+
cryptography==46.0.3
|
| 20 |
+
cssselect==1.3.0
|
| 21 |
+
cssutils==2.11.1
|
| 22 |
+
cycler==0.12.1
|
| 23 |
+
dataframe_image==0.2.7
|
| 24 |
+
defusedxml==0.7.1
|
| 25 |
+
ecdsa==0.19.1
|
| 26 |
+
fastapi==0.124.2
|
| 27 |
+
fastjsonschema==2.21.2
|
| 28 |
+
fonttools==4.61.0
|
| 29 |
+
frozenlist==1.8.0
|
| 30 |
+
greenlet==3.3.0
|
| 31 |
+
h11==0.16.0
|
| 32 |
+
h5py==3.15.1
|
| 33 |
+
hdf5plugin==6.0.0
|
| 34 |
+
idna==3.11
|
| 35 |
+
Jinja2==3.1.6
|
| 36 |
+
jsonschema==4.25.1
|
| 37 |
+
jsonschema-specifications==2025.9.1
|
| 38 |
+
jupyter_client==8.7.0
|
| 39 |
+
jupyter_core==5.9.1
|
| 40 |
+
jupyterlab_pygments==0.3.0
|
| 41 |
+
kiwisolver==1.4.9
|
| 42 |
+
llvmlite==0.46.0
|
| 43 |
+
lxml==6.0.2
|
| 44 |
+
MarkupSafe==3.0.3
|
| 45 |
+
matplotlib==3.10.7
|
| 46 |
+
mistune==3.1.4
|
| 47 |
+
more-itertools==10.8.0
|
| 48 |
+
multidict==6.7.0
|
| 49 |
+
narwhals==2.13.0
|
| 50 |
+
nbclient==0.10.2
|
| 51 |
+
nbconvert==7.16.6
|
| 52 |
+
nbformat==5.10.4
|
| 53 |
+
numba==0.63.1
|
| 54 |
+
numpy==2.3.5
|
| 55 |
+
packaging==25.0
|
| 56 |
+
pandas==2.3.3
|
| 57 |
+
pandocfilters==1.5.1
|
| 58 |
+
pillow==12.0.0
|
| 59 |
+
platformdirs==4.5.1
|
| 60 |
+
playwright==1.57.0
|
| 61 |
+
plotly==6.5.0
|
| 62 |
+
propcache==0.4.1
|
| 63 |
+
pyasn1==0.6.1
|
| 64 |
+
pycares==4.11.0
|
| 65 |
+
pycparser==2.23
|
| 66 |
+
pycryptodome==3.23.0
|
| 67 |
+
pydantic==2.12.5
|
| 68 |
+
pydantic_core==2.41.5
|
| 69 |
+
pyee==13.0.0
|
| 70 |
+
Pygments==2.19.2
|
| 71 |
+
pyotp==2.9.0
|
| 72 |
+
pyparsing==3.2.5
|
| 73 |
+
python-dateutil==2.9.0.post0
|
| 74 |
+
python-dotenv==1.2.1
|
| 75 |
+
python-jose==3.5.0
|
| 76 |
+
python-multipart==0.0.20
|
| 77 |
+
python-socks==2.8.0
|
| 78 |
+
pytz==2025.2
|
| 79 |
+
pyzmq==27.1.0
|
| 80 |
+
referencing==0.37.0
|
| 81 |
+
requests==2.32.5
|
| 82 |
+
rpds-py==0.30.0
|
| 83 |
+
rsa==4.9.1
|
| 84 |
+
scipy==1.16.3
|
| 85 |
+
seaborn==0.13.2
|
| 86 |
+
setuptools==80.9.0
|
| 87 |
+
six==1.17.0
|
| 88 |
+
soupsieve==2.8
|
| 89 |
+
SQLAlchemy==2.0.45
|
| 90 |
+
starlette==0.50.0
|
| 91 |
+
tinycss2==1.4.0
|
| 92 |
+
tornado==6.5.2
|
| 93 |
+
tqdm==4.67.1
|
| 94 |
+
traitlets==5.14.3
|
| 95 |
+
typing-inspection==0.4.2
|
| 96 |
+
typing_extensions==4.15.0
|
| 97 |
+
tzdata==2025.2
|
| 98 |
+
urllib3==2.6.1
|
| 99 |
+
uvicorn==0.38.0
|
| 100 |
+
webencodings==0.5.1
|
| 101 |
+
websockets
|
| 102 |
+
supabase
|
| 103 |
+
pyarrow
|
| 104 |
+
gradio
|
| 105 |
+
psutil
|
| 106 |
+
python-dotenv
|
| 107 |
+
websockets==15.0.1
|
| 108 |
+
yarl==1.22.0
|
| 109 |
+
supabase
|
| 110 |
+
psutil
|
| 111 |
+
pyarrow
|
sitecustomize.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Python 环境初始化钩子
|
| 3 |
+
|
| 4 |
+
此文件会被 Python 的 site 模块自动加载(只要它在 sys.path 中)。
|
| 5 |
+
它的主要作用是动态配置 sys.path,将项目的 libs, services, strategies, apps 等目录
|
| 6 |
+
加入到模块搜索路径中。
|
| 7 |
+
|
| 8 |
+
这样做的目的是:
|
| 9 |
+
1. 允许项目内的代码直接通过 import 导入这些目录下的模块(如 import common_core)。
|
| 10 |
+
2. 避免在每个脚本中手动编写 sys.path.append(...)。
|
| 11 |
+
3. 简化开发环境配置,无需强制设置 PYTHONPATH 环境变量。
|
| 12 |
+
"""
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
_ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 17 |
+
_EXTRA = [
|
| 18 |
+
os.path.join(_ROOT, '基础库'),
|
| 19 |
+
os.path.join(_ROOT, '服务'),
|
| 20 |
+
os.path.join(_ROOT, '策略仓库'),
|
| 21 |
+
os.path.join(_ROOT, '4 号做市策略'),
|
| 22 |
+
os.path.join(_ROOT, '应用'),
|
| 23 |
+
os.path.join(_ROOT, '应用', 'qronos'),
|
| 24 |
+
]
|
| 25 |
+
for p in _EXTRA:
|
| 26 |
+
if os.path.isdir(p) and p not in sys.path:
|
| 27 |
+
sys.path.append(p)
|
基础库/common_core/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified Common Core
|
| 3 |
+
"""
|
基础库/common_core/backtest/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
__init__.py
|
| 4 |
+
"""
|
基础库/common_core/backtest/equity.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
[核心资产计算模块]
|
| 4 |
+
功能:负责根据策略信号、行情数据、费率等,计算资金曲线、持仓价值和爆仓风险。
|
| 5 |
+
"""
|
| 6 |
+
import time
|
| 7 |
+
|
| 8 |
+
import numba as nb
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
from core.evaluate import strategy_evaluate
|
| 13 |
+
from core.figure import draw_equity_curve_plotly
|
| 14 |
+
from core.model.backtest_config import BacktestConfig
|
| 15 |
+
from core.rebalance import RebAlways
|
| 16 |
+
from core.simulator import Simulator
|
| 17 |
+
from core.utils.functions import load_min_qty
|
| 18 |
+
from core.utils.path_kit import get_file_path
|
| 19 |
+
from update_min_qty import min_qty_path
|
| 20 |
+
|
| 21 |
+
pd.set_option('display.max_rows', 1000)
|
| 22 |
+
pd.set_option('expand_frame_repr', False) # 当列太多时不换行
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def calc_equity(conf: BacktestConfig,
|
| 26 |
+
pivot_dict_spot: dict,
|
| 27 |
+
pivot_dict_swap: dict,
|
| 28 |
+
df_spot_ratio: pd.DataFrame,
|
| 29 |
+
df_swap_ratio: pd.DataFrame,
|
| 30 |
+
show_plot: bool = True):
|
| 31 |
+
"""
|
| 32 |
+
计算回测结果的函数
|
| 33 |
+
:param conf: 回测配置
|
| 34 |
+
:param pivot_dict_spot: 现货行情数据
|
| 35 |
+
:param pivot_dict_swap: 永续合约行情数据
|
| 36 |
+
:param df_spot_ratio: 现货目标资金占比
|
| 37 |
+
:param df_swap_ratio: 永续合约目标资金占比
|
| 38 |
+
:param show_plot: 是否显示回测图
|
| 39 |
+
:return: 没有返回值
|
| 40 |
+
"""
|
| 41 |
+
# ====================================================================================================
|
| 42 |
+
# 1. 数据预检和准备数据
|
| 43 |
+
# 数据预检,对齐所有数据的长度(防御性编程)
|
| 44 |
+
# ====================================================================================================
|
| 45 |
+
if len(df_spot_ratio) != len(df_swap_ratio) or np.any(df_swap_ratio.index != df_spot_ratio.index):
|
| 46 |
+
raise RuntimeError(f'数据长度不一致,现货数据长度:{len(df_spot_ratio)}, 永续合约数据长度:{len(df_swap_ratio)}')
|
| 47 |
+
|
| 48 |
+
# 开始时间列
|
| 49 |
+
candle_begin_times = df_spot_ratio.index.to_series().reset_index(drop=True)
|
| 50 |
+
|
| 51 |
+
# 获取现货和永续合约的币种,并且排序
|
| 52 |
+
spot_symbols = sorted(df_spot_ratio.columns)
|
| 53 |
+
swap_symbols = sorted(df_swap_ratio.columns)
|
| 54 |
+
|
| 55 |
+
# 裁切现货数据,保证open,close,vwap1m,对应的df中,现货币种、时间长度一致
|
| 56 |
+
pivot_dict_spot = align_pivot_dimensions(pivot_dict_spot, spot_symbols, candle_begin_times)
|
| 57 |
+
|
| 58 |
+
# 裁切合约数据,保证open,close,vwap1m,funding_fee对应的df中,合约币种、时间长度一致
|
| 59 |
+
pivot_dict_swap = align_pivot_dimensions(pivot_dict_swap, swap_symbols, candle_begin_times)
|
| 60 |
+
|
| 61 |
+
# 读入最小下单量数据
|
| 62 |
+
spot_lot_sizes = read_lot_sizes(min_qty_path / '最小下单量_spot.csv', spot_symbols)
|
| 63 |
+
swap_lot_sizes = read_lot_sizes(min_qty_path / '最小下单量_swap.csv', swap_symbols)
|
| 64 |
+
|
| 65 |
+
pos_calc = RebAlways(spot_lot_sizes.to_numpy(), swap_lot_sizes.to_numpy())
|
| 66 |
+
|
| 67 |
+
# ====================================================================================================
|
| 68 |
+
# 2. 开始模拟交易
|
| 69 |
+
# 开始策马奔腾啦 🐎
|
| 70 |
+
# ====================================================================================================
|
| 71 |
+
s_time = time.perf_counter()
|
| 72 |
+
equities, turnovers, fees, funding_fees, margin_rates, long_pos_values, short_pos_values = start_simulation(
|
| 73 |
+
init_capital=conf.initial_usdt, # 初始资金,单位:USDT
|
| 74 |
+
leverage=conf.leverage, # 杠杆
|
| 75 |
+
spot_lot_sizes=spot_lot_sizes.to_numpy(), # 现货最小下单量
|
| 76 |
+
swap_lot_sizes=swap_lot_sizes.to_numpy(), # 永续合约最小下单量
|
| 77 |
+
spot_c_rate=conf.spot_c_rate, # 现货杠杆率
|
| 78 |
+
swap_c_rate=conf.swap_c_rate, # 永续合约杠杆率
|
| 79 |
+
spot_min_order_limit=float(conf.spot_min_order_limit), # 现货最小下单金额
|
| 80 |
+
swap_min_order_limit=float(conf.swap_min_order_limit), # 永续合约最小下单金额
|
| 81 |
+
min_margin_rate=conf.margin_rate, # 最低保证金比例
|
| 82 |
+
# 选股结果计算聚合得到的每个周期目标资金占比
|
| 83 |
+
spot_ratio=df_spot_ratio[spot_symbols].to_numpy(), # 现货目标资金占比
|
| 84 |
+
swap_ratio=df_swap_ratio[swap_symbols].to_numpy(), # 永续合约目标资金占比
|
| 85 |
+
# 现货行情数据
|
| 86 |
+
spot_open_p=pivot_dict_spot['open'].to_numpy(), # 现货开盘价
|
| 87 |
+
spot_close_p=pivot_dict_spot['close'].to_numpy(), # 现货收盘价
|
| 88 |
+
spot_vwap1m_p=pivot_dict_spot['vwap1m'].to_numpy(), # 现货开盘一分钟均价
|
| 89 |
+
# 永续合约行情数据
|
| 90 |
+
swap_open_p=pivot_dict_swap['open'].to_numpy(), # 永续合约开盘价
|
| 91 |
+
swap_close_p=pivot_dict_swap['close'].to_numpy(), # 永续合约收盘价
|
| 92 |
+
swap_vwap1m_p=pivot_dict_swap['vwap1m'].to_numpy(), # 永续合约开盘一分钟均价
|
| 93 |
+
funding_rates=pivot_dict_swap['funding_rate'].to_numpy(), # 永续合约资金费率
|
| 94 |
+
pos_calc=pos_calc, # 仓位计算
|
| 95 |
+
)
|
| 96 |
+
print(f'✅ 完成模拟交易,花费时间: {time.perf_counter() - s_time:.3f}秒')
|
| 97 |
+
print()
|
| 98 |
+
|
| 99 |
+
# ====================================================================================================
|
| 100 |
+
# 3. 回测结果汇总,并输出相关文件
|
| 101 |
+
# ====================================================================================================
|
| 102 |
+
print('🌀 开始生成回测统计结果...')
|
| 103 |
+
account_df = pd.DataFrame({
|
| 104 |
+
'candle_begin_time': candle_begin_times,
|
| 105 |
+
'equity': equities,
|
| 106 |
+
'turnover': turnovers,
|
| 107 |
+
'fee': fees,
|
| 108 |
+
'funding_fee': funding_fees,
|
| 109 |
+
'marginRatio': margin_rates,
|
| 110 |
+
'long_pos_value': long_pos_values,
|
| 111 |
+
'short_pos_value': short_pos_values
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
account_df['净值'] = account_df['equity'] / conf.initial_usdt
|
| 115 |
+
account_df['涨跌幅'] = account_df['净值'].pct_change()
|
| 116 |
+
account_df.loc[account_df['marginRatio'] < conf.margin_rate, '是否爆仓'] = 1
|
| 117 |
+
account_df['是否爆仓'].fillna(method='ffill', inplace=True)
|
| 118 |
+
account_df['是否爆仓'].fillna(value=0, inplace=True)
|
| 119 |
+
|
| 120 |
+
account_df.to_csv(conf.get_result_folder() / '资金曲线.csv', encoding='utf-8-sig')
|
| 121 |
+
|
| 122 |
+
# 策略评价
|
| 123 |
+
rtn, year_return, month_return, quarter_return = strategy_evaluate(account_df, net_col='净值', pct_col='涨跌幅')
|
| 124 |
+
conf.set_report(rtn.T)
|
| 125 |
+
rtn.to_csv(conf.get_result_folder() / '策略评价.csv', encoding='utf-8-sig')
|
| 126 |
+
year_return.to_csv(conf.get_result_folder() / '年度账户收益.csv', encoding='utf-8-sig')
|
| 127 |
+
quarter_return.to_csv(conf.get_result_folder() / '季度账户收益.csv', encoding='utf-8-sig')
|
| 128 |
+
month_return.to_csv(conf.get_result_folder() / '月度账户收益.csv', encoding='utf-8-sig')
|
| 129 |
+
|
| 130 |
+
if show_plot:
|
| 131 |
+
# 绘制资金曲线
|
| 132 |
+
all_swap = pd.read_pickle(get_file_path('data', 'candle_data_dict.pkl'))
|
| 133 |
+
btc_df = all_swap['BTC-USDT']
|
| 134 |
+
account_df = pd.merge(left=account_df, right=btc_df[['candle_begin_time', 'close']], on=['candle_begin_time'],
|
| 135 |
+
how='left')
|
| 136 |
+
account_df['close'].fillna(method='ffill', inplace=True)
|
| 137 |
+
account_df['BTC涨跌幅'] = account_df['close'].pct_change()
|
| 138 |
+
account_df['BTC涨跌幅'].fillna(value=0, inplace=True)
|
| 139 |
+
account_df['BTC资金曲线'] = (account_df['BTC涨跌幅'] + 1).cumprod()
|
| 140 |
+
del account_df['close'], account_df['BTC涨跌幅']
|
| 141 |
+
|
| 142 |
+
print(f"🎯 策略评价================\n{rtn}")
|
| 143 |
+
print(f"🗓️ 分年收益率================\n{year_return}")
|
| 144 |
+
|
| 145 |
+
print(f'💰 总手续费: {account_df["fee"].sum():,.2f}USDT')
|
| 146 |
+
print()
|
| 147 |
+
|
| 148 |
+
print('🌀 开始绘制资金曲线...')
|
| 149 |
+
eth_df = all_swap['ETH-USDT']
|
| 150 |
+
account_df = pd.merge(left=account_df, right=eth_df[['candle_begin_time', 'close']], on=['candle_begin_time'],
|
| 151 |
+
how='left')
|
| 152 |
+
account_df['close'].fillna(method='ffill', inplace=True)
|
| 153 |
+
account_df['ETH涨跌幅'] = account_df['close'].pct_change()
|
| 154 |
+
account_df['ETH涨跌幅'].fillna(value=0, inplace=True)
|
| 155 |
+
account_df['ETH资金曲线'] = (account_df['ETH涨跌幅'] + 1).cumprod()
|
| 156 |
+
del account_df['close'], account_df['ETH涨跌幅']
|
| 157 |
+
|
| 158 |
+
account_df['long_pos_ratio'] = account_df['long_pos_value'] / account_df['equity']
|
| 159 |
+
account_df['short_pos_ratio'] = account_df['short_pos_value'] / account_df['equity']
|
| 160 |
+
account_df['empty_ratio'] = (conf.leverage - account_df['long_pos_ratio'] - account_df['short_pos_ratio']).clip(
|
| 161 |
+
lower=0)
|
| 162 |
+
# 计算累计值,主要用于后面画图使用
|
| 163 |
+
account_df['long_cum'] = account_df['long_pos_ratio']
|
| 164 |
+
account_df['short_cum'] = account_df['long_pos_ratio'] + account_df['short_pos_ratio']
|
| 165 |
+
account_df['empty_cum'] = conf.leverage # 空仓占比始终为 1(顶部)
|
| 166 |
+
# 选币数量
|
| 167 |
+
df_swap_ratio = df_swap_ratio * conf.leverage
|
| 168 |
+
df_spot_ratio = df_spot_ratio * conf.leverage
|
| 169 |
+
|
| 170 |
+
symbol_long_num = df_spot_ratio[df_spot_ratio > 0].count(axis=1) + df_swap_ratio[df_swap_ratio > 0].count(
|
| 171 |
+
axis=1)
|
| 172 |
+
account_df['symbol_long_num'] = symbol_long_num.values
|
| 173 |
+
symbol_short_num = df_spot_ratio[df_spot_ratio < 0].count(axis=1) + df_swap_ratio[df_swap_ratio < 0].count(
|
| 174 |
+
axis=1)
|
| 175 |
+
account_df['symbol_short_num'] = symbol_short_num.values
|
| 176 |
+
|
| 177 |
+
# 生成画图数据字典,可以画出所有offset资金曲线以及各个offset资金曲线
|
| 178 |
+
data_dict = {'多空资金曲线': '净值', 'BTC资金曲线': 'BTC资金曲线', 'ETH资金曲线': 'ETH资金曲线'}
|
| 179 |
+
right_axis = {'多空最大回撤': '净值dd2here'}
|
| 180 |
+
|
| 181 |
+
# 如果画多头、空头资金曲线,同时也会画上回撤曲线
|
| 182 |
+
pic_title = f"CumNetVal:{rtn.at['累积净值', 0]}, Annual:{rtn.at['年化收益', 0]}, MaxDrawdown:{rtn.at['最大回撤', 0]}"
|
| 183 |
+
pic_desc = conf.get_fullname()
|
| 184 |
+
# 调用画图函数
|
| 185 |
+
draw_equity_curve_plotly(account_df, data_dict=data_dict, date_col='candle_begin_time', right_axis=right_axis,
|
| 186 |
+
title=pic_title, desc=pic_desc, path=conf.get_result_folder() / '资金曲线.html',
|
| 187 |
+
show_subplots=True)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def read_lot_sizes(path, symbols):
|
| 191 |
+
"""
|
| 192 |
+
读取每个币种的最小下单量
|
| 193 |
+
:param path: 文件路径
|
| 194 |
+
:param symbols: 币种列表
|
| 195 |
+
:return:
|
| 196 |
+
"""
|
| 197 |
+
default_min_qty, min_qty_dict = load_min_qty(path)
|
| 198 |
+
lot_sizes = 0.1 ** pd.Series(min_qty_dict)
|
| 199 |
+
lot_sizes = lot_sizes.reindex(symbols, fill_value=0.1 ** default_min_qty)
|
| 200 |
+
return lot_sizes
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def align_pivot_dimensions(market_pivot_dict, symbols, candle_begin_times):
|
| 204 |
+
"""
|
| 205 |
+
对不同维度的数据进行对齐
|
| 206 |
+
:param market_pivot_dict: 原始数据,是一个dict哦
|
| 207 |
+
:param symbols: 币种(列)
|
| 208 |
+
:param candle_begin_times: 时间(行)
|
| 209 |
+
:return:
|
| 210 |
+
"""
|
| 211 |
+
return {k: df.loc[candle_begin_times, symbols] for k, df in market_pivot_dict.items()}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@nb.njit
|
| 215 |
+
def calc_lots(equity, close_prices, ratios, lot_sizes):
|
| 216 |
+
"""
|
| 217 |
+
计算每个币种的目标手数
|
| 218 |
+
:param equity: 总权益
|
| 219 |
+
:param close_prices: 收盘价
|
| 220 |
+
:param ratios: 每个币种的资金比例
|
| 221 |
+
:param lot_sizes: 每个币种的最小下单量
|
| 222 |
+
:return: 每个币种的目标手数
|
| 223 |
+
"""
|
| 224 |
+
pos_equity = equity * ratios
|
| 225 |
+
mask = np.abs(pos_equity) > 0.01
|
| 226 |
+
target_lots = np.zeros(len(close_prices), dtype=np.int64)
|
| 227 |
+
target_lots[mask] = (pos_equity[mask] / close_prices[mask] / lot_sizes[mask]).astype(np.int64)
|
| 228 |
+
return target_lots
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@nb.jit(nopython=True, boundscheck=True)
|
| 232 |
+
def start_simulation(init_capital, leverage, spot_lot_sizes, swap_lot_sizes, spot_c_rate, swap_c_rate,
|
| 233 |
+
spot_min_order_limit, swap_min_order_limit, min_margin_rate, spot_ratio, swap_ratio,
|
| 234 |
+
spot_open_p, spot_close_p, spot_vwap1m_p, swap_open_p, swap_close_p, swap_vwap1m_p,
|
| 235 |
+
funding_rates, pos_calc):
|
| 236 |
+
"""
|
| 237 |
+
模拟交易
|
| 238 |
+
:param init_capital: 初始资金
|
| 239 |
+
:param leverage: 杠杆
|
| 240 |
+
:param spot_lot_sizes: spot 现货的最小下单量
|
| 241 |
+
:param swap_lot_sizes: swap 合约的最小下单量
|
| 242 |
+
:param spot_c_rate: spot 现货的手续费率
|
| 243 |
+
:param swap_c_rate: swap 合约的手续费率
|
| 244 |
+
:param spot_min_order_limit: spot 现货最小下单金额
|
| 245 |
+
:param swap_min_order_limit: swap 合约最小下单金额
|
| 246 |
+
:param min_margin_rate: 维持保证金率
|
| 247 |
+
:param spot_ratio: spot 的仓位透视表 (numpy 矩阵)
|
| 248 |
+
:param swap_ratio: swap 的仓位透视表 (numpy 矩阵)
|
| 249 |
+
:param spot_open_p: spot 的开仓价格透视表 (numpy 矩阵)
|
| 250 |
+
:param spot_close_p: spot 的平仓价格透视表 (numpy 矩阵)
|
| 251 |
+
:param spot_vwap1m_p: spot 的 vwap1m 价格透视表 (numpy 矩阵)
|
| 252 |
+
:param swap_open_p: swap 的开仓价格透视表 (numpy 矩阵)
|
| 253 |
+
:param swap_close_p: swap 的平仓价格透视表 (numpy 矩阵)
|
| 254 |
+
:param swap_vwap1m_p: swap 的 vwap1m 价格透视表 (numpy 矩阵)
|
| 255 |
+
:param funding_rates: swap 的 funding rate 透视表 (numpy 矩阵)
|
| 256 |
+
:param pos_calc: 仓位计算
|
| 257 |
+
:return:
|
| 258 |
+
"""
|
| 259 |
+
# ====================================================================================================
|
| 260 |
+
# 1. 初始化回测空间
|
| 261 |
+
# 设置几个固定长度的数组变量,并且重置为0,到时候每一个周期的数据,都按照index的顺序,依次填充进去
|
| 262 |
+
# ====================================================================================================
|
| 263 |
+
n_bars = spot_ratio.shape[0]
|
| 264 |
+
n_syms_spot = spot_ratio.shape[1]
|
| 265 |
+
n_syms_swap = swap_ratio.shape[1]
|
| 266 |
+
|
| 267 |
+
start_lots_spot = np.zeros(n_syms_spot, dtype=np.int64)
|
| 268 |
+
start_lots_swap = np.zeros(n_syms_swap, dtype=np.int64)
|
| 269 |
+
# 现货不设置资金费
|
| 270 |
+
funding_rates_spot = np.zeros(n_syms_spot, dtype=np.float64)
|
| 271 |
+
|
| 272 |
+
turnovers = np.zeros(n_bars, dtype=np.float64)
|
| 273 |
+
fees = np.zeros(n_bars, dtype=np.float64)
|
| 274 |
+
equities = np.zeros(n_bars, dtype=np.float64) # equity after execution
|
| 275 |
+
funding_fees = np.zeros(n_bars, dtype=np.float64)
|
| 276 |
+
margin_rates = np.zeros(n_bars, dtype=np.float64)
|
| 277 |
+
long_pos_values = np.zeros(n_bars, dtype=np.float64)
|
| 278 |
+
short_pos_values = np.zeros(n_bars, dtype=np.float64)
|
| 279 |
+
|
| 280 |
+
# ====================================================================================================
|
| 281 |
+
# 2. 初始化模拟对象
|
| 282 |
+
# ====================================================================================================
|
| 283 |
+
sim_spot = Simulator(init_capital, spot_lot_sizes, spot_c_rate, 0.0, start_lots_spot, spot_min_order_limit)
|
| 284 |
+
sim_swap = Simulator(0, swap_lot_sizes, swap_c_rate, 0.0, start_lots_swap, swap_min_order_limit)
|
| 285 |
+
|
| 286 |
+
# ====================================================================================================
|
| 287 |
+
# 3. 开始回测
|
| 288 |
+
# 每次循环包含以下四个步骤:
|
| 289 |
+
# 1. 模拟开盘on_open
|
| 290 |
+
# 2. 模拟执行on_execution
|
| 291 |
+
# 3. 模拟平仓on_close
|
| 292 |
+
# 4. 设置目标仓位set_target_lots
|
| 293 |
+
# 如下依次执行
|
| 294 |
+
# t1: on_open -> on_execution -> on_close -> set_target_lots
|
| 295 |
+
# t2: on_open -> on_execution -> on_close -> set_target_lots
|
| 296 |
+
# t3: on_open -> on_execution -> on_close -> set_target_lots
|
| 297 |
+
# ...
|
| 298 |
+
# tN: on_open -> on_execution -> on_close -> set_target_lots
|
| 299 |
+
# 并且在每一个t时刻,都会记录账户的截面数据,包括equity,funding_fee,margin_rate,等等
|
| 300 |
+
# ====================================================================================================
|
| 301 |
+
#
|
| 302 |
+
for i in range(n_bars):
|
| 303 |
+
"""1. 模拟开盘on_open"""
|
| 304 |
+
# 根据开盘价格,计算账户权益,当前持仓的名义价值,以及资金费
|
| 305 |
+
equity_spot, _, pos_value_spot = sim_spot.on_open(spot_open_p[i], funding_rates_spot, spot_open_p[i])
|
| 306 |
+
equity_swap, funding_fee, pos_value_swap = sim_swap.on_open(swap_open_p[i], funding_rates[i], swap_open_p[i])
|
| 307 |
+
|
| 308 |
+
# 当前持仓的名义价值
|
| 309 |
+
position_val = np.sum(np.abs(pos_value_spot)) + np.sum(np.abs(pos_value_swap))
|
| 310 |
+
if position_val < 1e-8:
|
| 311 |
+
# 没有持仓
|
| 312 |
+
margin_rate = 10000.0
|
| 313 |
+
else:
|
| 314 |
+
margin_rate = (equity_spot + equity_swap) / float(position_val)
|
| 315 |
+
|
| 316 |
+
# 当前保证金率小于维持保证金率,爆仓 💀
|
| 317 |
+
if margin_rate < min_margin_rate:
|
| 318 |
+
margin_rates[i] = margin_rate
|
| 319 |
+
break
|
| 320 |
+
|
| 321 |
+
"""2. 模拟开仓on_execution"""
|
| 322 |
+
# 根据开仓价格,计算账户权益,换手,手续费
|
| 323 |
+
equity_spot, turnover_spot, fee_spot = sim_spot.on_execution(spot_vwap1m_p[i])
|
| 324 |
+
equity_swap, turnover_swap, fee_swap = sim_swap.on_execution(swap_vwap1m_p[i])
|
| 325 |
+
|
| 326 |
+
"""3. 模拟K线结束on_close"""
|
| 327 |
+
# 根据收盘价格,计算账户权益
|
| 328 |
+
equity_spot_close, pos_value_spot_close = sim_spot.on_close(spot_close_p[i])
|
| 329 |
+
equity_swap_close, pos_value_swap_close = sim_swap.on_close(swap_close_p[i])
|
| 330 |
+
|
| 331 |
+
long_pos_value = (np.sum(pos_value_spot_close[pos_value_spot_close > 0]) +
|
| 332 |
+
np.sum(pos_value_swap_close[pos_value_swap_close > 0]))
|
| 333 |
+
|
| 334 |
+
short_pos_value = -(np.sum(pos_value_spot_close[pos_value_spot_close < 0]) +
|
| 335 |
+
np.sum(pos_value_swap_close[pos_value_swap_close < 0]))
|
| 336 |
+
|
| 337 |
+
# 把中间结果更新到之前初始化的空间
|
| 338 |
+
funding_fees[i] = funding_fee
|
| 339 |
+
equities[i] = equity_spot + equity_swap
|
| 340 |
+
turnovers[i] = turnover_spot + turnover_swap
|
| 341 |
+
fees[i] = fee_spot + fee_swap
|
| 342 |
+
margin_rates[i] = margin_rate
|
| 343 |
+
long_pos_values[i] = long_pos_value
|
| 344 |
+
short_pos_values[i] = short_pos_value
|
| 345 |
+
|
| 346 |
+
# 考虑杠杆
|
| 347 |
+
equity_leveraged = (equity_spot_close + equity_swap_close) * leverage
|
| 348 |
+
|
| 349 |
+
"""4. 计算目标持仓"""
|
| 350 |
+
# target_lots_spot = calc_lots(equity_leveraged, spot_close_p[i], spot_ratio[i], spot_lot_sizes)
|
| 351 |
+
# target_lots_swap = calc_lots(equity_leveraged, swap_close_p[i], swap_ratio[i], swap_lot_sizes)
|
| 352 |
+
|
| 353 |
+
target_lots_spot, target_lots_swap = pos_calc.calc_lots(equity_leveraged,
|
| 354 |
+
spot_close_p[i], sim_spot.lots, spot_ratio[i],
|
| 355 |
+
swap_close_p[i], sim_swap.lots, swap_ratio[i])
|
| 356 |
+
# 更新目标持仓
|
| 357 |
+
sim_spot.set_target_lots(target_lots_spot)
|
| 358 |
+
sim_swap.set_target_lots(target_lots_swap)
|
| 359 |
+
|
| 360 |
+
return equities, turnovers, fees, funding_fees, margin_rates, long_pos_values, short_pos_values
|
基础库/common_core/backtest/evaluate.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
[策略绩效评估模块]
|
| 4 |
+
功能:计算年化收益、最大回撤、夏普比率、胜率等关键指标,并生成分月/分年收益表。
|
| 5 |
+
"""
|
| 6 |
+
import itertools
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# 计算策略评价指标
|
| 13 |
+
def strategy_evaluate(equity, net_col='多空资金曲线', pct_col='本周期多空涨跌幅'):
|
| 14 |
+
"""
|
| 15 |
+
回测评价函数
|
| 16 |
+
:param equity: 资金曲线数据
|
| 17 |
+
:param net_col: 资金曲线列名
|
| 18 |
+
:param pct_col: 周期涨跌幅列名
|
| 19 |
+
:return:
|
| 20 |
+
"""
|
| 21 |
+
# ===新建一个dataframe保存回测指标
|
| 22 |
+
results = pd.DataFrame()
|
| 23 |
+
|
| 24 |
+
# 将数字转为百分数
|
| 25 |
+
def num_to_pct(value):
|
| 26 |
+
return '%.2f%%' % (value * 100)
|
| 27 |
+
|
| 28 |
+
# ===计算累积净值
|
| 29 |
+
results.loc[0, '累积净值'] = round(equity[net_col].iloc[-1], 2)
|
| 30 |
+
|
| 31 |
+
# ===计算年化收益
|
| 32 |
+
annual_return = (equity[net_col].iloc[-1]) ** (
|
| 33 |
+
'1 days 00:00:00' / (equity['candle_begin_time'].iloc[-1] - equity['candle_begin_time'].iloc[0]) * 365) - 1
|
| 34 |
+
results.loc[0, '年化收益'] = num_to_pct(annual_return)
|
| 35 |
+
|
| 36 |
+
# ===计算最大回撤,最大回撤的含义:《如何通过3行代码计算最大回撤》https://mp.weixin.qq.com/s/Dwt4lkKR_PEnWRprLlvPVw
|
| 37 |
+
# 计算当日之前的资金曲线的最高点
|
| 38 |
+
equity[f'{net_col.split("资金曲线")[0]}max2here'] = equity[net_col].expanding().max()
|
| 39 |
+
# 计算到历史最高值到当日的跌幅,drowdwon
|
| 40 |
+
equity[f'{net_col.split("资金曲线")[0]}dd2here'] = equity[net_col] / equity[f'{net_col.split("资金曲线")[0]}max2here'] - 1
|
| 41 |
+
# 计算最大回撤,以及最大回撤结束时间
|
| 42 |
+
end_date, max_draw_down = tuple(equity.sort_values(by=[f'{net_col.split("资金曲线")[0]}dd2here']).iloc[0][['candle_begin_time', f'{net_col.split("资金曲线")[0]}dd2here']])
|
| 43 |
+
# 计算最大回撤开始时间
|
| 44 |
+
start_date = equity[equity['candle_begin_time'] <= end_date].sort_values(by=net_col, ascending=False).iloc[0]['candle_begin_time']
|
| 45 |
+
results.loc[0, '最大回撤'] = num_to_pct(max_draw_down)
|
| 46 |
+
results.loc[0, '最大回撤开始时间'] = str(start_date)
|
| 47 |
+
results.loc[0, '最大回撤结束时间'] = str(end_date)
|
| 48 |
+
# ===年化收益/回撤比:我个人比较关注的一个指标
|
| 49 |
+
results.loc[0, '年化收益/回撤比'] = round(annual_return / abs(max_draw_down), 2)
|
| 50 |
+
# ===统计每个周期
|
| 51 |
+
results.loc[0, '盈利周期数'] = len(equity.loc[equity[pct_col] > 0]) # 盈利笔数
|
| 52 |
+
results.loc[0, '亏损周期数'] = len(equity.loc[equity[pct_col] <= 0]) # 亏损笔数
|
| 53 |
+
results.loc[0, '胜率'] = num_to_pct(results.loc[0, '盈利周期数'] / len(equity)) # 胜率
|
| 54 |
+
results.loc[0, '每周期平均收益'] = num_to_pct(equity[pct_col].mean()) # 每笔交易平均盈亏
|
| 55 |
+
results.loc[0, '盈亏收益比'] = round(equity.loc[equity[pct_col] > 0][pct_col].mean() / equity.loc[equity[pct_col] <= 0][pct_col].mean() * (-1), 2) # 盈亏比
|
| 56 |
+
if 1 in equity['是否爆仓'].to_list():
|
| 57 |
+
results.loc[0, '盈亏收益比'] = 0
|
| 58 |
+
results.loc[0, '单周期最大盈利'] = num_to_pct(equity[pct_col].max()) # 单笔最大盈利
|
| 59 |
+
results.loc[0, '单周期大亏损'] = num_to_pct(equity[pct_col].min()) # 单笔最大亏损
|
| 60 |
+
|
| 61 |
+
# ===连续盈利亏损
|
| 62 |
+
results.loc[0, '最大连续盈利周期数'] = max(
|
| 63 |
+
[len(list(v)) for k, v in itertools.groupby(np.where(equity[pct_col] > 0, 1, np.nan))]) # 最大连续盈利次数
|
| 64 |
+
results.loc[0, '最大连续亏损周期数'] = max(
|
| 65 |
+
[len(list(v)) for k, v in itertools.groupby(np.where(equity[pct_col] <= 0, 1, np.nan))]) # 最大连续亏损次数
|
| 66 |
+
|
| 67 |
+
# ===其他评价指标
|
| 68 |
+
results.loc[0, '收益率标准差'] = num_to_pct(equity[pct_col].std())
|
| 69 |
+
|
| 70 |
+
# ===每年、每月收益率
|
| 71 |
+
temp = equity.copy()
|
| 72 |
+
temp.set_index('candle_begin_time', inplace=True)
|
| 73 |
+
year_return = temp[[pct_col]].resample(rule='YE').apply(lambda x: (1 + x).prod() - 1)
|
| 74 |
+
month_return = temp[[pct_col]].resample(rule='ME').apply(lambda x: (1 + x).prod() - 1)
|
| 75 |
+
quarter_return = temp[[pct_col]].resample(rule='QE').apply(lambda x: (1 + x).prod() - 1)
|
| 76 |
+
|
| 77 |
+
def num2pct(x):
|
| 78 |
+
if str(x) != 'nan':
|
| 79 |
+
return str(round(x * 100, 2)) + '%'
|
| 80 |
+
else:
|
| 81 |
+
return x
|
| 82 |
+
|
| 83 |
+
year_return['涨跌幅'] = year_return[pct_col].apply(num2pct)
|
| 84 |
+
month_return['涨跌幅'] = month_return[pct_col].apply(num2pct)
|
| 85 |
+
quarter_return['涨跌幅'] = quarter_return[pct_col].apply(num2pct)
|
| 86 |
+
|
| 87 |
+
# # 对每月收益进行处理,做成二维表
|
| 88 |
+
# month_return.reset_index(inplace=True)
|
| 89 |
+
# month_return['year'] = month_return['candle_begin_time'].dt.year
|
| 90 |
+
# month_return['month'] = month_return['candle_begin_time'].dt.month
|
| 91 |
+
# month_return.set_index(['year', 'month'], inplace=True)
|
| 92 |
+
# del month_return['candle_begin_time']
|
| 93 |
+
# month_return_all = month_return[pct_col].unstack()
|
| 94 |
+
# month_return_all.loc['mean'] = month_return_all.mean(axis=0)
|
| 95 |
+
# month_return_all = month_return_all.apply(lambda x: x.apply(num2pct))
|
| 96 |
+
|
| 97 |
+
return results.T, year_return, month_return, quarter_return
|
基础库/common_core/backtest/figure.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
figure.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
import seaborn as sns
|
| 9 |
+
from matplotlib import pyplot as plt
|
| 10 |
+
from plotly import subplots
|
| 11 |
+
from plotly.offline import plot
|
| 12 |
+
from plotly.subplots import make_subplots
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def draw_equity_curve_plotly(df, data_dict, date_col=None, right_axis=None, pic_size=None, chg=False,
|
| 17 |
+
title=None, path=Path('data') / 'pic.html', show=True, desc=None,
|
| 18 |
+
show_subplots=False, markers=None, right_axis_lines=None):
|
| 19 |
+
"""
|
| 20 |
+
绘制策略曲线
|
| 21 |
+
:param df: 包含净值数据的df
|
| 22 |
+
:param data_dict: 要展示的数据字典格式:{图片上显示的名字:df中的列名}
|
| 23 |
+
:param date_col: 时间列的名字,如果为None将用索引作为时间列
|
| 24 |
+
:param right_axis: 右轴数据 (区域图/面积图) {图片上显示的名字:df中的列名}
|
| 25 |
+
:param pic_size: 图片的尺寸
|
| 26 |
+
:param chg: datadict中的数据是否为涨跌幅,True表示涨跌幅,False表示净值
|
| 27 |
+
:param title: 标题
|
| 28 |
+
:param path: 图片路径
|
| 29 |
+
:param show: 是否打开图片
|
| 30 |
+
:param right_axis_lines: 右轴数据 (线图) {图片上显示的名字:df中的列名}
|
| 31 |
+
:return:
|
| 32 |
+
"""
|
| 33 |
+
if pic_size is None:
|
| 34 |
+
pic_size = [1500, 800]
|
| 35 |
+
|
| 36 |
+
draw_df = df.copy()
|
| 37 |
+
|
| 38 |
+
# 设置时间序列
|
| 39 |
+
if date_col:
|
| 40 |
+
time_data = draw_df[date_col]
|
| 41 |
+
else:
|
| 42 |
+
time_data = draw_df.index
|
| 43 |
+
|
| 44 |
+
# 绘制左轴数据
|
| 45 |
+
fig = make_subplots(
|
| 46 |
+
rows=3 if show_subplots else 1, cols=1,
|
| 47 |
+
shared_xaxes=True, # 共享 x 轴,主,子图共同变化
|
| 48 |
+
vertical_spacing=0.02, # 减少主图和子图之间的间距
|
| 49 |
+
row_heights=[0.8, 0.1, 0.1] if show_subplots else [1.0], # 主图高度占 70%,子图各占 10%
|
| 50 |
+
specs=[[{"secondary_y": True}], [{"secondary_y": False}], [{"secondary_y": False}]] if show_subplots else [[{"secondary_y": True}]]
|
| 51 |
+
)
|
| 52 |
+
for key in data_dict:
|
| 53 |
+
if chg:
|
| 54 |
+
draw_df[data_dict[key]] = (draw_df[data_dict[key]] + 1).fillna(1).cumprod()
|
| 55 |
+
fig.add_trace(go.Scatter(x=time_data, y=draw_df[data_dict[key]], name=key, ), row=1, col=1)
|
| 56 |
+
|
| 57 |
+
# 绘制右轴数据 (面积图 - 通常用于回撤)
|
| 58 |
+
if right_axis:
|
| 59 |
+
key = list(right_axis.keys())[0]
|
| 60 |
+
fig.add_trace(go.Scatter(x=time_data, y=draw_df[right_axis[key]], name=key + '(右轴)',
|
| 61 |
+
# marker=dict(color='rgba(220, 220, 220, 0.8)'),
|
| 62 |
+
marker_color='orange',
|
| 63 |
+
opacity=0.1, line=dict(width=0),
|
| 64 |
+
fill='tozeroy',
|
| 65 |
+
yaxis='y2')) # 标明设置一个不同于trace1的一个坐标轴
|
| 66 |
+
for key in list(right_axis.keys())[1:]:
|
| 67 |
+
fig.add_trace(go.Scatter(x=time_data, y=draw_df[right_axis[key]], name=key + '(右轴)',
|
| 68 |
+
# marker=dict(color='rgba(220, 220, 220, 0.8)'),
|
| 69 |
+
opacity=0.1, line=dict(width=0),
|
| 70 |
+
fill='tozeroy',
|
| 71 |
+
yaxis='y2')) # 标明设置一个不同于trace1的一个坐标轴
|
| 72 |
+
|
| 73 |
+
# 绘制右轴数据 (线图 - 通常用于价格)
|
| 74 |
+
if right_axis_lines:
|
| 75 |
+
for key in right_axis_lines:
|
| 76 |
+
fig.add_trace(go.Scatter(x=time_data, y=draw_df[right_axis_lines[key]], name=key + '(右轴)',
|
| 77 |
+
mode='lines',
|
| 78 |
+
line=dict(width=1.5, color='rgba(46, 204, 113, 0.9)'),
|
| 79 |
+
opacity=0.9,
|
| 80 |
+
yaxis='y2'))
|
| 81 |
+
|
| 82 |
+
if markers:
|
| 83 |
+
for m in markers:
|
| 84 |
+
fig.add_trace(go.Scatter(
|
| 85 |
+
x=[m['time']],
|
| 86 |
+
y=[m['price']],
|
| 87 |
+
mode='markers+text',
|
| 88 |
+
name=m.get('text', 'Marker'),
|
| 89 |
+
text=[m.get('text', '')],
|
| 90 |
+
textposition="top center",
|
| 91 |
+
marker=dict(
|
| 92 |
+
symbol=m.get('symbol', 'x'),
|
| 93 |
+
size=m.get('size', 15),
|
| 94 |
+
color=m.get('color', 'red'),
|
| 95 |
+
line=dict(width=2, color='white')
|
| 96 |
+
),
|
| 97 |
+
yaxis='y2' if m.get('on_right_axis', False) else 'y1'
|
| 98 |
+
), row=1, col=1, secondary_y=m.get('on_right_axis', False))
|
| 99 |
+
|
| 100 |
+
if show_subplots:
|
| 101 |
+
# 子图:按照 matplotlib stackplot 风格实现堆叠图
|
| 102 |
+
# 最下面是多头仓位占比
|
| 103 |
+
fig.add_trace(go.Scatter(
|
| 104 |
+
x=time_data,
|
| 105 |
+
y=draw_df['long_cum'],
|
| 106 |
+
mode='lines',
|
| 107 |
+
line=dict(width=0),
|
| 108 |
+
fill='tozeroy',
|
| 109 |
+
fillcolor='rgba(30, 177, 0, 0.6)',
|
| 110 |
+
name='多头仓位占比',
|
| 111 |
+
hovertemplate="多头仓位占比: %{customdata:.4f}<extra></extra>",
|
| 112 |
+
customdata=draw_df['long_pos_ratio'] # 使用原始比例值
|
| 113 |
+
), row=2, col=1)
|
| 114 |
+
|
| 115 |
+
# 中间是空头仓位占比
|
| 116 |
+
fig.add_trace(go.Scatter(
|
| 117 |
+
x=time_data,
|
| 118 |
+
y=draw_df['short_cum'],
|
| 119 |
+
mode='lines',
|
| 120 |
+
line=dict(width=0),
|
| 121 |
+
fill='tonexty',
|
| 122 |
+
fillcolor='rgba(255, 99, 77, 0.6)',
|
| 123 |
+
name='空头仓位占比',
|
| 124 |
+
hovertemplate="空头仓位占比: %{customdata:.4f}<extra></extra>",
|
| 125 |
+
customdata=draw_df['short_pos_ratio'] # 使用原始比例值
|
| 126 |
+
), row=2, col=1)
|
| 127 |
+
|
| 128 |
+
# 最上面是空仓占比
|
| 129 |
+
fig.add_trace(go.Scatter(
|
| 130 |
+
x=time_data,
|
| 131 |
+
y=draw_df['empty_cum'],
|
| 132 |
+
mode='lines',
|
| 133 |
+
line=dict(width=0),
|
| 134 |
+
fill='tonexty',
|
| 135 |
+
fillcolor='rgba(0, 46, 77, 0.6)',
|
| 136 |
+
name='空仓占比',
|
| 137 |
+
hovertemplate="空仓占比: %{customdata:.4f}<extra></extra>",
|
| 138 |
+
customdata=draw_df['empty_ratio'] # 使用原始比例值
|
| 139 |
+
), row=2, col=1)
|
| 140 |
+
|
| 141 |
+
# 子图:右轴绘制 long_short_ratio 曲线
|
| 142 |
+
fig.add_trace(go.Scatter(
|
| 143 |
+
x=time_data,
|
| 144 |
+
y=draw_df['symbol_long_num'],
|
| 145 |
+
name='多头选币数量',
|
| 146 |
+
mode='lines',
|
| 147 |
+
line=dict(color='rgba(30, 177, 0, 0.6)', width=2)
|
| 148 |
+
), row=3, col=1)
|
| 149 |
+
|
| 150 |
+
fig.add_trace(go.Scatter(
|
| 151 |
+
x=time_data,
|
| 152 |
+
y=draw_df['symbol_short_num'],
|
| 153 |
+
name='空头选币数量',
|
| 154 |
+
mode='lines',
|
| 155 |
+
line=dict(color='rgba(255, 99, 77, 0.6)', width=2)
|
| 156 |
+
), row=3, col=1)
|
| 157 |
+
|
| 158 |
+
# 更新子图标题
|
| 159 |
+
fig.update_yaxes(title_text="仓位占比", row=2, col=1)
|
| 160 |
+
fig.update_yaxes(title_text="选币数量", row=3, col=1)
|
| 161 |
+
|
| 162 |
+
fig.update_layout(template="none", width=pic_size[0], height=pic_size[1], title_text=title,
|
| 163 |
+
hovermode="x unified", hoverlabel=dict(bgcolor='rgba(255,255,255,0.5)', ),
|
| 164 |
+
font=dict(family="PingFang SC, Hiragino Sans GB, Songti SC, Arial, sans-serif", size=12),
|
| 165 |
+
annotations=[
|
| 166 |
+
dict(
|
| 167 |
+
text=desc,
|
| 168 |
+
xref='paper',
|
| 169 |
+
yref='paper',
|
| 170 |
+
x=0.5,
|
| 171 |
+
y=1.05,
|
| 172 |
+
showarrow=False,
|
| 173 |
+
font=dict(size=12, color='black'),
|
| 174 |
+
align='center',
|
| 175 |
+
bgcolor='rgba(255,255,255,0.8)',
|
| 176 |
+
)
|
| 177 |
+
]
|
| 178 |
+
)
|
| 179 |
+
fig.update_layout(
|
| 180 |
+
updatemenus=[
|
| 181 |
+
dict(
|
| 182 |
+
buttons=[
|
| 183 |
+
dict(label="线性 y轴",
|
| 184 |
+
method="relayout",
|
| 185 |
+
args=[{"yaxis.type": "linear"}]),
|
| 186 |
+
dict(label="对数 y轴",
|
| 187 |
+
method="relayout",
|
| 188 |
+
args=[{"yaxis.type": "log"}]),
|
| 189 |
+
])],
|
| 190 |
+
)
|
| 191 |
+
plot(figure_or_data=fig, filename=str(path.resolve()), auto_open=False)
|
| 192 |
+
|
| 193 |
+
fig.update_yaxes(
|
| 194 |
+
showspikes=True, spikemode='across', spikesnap='cursor', spikedash='solid', spikethickness=1, # 峰线
|
| 195 |
+
)
|
| 196 |
+
fig.update_xaxes(
|
| 197 |
+
showspikes=True, spikemode='across+marker', spikesnap='cursor', spikedash='solid', spikethickness=1, # 峰线
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# 打开图片的html文件,需要判断系统的类型
|
| 201 |
+
if show:
|
| 202 |
+
fig.show()
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def plotly_plot(draw_df: pd.DataFrame, save_dir: str, name: str):
|
| 206 |
+
rows = len(draw_df.columns)
|
| 207 |
+
s = (1 / (rows - 1)) * 0.5
|
| 208 |
+
fig = subplots.make_subplots(rows=rows, cols=1, shared_xaxes=True, shared_yaxes=True, vertical_spacing=s)
|
| 209 |
+
|
| 210 |
+
for i, col_name in enumerate(draw_df.columns):
|
| 211 |
+
trace = go.Bar(x=draw_df.index, y=draw_df[col_name], name=f"{col_name}")
|
| 212 |
+
fig.add_trace(trace, i + 1, 1)
|
| 213 |
+
# 更新每个子图的x轴属性
|
| 214 |
+
fig.update_xaxes(showticklabels=True, row=i + 1, col=1) # 旋转x轴标签以避免重叠
|
| 215 |
+
|
| 216 |
+
# 更新每个子图的y轴标题
|
| 217 |
+
for i, col_name in enumerate(draw_df.columns):
|
| 218 |
+
fig.update_xaxes(title_text=col_name, row=i + 1, col=1)
|
| 219 |
+
|
| 220 |
+
fig.update_layout(height=200 * rows, showlegend=True, title_text=name)
|
| 221 |
+
fig.write_html(str((Path(save_dir) / f"{name}.html").resolve()))
|
| 222 |
+
fig.show()
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def mat_heatmap(draw_df: pd.DataFrame, name: str):
|
| 226 |
+
sns.set() # 设置一下展示的主题和样式
|
| 227 |
+
plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans', 'Font 119']
|
| 228 |
+
plt.title(name) # 设置标题
|
| 229 |
+
sns.heatmap(draw_df, annot=True, xticklabels=draw_df.columns, yticklabels=draw_df.index, fmt='.2f') # 画图
|
| 230 |
+
plt.show()
|
基础库/common_core/backtest/metrics.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
Quant Unified 量化交易系统
|
| 4 |
+
[统一回测指标模块]
|
| 5 |
+
|
| 6 |
+
功能:
|
| 7 |
+
为所有策略提供统一的回测绩效指标计算,避免重复开发。
|
| 8 |
+
一次计算,处处复用 —— 所有策略(1-8号)都调用这个模块。
|
| 9 |
+
|
| 10 |
+
支持的指标:
|
| 11 |
+
- 年化收益率 (CAGR)
|
| 12 |
+
- 对数收益率 (Log Return)
|
| 13 |
+
- 最大回撤 (Max Drawdown)
|
| 14 |
+
- 最大回撤恢复时间 (Recovery Time)
|
| 15 |
+
- 卡玛比率 (Calmar Ratio)
|
| 16 |
+
- 夏普比率 (Sharpe Ratio)
|
| 17 |
+
- 索提诺比率 (Sortino Ratio)
|
| 18 |
+
- 胜率 (Win Rate)
|
| 19 |
+
- 盈亏比 (Profit Factor)
|
| 20 |
+
- 交易次数 (Trade Count)
|
| 21 |
+
|
| 22 |
+
使用方法:
|
| 23 |
+
```python
|
| 24 |
+
from 基础库.common_core.backtest.metrics import 回测指标计算器
|
| 25 |
+
|
| 26 |
+
# 方式1: 传入权益曲线数组
|
| 27 |
+
计算器 = 回测指标计算器(权益曲线=equity_values, 初始资金=10000)
|
| 28 |
+
计算器.打印报告()
|
| 29 |
+
指标字典 = 计算器.获取指标()
|
| 30 |
+
|
| 31 |
+
# 方式2: 传入 DataFrame (需包含 equity 列)
|
| 32 |
+
计算器 = 回测指标计算器.从DataFrame创建(df, 权益列名='equity')
|
| 33 |
+
计算器.打印报告()
|
| 34 |
+
```
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
import numpy as np
|
| 38 |
+
import pandas as pd
|
| 39 |
+
from dataclasses import dataclass, field
|
| 40 |
+
from typing import Optional, Dict, Any, Union, List
|
| 41 |
+
from datetime import timedelta
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class 回测指标结果:
|
| 46 |
+
"""回测指标结果数据类"""
|
| 47 |
+
# 基础信息
|
| 48 |
+
初始资金: float = 0.0
|
| 49 |
+
最终资金: float = 0.0
|
| 50 |
+
总收益: float = 0.0
|
| 51 |
+
总收益率: float = 0.0
|
| 52 |
+
|
| 53 |
+
# 收益指标
|
| 54 |
+
年化收益率: float = 0.0 # CAGR
|
| 55 |
+
对数收益率: float = 0.0 # Log Return
|
| 56 |
+
|
| 57 |
+
# 风险指标
|
| 58 |
+
最大回撤: float = 0.0 # Max Drawdown (负数)
|
| 59 |
+
最大回撤百分比: str = "" # 格式化显示
|
| 60 |
+
最大回撤开始时间: Optional[str] = None
|
| 61 |
+
最大回撤结束时间: Optional[str] = None
|
| 62 |
+
最大回撤恢复时间: Optional[str] = None # 恢复到前高的时间
|
| 63 |
+
最大回撤恢复天数: int = 0
|
| 64 |
+
|
| 65 |
+
# 风险调整收益
|
| 66 |
+
卡玛比率: float = 0.0 # Calmar Ratio = 年化收益 / |最大回撤|
|
| 67 |
+
夏普比率: float = 0.0 # Sharpe Ratio
|
| 68 |
+
索提诺比率: float = 0.0 # Sortino Ratio
|
| 69 |
+
年化波动率: float = 0.0 # Annualized Volatility
|
| 70 |
+
|
| 71 |
+
# 交易统计
|
| 72 |
+
总周期数: int = 0
|
| 73 |
+
盈利周期数: int = 0
|
| 74 |
+
亏损周期数: int = 0
|
| 75 |
+
胜率: float = 0.0
|
| 76 |
+
盈亏比: float = 0.0 # Profit Factor
|
| 77 |
+
最大连续盈利周期: int = 0
|
| 78 |
+
最大连续亏损周期: int = 0
|
| 79 |
+
|
| 80 |
+
# 其他
|
| 81 |
+
交易次数: int = 0
|
| 82 |
+
回测天数: int = 0
|
| 83 |
+
|
| 84 |
+
def 转为字典(self) -> Dict[str, Any]:
|
| 85 |
+
"""转换为字典格式"""
|
| 86 |
+
return {
|
| 87 |
+
"初始资金": self.初始资金,
|
| 88 |
+
"最终资金": self.最终资金,
|
| 89 |
+
"总收益": self.总收益,
|
| 90 |
+
"总收益率": f"{self.总收益率:.2%}",
|
| 91 |
+
"年化收益率": f"{self.年化收益率:.2%}",
|
| 92 |
+
"对数收益率": f"{self.对数收益率:.4f}",
|
| 93 |
+
"最大回撤": f"{self.最大回撤:.2%}",
|
| 94 |
+
"最大回撤恢复天数": self.最大回撤恢复天数,
|
| 95 |
+
"卡玛比率": f"{self.卡玛比率:.2f}",
|
| 96 |
+
"夏普比率": f"{self.夏普比率:.2f}",
|
| 97 |
+
"索提诺比率": f"{self.索提诺比率:.2f}",
|
| 98 |
+
"年化波动率": f"{self.年化波动率:.2%}",
|
| 99 |
+
"胜率": f"{self.胜率:.2%}",
|
| 100 |
+
"盈亏比": f"{self.盈亏比:.2f}",
|
| 101 |
+
"交易次数": self.交易次数,
|
| 102 |
+
"回测天数": self.回测天数,
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class 回测指标计算器:
|
| 107 |
+
"""
|
| 108 |
+
统一回测指标计算器
|
| 109 |
+
|
| 110 |
+
这个类就像一个"成绩单生成器":
|
| 111 |
+
你把考试成绩(权益曲线)交给它,它会自动帮你算出:
|
| 112 |
+
- 平均分是多少 (年化收益)
|
| 113 |
+
- 最差的一次考了多少 (最大回撤)
|
| 114 |
+
- 成绩稳不稳定 (夏普比率)
|
| 115 |
+
等等一系列指标。
|
| 116 |
+
"""
|
| 117 |
+
|
| 118 |
+
def __init__(
|
| 119 |
+
self,
|
| 120 |
+
权益曲线: Union[np.ndarray, List[float], pd.Series],
|
| 121 |
+
初始资金: float = 10000.0,
|
| 122 |
+
时间戳: Optional[Union[np.ndarray, List, pd.DatetimeIndex]] = None,
|
| 123 |
+
持仓序列: Optional[Union[np.ndarray, List[float], pd.Series]] = None,
|
| 124 |
+
无风险利率: float = 0.0, # 年化无风险利率,加密货币通常设为 0
|
| 125 |
+
周期每年数量: int = 525600, # 分钟级数据: 365.25 * 24 * 60
|
| 126 |
+
):
|
| 127 |
+
"""
|
| 128 |
+
初始化回测指标计算器
|
| 129 |
+
|
| 130 |
+
参数:
|
| 131 |
+
权益曲线: 账户总资产序列 (如 [10000, 10100, 10050, ...])
|
| 132 |
+
初始资金: 初始本金
|
| 133 |
+
时间戳: 可选,每个数据点对应的时间戳
|
| 134 |
+
持仓序列: 可选,持仓状态序列 (如 [0, 1, 1, -1, 0, ...]),用于计算交易次数
|
| 135 |
+
无风险利率: 年化无风险收益率,默认 0 (加密货币市场)
|
| 136 |
+
周期每年数量: 每年有多少���周期,用于年化
|
| 137 |
+
- 分钟级: 525600 (365.25 * 24 * 60)
|
| 138 |
+
- 小时级: 8766 (365.25 * 24)
|
| 139 |
+
- 日级: 365
|
| 140 |
+
"""
|
| 141 |
+
# 转换为 numpy 数组
|
| 142 |
+
self.权益 = np.array(权益曲线, dtype=np.float64)
|
| 143 |
+
self.初始资金 = float(初始资金)
|
| 144 |
+
self.无风险利率 = 无风险利率
|
| 145 |
+
self.周期每年数量 = 周期每年数量
|
| 146 |
+
|
| 147 |
+
# 时间戳处理
|
| 148 |
+
if 时间戳 is not None:
|
| 149 |
+
self.时间戳 = pd.to_datetime(时间戳)
|
| 150 |
+
else:
|
| 151 |
+
self.时间戳 = None
|
| 152 |
+
|
| 153 |
+
# 持仓序列
|
| 154 |
+
if 持仓序列 is not None:
|
| 155 |
+
self.持仓 = np.array(持仓序列, dtype=np.float64)
|
| 156 |
+
else:
|
| 157 |
+
self.持仓 = None
|
| 158 |
+
|
| 159 |
+
# 预计算
|
| 160 |
+
self._计算收益率序列()
|
| 161 |
+
|
| 162 |
+
def _计算收益率序列(self):
|
| 163 |
+
"""计算周期收益率序列"""
|
| 164 |
+
# 简单收益率: (P_t - P_{t-1}) / P_{t-1}
|
| 165 |
+
self.收益率 = np.diff(self.权益) / self.权益[:-1]
|
| 166 |
+
# 处理 NaN 和 Inf
|
| 167 |
+
self.收益率 = np.nan_to_num(self.收益率, nan=0.0, posinf=0.0, neginf=0.0)
|
| 168 |
+
|
| 169 |
+
# 对数收益率: ln(P_t / P_{t-1})
|
| 170 |
+
with np.errstate(divide='ignore', invalid='ignore'):
|
| 171 |
+
self.对数收益率序列 = np.log(self.权益[1:] / self.权益[:-1])
|
| 172 |
+
self.对数收益率序列 = np.nan_to_num(self.对数收益率序列, nan=0.0, posinf=0.0, neginf=0.0)
|
| 173 |
+
|
| 174 |
+
@classmethod
|
| 175 |
+
def 从DataFrame创建(
|
| 176 |
+
cls,
|
| 177 |
+
df: pd.DataFrame,
|
| 178 |
+
权益列名: str = 'equity',
|
| 179 |
+
时间列名: str = 'candle_begin_time',
|
| 180 |
+
持仓列名: Optional[str] = None,
|
| 181 |
+
初始资金: Optional[float] = None,
|
| 182 |
+
**kwargs
|
| 183 |
+
) -> '回测指标计算器':
|
| 184 |
+
"""
|
| 185 |
+
从 DataFrame 创建计算器
|
| 186 |
+
|
| 187 |
+
参数:
|
| 188 |
+
df: 包含回测数据的 DataFrame
|
| 189 |
+
权益列名: 权益/净值列的名称
|
| 190 |
+
时间列名: 时间戳列的名称
|
| 191 |
+
持仓列名: 可选,持仓列的名称
|
| 192 |
+
初始资金: 可选,如不提供则使用权益曲线第一个值
|
| 193 |
+
"""
|
| 194 |
+
权益 = df[权益列名].values
|
| 195 |
+
|
| 196 |
+
时间戳 = None
|
| 197 |
+
if 时间列名 in df.columns:
|
| 198 |
+
时间戳 = df[时间列名].values
|
| 199 |
+
elif isinstance(df.index, pd.DatetimeIndex):
|
| 200 |
+
时间戳 = df.index
|
| 201 |
+
|
| 202 |
+
持仓 = None
|
| 203 |
+
if 持仓列名 and 持仓列名 in df.columns:
|
| 204 |
+
持仓 = df[持仓列名].values
|
| 205 |
+
|
| 206 |
+
if 初始资金 is None:
|
| 207 |
+
初始资金 = 权益[0]
|
| 208 |
+
|
| 209 |
+
return cls(
|
| 210 |
+
权益曲线=权益,
|
| 211 |
+
初始资金=初始资金,
|
| 212 |
+
时间戳=时间戳,
|
| 213 |
+
持仓序列=持仓,
|
| 214 |
+
**kwargs
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
def 计算全部指标(self) -> 回测指标结果:
|
| 218 |
+
"""计算所有回测指标,返回结构化结果"""
|
| 219 |
+
结果 = 回测指标结果()
|
| 220 |
+
|
| 221 |
+
# 1. 基础信息
|
| 222 |
+
结果.初始资金 = self.初始资金
|
| 223 |
+
结果.最终资金 = self.权益[-1]
|
| 224 |
+
结果.总收益 = 结果.最终资金 - 结果.初始资金
|
| 225 |
+
结果.总收益率 = 结果.总收益 / 结果.初始资金
|
| 226 |
+
结果.总周期数 = len(self.权益)
|
| 227 |
+
|
| 228 |
+
# 2. 回测天数
|
| 229 |
+
if self.时间戳 is not None and len(self.时间戳) > 1:
|
| 230 |
+
结果.回测天数 = (self.时间戳[-1] - self.时间戳[0]).days
|
| 231 |
+
else:
|
| 232 |
+
结果.回测天数 = len(self.权益) // (24 * 60) # 假设分钟级数据
|
| 233 |
+
|
| 234 |
+
# 3. 年化收益率 (CAGR)
|
| 235 |
+
# CAGR = (最终净值 / 初始净值) ^ (1 / 年数) - 1
|
| 236 |
+
年数 = max(结果.回测天数 / 365.25, 0.001) # 防止除零
|
| 237 |
+
净值终点 = 结果.最终资金 / 结果.初始资金
|
| 238 |
+
if 净值终点 > 0:
|
| 239 |
+
结果.年化收益率 = (净值终点 ** (1 / 年数)) - 1
|
| 240 |
+
else:
|
| 241 |
+
结果.年化收益率 = -1.0
|
| 242 |
+
|
| 243 |
+
# 4. 对数收益率 (总体)
|
| 244 |
+
# Log Return = ln(最终净值 / 初始净值)
|
| 245 |
+
if 净值终点 > 0:
|
| 246 |
+
结果.对数收益率 = np.log(净值终点)
|
| 247 |
+
else:
|
| 248 |
+
结果.对数收益率 = float('-inf')
|
| 249 |
+
|
| 250 |
+
# 5. 最大回撤
|
| 251 |
+
回撤结果 = self._计算最大回撤()
|
| 252 |
+
结果.最大回撤 = 回撤结果['最大回撤']
|
| 253 |
+
结果.最大回撤百分比 = f"{回撤结果['最大回撤']:.2%}"
|
| 254 |
+
结果.最大回撤开始时间 = 回撤结果.get('开始时间')
|
| 255 |
+
结果.最大回撤结束时间 = 回撤结果.get('结束时间')
|
| 256 |
+
结果.最大回撤恢复时间 = 回撤结果.get('恢复时间')
|
| 257 |
+
结果.最大回撤恢复天数 = 回撤结果.get('恢复天数', 0)
|
| 258 |
+
|
| 259 |
+
# 6. 卡玛比率 (Calmar Ratio)
|
| 260 |
+
# Calmar = 年化收益率 / |最大回撤|
|
| 261 |
+
if abs(结果.最大回撤) > 1e-9:
|
| 262 |
+
结果.卡玛比率 = 结果.年化收益率 / abs(结果.最大回撤)
|
| 263 |
+
else:
|
| 264 |
+
结果.卡玛比率 = float('inf') if 结果.年化收益率 > 0 else 0.0
|
| 265 |
+
|
| 266 |
+
# 7. 波动率和夏普比率
|
| 267 |
+
if len(self.收益率) > 1:
|
| 268 |
+
# 年化波动率 = 周期标准差 * sqrt(周期每年数量)
|
| 269 |
+
结果.年化波动率 = np.std(self.收益率) * np.sqrt(self.周期每年数量)
|
| 270 |
+
|
| 271 |
+
# 夏普比率 = (年化收益 - 无风险利率) / 年化波动率
|
| 272 |
+
if 结果.年化波动率 > 1e-9:
|
| 273 |
+
结果.夏普比率 = (结果.年化收益率 - self.无风险利率) / 结果.年化波动率
|
| 274 |
+
else:
|
| 275 |
+
结果.夏普比率 = 0.0
|
| 276 |
+
|
| 277 |
+
# 索提诺比率 = (年化收益 - 无风险利率) / 下行波动率
|
| 278 |
+
下行收益 = self.收益率[self.收益率 < 0]
|
| 279 |
+
if len(下行收益) > 0:
|
| 280 |
+
下行波动率 = np.std(下行收益) * np.sqrt(self.周期每年数量)
|
| 281 |
+
if 下行波动率 > 1e-9:
|
| 282 |
+
结果.索提诺比率 = (结果.年化收益率 - self.无风险利率) / 下行波动率
|
| 283 |
+
|
| 284 |
+
# 8. 胜率和盈亏比
|
| 285 |
+
盈利周期 = self.收益率[self.收益率 > 0]
|
| 286 |
+
亏损周期 = self.收益率[self.收益率 < 0]
|
| 287 |
+
|
| 288 |
+
结果.盈利周期数 = len(盈利周期)
|
| 289 |
+
结果.亏损周期数 = len(亏损周期)
|
| 290 |
+
|
| 291 |
+
if len(self.收益率) > 0:
|
| 292 |
+
结果.胜率 = 结果.盈利周期数 / len(self.收益率)
|
| 293 |
+
|
| 294 |
+
if len(亏损周期) > 0 and np.sum(np.abs(亏损周期)) > 1e-9:
|
| 295 |
+
结果.盈亏比 = np.sum(盈利周期) / np.abs(np.sum(亏损周期))
|
| 296 |
+
|
| 297 |
+
# 9. 连续盈亏
|
| 298 |
+
结果.最大连续盈利周期 = self._计算最大连续(self.收益率 > 0)
|
| 299 |
+
结果.最大连续亏损周期 = self._计算最大连续(self.收益率 < 0)
|
| 300 |
+
|
| 301 |
+
# 10. 交易次数 (如果有持仓序列)
|
| 302 |
+
if self.持仓 is not None:
|
| 303 |
+
# 持仓变化就是交易
|
| 304 |
+
结果.交易次数 = int(np.sum(np.abs(np.diff(self.持仓)) > 0))
|
| 305 |
+
|
| 306 |
+
return 结果
|
| 307 |
+
|
| 308 |
+
def _计算最大回撤(self) -> Dict[str, Any]:
|
| 309 |
+
"""
|
| 310 |
+
计算最大回撤及相关信息
|
| 311 |
+
|
| 312 |
+
最大回撤 = (峰值 - 谷值) / 峰值
|
| 313 |
+
就像股票从最高点跌到最低点的幅度
|
| 314 |
+
"""
|
| 315 |
+
if len(self.权益) < 2:
|
| 316 |
+
return {'最大回撤': 0.0}
|
| 317 |
+
|
| 318 |
+
# 计算滚动最高点 (累计最大值)
|
| 319 |
+
累计最高 = np.maximum.accumulate(self.权益)
|
| 320 |
+
|
| 321 |
+
# 计算回撤 (当前值相对于历史最高的跌幅)
|
| 322 |
+
# 回撤 = (当前值 - 最高值) / 最高值 (负数或零)
|
| 323 |
+
回撤序列 = (self.权益 - 累计最高) / 累计最高
|
| 324 |
+
|
| 325 |
+
# 最大回撤位置 (最低点)
|
| 326 |
+
最大回撤索引 = np.argmin(回撤序列)
|
| 327 |
+
最大回撤值 = 回撤序列[最大回撤索引]
|
| 328 |
+
|
| 329 |
+
# 最大回撤开始位置 (在最低点之前的最高点)
|
| 330 |
+
峰值索引 = np.argmax(self.权益[:最大回撤索引 + 1])
|
| 331 |
+
|
| 332 |
+
结果 = {
|
| 333 |
+
'最大回撤': 最大回撤值,
|
| 334 |
+
'回撤开始索引': 峰值索引,
|
| 335 |
+
'回撤结束索引': 最大回撤索引,
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
# 如果有时间戳,添加时间信息
|
| 339 |
+
if self.时间戳 is not None:
|
| 340 |
+
结果['开始时间'] = str(self.时间戳[峰值索引])
|
| 341 |
+
结果['结束时间'] = str(self.时间戳[最大回撤索引])
|
| 342 |
+
|
| 343 |
+
# 计算恢复时间 (从最低点恢复到前高)
|
| 344 |
+
峰值 = self.权益[峰值索引]
|
| 345 |
+
恢复索引 = None
|
| 346 |
+
for i in range(最大回撤索引 + 1, len(self.权益)):
|
| 347 |
+
if self.权益[i] >= 峰值:
|
| 348 |
+
恢复索引 = i
|
| 349 |
+
break
|
| 350 |
+
|
| 351 |
+
if 恢复索引 is not None:
|
| 352 |
+
结果['恢复时间'] = str(self.时间戳[恢复索引])
|
| 353 |
+
结果['恢复天数'] = (self.时间戳[恢复索引] - self.时间戳[最大回撤索引]).days
|
| 354 |
+
else:
|
| 355 |
+
结果['恢复天数'] = -1 # 未恢复
|
| 356 |
+
结果['恢复时间'] = "未恢复"
|
| 357 |
+
|
| 358 |
+
return 结果
|
| 359 |
+
|
| 360 |
+
def _计算最大连续(self, 条件数组: np.ndarray) -> int:
|
| 361 |
+
"""计算最大连续满足条件的周期数"""
|
| 362 |
+
if len(条件数组) == 0:
|
| 363 |
+
return 0
|
| 364 |
+
|
| 365 |
+
最大连续 = 0
|
| 366 |
+
当前连续 = 0
|
| 367 |
+
|
| 368 |
+
for 满足条件 in 条件数组:
|
| 369 |
+
if 满足条件:
|
| 370 |
+
当前连续 += 1
|
| 371 |
+
最大连续 = max(最大连续, 当前连续)
|
| 372 |
+
else:
|
| 373 |
+
当前连续 = 0
|
| 374 |
+
|
| 375 |
+
return 最大连续
|
| 376 |
+
|
| 377 |
+
def 获取指标(self) -> Dict[str, Any]:
|
| 378 |
+
"""获取指标字典"""
|
| 379 |
+
return self.计算全部指标().转为字典()
|
| 380 |
+
|
| 381 |
+
def 打印报告(self, 策略名称: str = "策略"):
|
| 382 |
+
"""
|
| 383 |
+
打印格式化的回测报告
|
| 384 |
+
|
| 385 |
+
输出一个漂亮的表格,展示所有关键指标
|
| 386 |
+
"""
|
| 387 |
+
结果 = self.计算全部指标()
|
| 388 |
+
|
| 389 |
+
# 构建分隔线
|
| 390 |
+
宽度 = 50
|
| 391 |
+
分隔线 = "═" * 宽度
|
| 392 |
+
细分隔线 = "─" * 宽度
|
| 393 |
+
|
| 394 |
+
print()
|
| 395 |
+
print(f"╔{分隔线}╗")
|
| 396 |
+
print(f"║{'📊 ' + 策略名称 + ' 回测报告':^{宽度-2}}║")
|
| 397 |
+
print(f"╠{分隔线}╣")
|
| 398 |
+
|
| 399 |
+
# 基础信息
|
| 400 |
+
print(f"║ {'💰 初始资金':<15}: {结果.初始资金:>18,.2f} USDT ║")
|
| 401 |
+
print(f"║ {'💎 最终资金':<15}: {结果.最终资金:>18,.2f} USDT ║")
|
| 402 |
+
print(f"║ {'📈 总收益率':<15}: {结果.总收益率:>18.2%} ║")
|
| 403 |
+
print(f"╠{细分隔线}╣")
|
| 404 |
+
|
| 405 |
+
# 收益指标
|
| 406 |
+
print(f"║ {'📅 年化收益率':<14}: {结果.年化收益率:>18.2%} ║")
|
| 407 |
+
print(f"║ {'📐 对数收益率':<14}: {结果.对数收益率:>18.4f} ║")
|
| 408 |
+
print(f"╠{细分隔线}╣")
|
| 409 |
+
|
| 410 |
+
# 风险指标
|
| 411 |
+
print(f"║ {'🌊 最大回撤':<15}: {结果.最大回撤:>18.2%} ║")
|
| 412 |
+
if 结果.最大回撤恢复天数 > 0:
|
| 413 |
+
print(f"║ {'⏱️ 回撤恢复天数':<13}: {结果.最大回撤恢复天数:>18} 天 ║")
|
| 414 |
+
elif 结果.最大回撤恢复天数 == -1:
|
| 415 |
+
print(f"║ {'⏱️ 回撤恢复天数':<13}: {'未恢复':>21} ║")
|
| 416 |
+
print(f"╠{细分隔线}╣")
|
| 417 |
+
|
| 418 |
+
# 风险调整收益
|
| 419 |
+
print(f"║ {'⚖️ 卡玛比率':<14}: {结果.卡玛比率:>18.2f} ║")
|
| 420 |
+
print(f"║ {'📊 夏普比率':<15}: {结果.夏普比率:>18.2f} ║")
|
| 421 |
+
print(f"║ {'📉 索提诺比率':<14}: {结果.索提诺比率:>18.2f} ║")
|
| 422 |
+
print(f"║ {'📈 年化波动率':<14}: {结果.年化波动率:>18.2%} ║")
|
| 423 |
+
print(f"╠{细分隔线}╣")
|
| 424 |
+
|
| 425 |
+
# 交易统计
|
| 426 |
+
print(f"║ {'🎯 胜率':<16}: {结果.胜率:>18.2%} ║")
|
| 427 |
+
print(f"║ {'💹 盈亏比':<15}: {结果.盈亏比:>18.2f} ║")
|
| 428 |
+
if 结果.交易次数 > 0:
|
| 429 |
+
print(f"║ {'🔄 交易次数':<15}: {结果.交易次数:>18} ║")
|
| 430 |
+
print(f"║ {'📆 回测天数':<15}: {结果.回测天数:>18} 天 ║")
|
| 431 |
+
|
| 432 |
+
print(f"╚{分隔线}╝")
|
| 433 |
+
print()
|
| 434 |
+
|
| 435 |
+
return 结果
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
# ============== 便捷函数 ==============
|
| 439 |
+
|
| 440 |
+
def 快速计算指标(
|
| 441 |
+
权益曲线: Union[np.ndarray, List[float]],
|
| 442 |
+
初始资金: float = 10000.0,
|
| 443 |
+
打印: bool = True,
|
| 444 |
+
策略名称: str = "策略"
|
| 445 |
+
) -> Dict[str, Any]:
|
| 446 |
+
"""
|
| 447 |
+
快速计算回测指标的便捷函数
|
| 448 |
+
|
| 449 |
+
使用方法:
|
| 450 |
+
from 基础库.common_core.backtest.metrics import 快速计算指标
|
| 451 |
+
|
| 452 |
+
指标 = 快速计算指标(equity_list, 初始资金=10000)
|
| 453 |
+
"""
|
| 454 |
+
计算器 = 回测指标计算器(权益曲线=权益曲线, 初始资金=初始资金)
|
| 455 |
+
if 打印:
|
| 456 |
+
计算器.打印报告(策略名称=策略名称)
|
| 457 |
+
return 计算器.获取指标()
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
# ============== 测试代码 ==============
|
| 461 |
+
|
| 462 |
+
if __name__ == "__main__":
|
| 463 |
+
# 生成模拟权益曲线 (用于测试)
|
| 464 |
+
np.random.seed(42)
|
| 465 |
+
天数 = 365
|
| 466 |
+
每天周期数 = 1440 # 分钟级
|
| 467 |
+
总周期 = 天数 * 每天周期数
|
| 468 |
+
|
| 469 |
+
# 模拟一个有波动的权益曲线
|
| 470 |
+
收益率 = np.random.normal(0.00001, 0.0005, 总周期) # 微小正漂移 + 波动
|
| 471 |
+
权益 = 10000 * np.cumprod(1 + 收益率)
|
| 472 |
+
|
| 473 |
+
# 插入一个大回撤
|
| 474 |
+
权益[int(总周期*0.3):int(总周期*0.4)] *= 0.7
|
| 475 |
+
|
| 476 |
+
# 测试计算器
|
| 477 |
+
计算器 = 回测指标计算器(权益曲线=权益, 初始资金=10000, 周期每年数量=每天周期数*365)
|
| 478 |
+
计算器.打印报告(策略名称="测试策略")
|
基础库/common_core/backtest/rebalance.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
rebalance.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import numba as nb
|
| 8 |
+
from numba.experimental import jitclass
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@jitclass
|
| 12 |
+
class RebAlways:
|
| 13 |
+
spot_lot_sizes: nb.float64[:] # 每手币数,表示一手加密货币中包含的币数
|
| 14 |
+
swap_lot_sizes: nb.float64[:]
|
| 15 |
+
|
| 16 |
+
def __init__(self, spot_lot_sizes, swap_lot_sizes):
|
| 17 |
+
n_syms_spot = len(spot_lot_sizes)
|
| 18 |
+
n_syms_swap = len(swap_lot_sizes)
|
| 19 |
+
|
| 20 |
+
self.spot_lot_sizes = np.zeros(n_syms_spot, dtype=np.float64)
|
| 21 |
+
self.spot_lot_sizes[:] = spot_lot_sizes
|
| 22 |
+
|
| 23 |
+
self.swap_lot_sizes = np.zeros(n_syms_swap, dtype=np.float64)
|
| 24 |
+
self.swap_lot_sizes[:] = swap_lot_sizes
|
| 25 |
+
|
| 26 |
+
def _calc(self, equity, prices, ratios, lot_sizes):
|
| 27 |
+
# 初始化目标持仓手数
|
| 28 |
+
target_lots = np.zeros(len(lot_sizes), dtype=np.int64)
|
| 29 |
+
|
| 30 |
+
# 每个币分配的资金(带方向)
|
| 31 |
+
symbol_equity = equity * ratios
|
| 32 |
+
|
| 33 |
+
# 分配资金大于 0.01U 则认为是有效持仓
|
| 34 |
+
mask = np.abs(symbol_equity) > 0.01
|
| 35 |
+
|
| 36 |
+
# 为有效持仓分配仓位
|
| 37 |
+
target_lots[mask] = (symbol_equity[mask] / prices[mask] / lot_sizes[mask]).astype(np.int64)
|
| 38 |
+
|
| 39 |
+
return target_lots
|
| 40 |
+
|
| 41 |
+
def calc_lots(self, equity, spot_prices, spot_lots, spot_ratios, swap_prices, swap_lots, swap_ratios):
|
| 42 |
+
"""
|
| 43 |
+
计算每个币种的目标手数
|
| 44 |
+
:param equity: 总权益
|
| 45 |
+
:param spot_prices: 现货最新价格
|
| 46 |
+
:param spot_lots: 现货当前持仓手数
|
| 47 |
+
:param spot_ratios: 现货币种的资金比例
|
| 48 |
+
:param swap_prices: 合约最新价格
|
| 49 |
+
:param swap_lots: 合约当前持仓手数
|
| 50 |
+
:param swap_ratios: 合约币种的资金比例
|
| 51 |
+
:return: tuple[现货目标手数, 合约目标手数]
|
| 52 |
+
"""
|
| 53 |
+
is_spot_only = False
|
| 54 |
+
|
| 55 |
+
# 合约总权重小于极小值,认为是纯现货模式
|
| 56 |
+
if np.sum(np.abs(swap_ratios)) < 1e-6:
|
| 57 |
+
is_spot_only = True
|
| 58 |
+
equity *= 0.99 # 纯现货留 1% 的资金作为缓冲
|
| 59 |
+
|
| 60 |
+
# 现货目标持仓手数
|
| 61 |
+
spot_target_lots = self._calc(equity, spot_prices, spot_ratios, self.spot_lot_sizes)
|
| 62 |
+
|
| 63 |
+
if is_spot_only:
|
| 64 |
+
swap_target_lots = np.zeros(len(self.swap_lot_sizes), dtype=np.int64)
|
| 65 |
+
return spot_target_lots, swap_target_lots
|
| 66 |
+
|
| 67 |
+
# 合约目标持仓手数
|
| 68 |
+
swap_target_lots = self._calc(equity, swap_prices, swap_ratios, self.swap_lot_sizes)
|
| 69 |
+
|
| 70 |
+
return spot_target_lots, swap_target_lots
|
基础库/common_core/backtest/signal.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
signal.py
|
| 4 |
+
|
| 5 |
+
概率信号 -> 仓位(带迟滞 / hysteresis)
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import numba as nb
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@nb.njit(cache=True)
|
| 15 |
+
def positions_from_probs_hysteresis(
|
| 16 |
+
p_up: np.ndarray,
|
| 17 |
+
p_down: np.ndarray,
|
| 18 |
+
p_enter: float = 0.55,
|
| 19 |
+
p_exit: float = 0.55,
|
| 20 |
+
diff_enter: float = 0.0,
|
| 21 |
+
diff_exit: float = 0.0,
|
| 22 |
+
init_pos: int = 0,
|
| 23 |
+
) -> np.ndarray:
|
| 24 |
+
"""
|
| 25 |
+
将 (p_up, p_down) 转换成 {-1,0,1} 仓位序列,并加入迟滞以降低来回翻仓。
|
| 26 |
+
|
| 27 |
+
规则(默认):
|
| 28 |
+
- 空仓:p_up>=p_enter 且 (p_up-p_down)>=diff_enter -> 做多;
|
| 29 |
+
p_down>=p_enter 且 (p_down-p_up)>=diff_enter -> 做空;
|
| 30 |
+
- 多仓:p_down>=p_exit 且 (p_down-p_up)>=diff_exit -> 反手做空;
|
| 31 |
+
- 空仓:p_up>=p_exit 且 (p_up-p_down)>=diff_exit -> 反手做多;
|
| 32 |
+
- 其他情况保持原仓位。
|
| 33 |
+
"""
|
| 34 |
+
n = len(p_up)
|
| 35 |
+
pos = np.empty(n, dtype=np.int8)
|
| 36 |
+
cur = np.int8(init_pos)
|
| 37 |
+
|
| 38 |
+
for i in range(n):
|
| 39 |
+
up = p_up[i]
|
| 40 |
+
down = p_down[i]
|
| 41 |
+
|
| 42 |
+
if np.isnan(up) or np.isnan(down):
|
| 43 |
+
pos[i] = cur
|
| 44 |
+
continue
|
| 45 |
+
|
| 46 |
+
if cur == 0:
|
| 47 |
+
if up >= p_enter and (up - down) >= diff_enter:
|
| 48 |
+
cur = np.int8(1)
|
| 49 |
+
elif down >= p_enter and (down - up) >= diff_enter:
|
| 50 |
+
cur = np.int8(-1)
|
| 51 |
+
elif cur == 1:
|
| 52 |
+
if down >= p_exit and (down - up) >= diff_exit:
|
| 53 |
+
cur = np.int8(-1)
|
| 54 |
+
else: # cur == -1
|
| 55 |
+
if up >= p_exit and (up - down) >= diff_exit:
|
| 56 |
+
cur = np.int8(1)
|
| 57 |
+
|
| 58 |
+
pos[i] = cur
|
| 59 |
+
|
| 60 |
+
return pos
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def positions_from_probs_hysteresis_py(
|
| 64 |
+
p_up: np.ndarray,
|
| 65 |
+
p_down: np.ndarray,
|
| 66 |
+
p_enter: float = 0.55,
|
| 67 |
+
p_exit: float = 0.55,
|
| 68 |
+
diff_enter: float = 0.0,
|
| 69 |
+
diff_exit: float = 0.0,
|
| 70 |
+
init_pos: int = 0,
|
| 71 |
+
) -> np.ndarray:
|
| 72 |
+
"""
|
| 73 |
+
Python 版本(便于调试),输出与 numba 版一致。
|
| 74 |
+
"""
|
| 75 |
+
p_up = np.asarray(p_up, dtype=float)
|
| 76 |
+
p_down = np.asarray(p_down, dtype=float)
|
| 77 |
+
out = np.empty(len(p_up), dtype=np.int8)
|
| 78 |
+
cur = np.int8(init_pos)
|
| 79 |
+
|
| 80 |
+
for i, (up, down) in enumerate(zip(p_up, p_down)):
|
| 81 |
+
if np.isnan(up) or np.isnan(down):
|
| 82 |
+
out[i] = cur
|
| 83 |
+
continue
|
| 84 |
+
if cur == 0:
|
| 85 |
+
if up >= p_enter and (up - down) >= diff_enter:
|
| 86 |
+
cur = np.int8(1)
|
| 87 |
+
elif down >= p_enter and (down - up) >= diff_enter:
|
| 88 |
+
cur = np.int8(-1)
|
| 89 |
+
elif cur == 1:
|
| 90 |
+
if down >= p_exit and (down - up) >= diff_exit:
|
| 91 |
+
cur = np.int8(-1)
|
| 92 |
+
else:
|
| 93 |
+
if up >= p_exit and (up - down) >= diff_exit:
|
| 94 |
+
cur = np.int8(1)
|
| 95 |
+
out[i] = cur
|
| 96 |
+
|
| 97 |
+
return out
|
| 98 |
+
|
基础库/common_core/backtest/simulator.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
[高性能回测仿真器]
|
| 4 |
+
功能:基于 Numba 加速,模拟交易所撮合逻辑,处理开平仓、资金费结算,提供极速的回测执行。
|
| 5 |
+
"""
|
| 6 |
+
import numba as nb
|
| 7 |
+
import numpy as np
|
| 8 |
+
from numba.experimental import jitclass
|
| 9 |
+
|
| 10 |
+
"""
|
| 11 |
+
# 新语法小讲堂
|
| 12 |
+
通过操作对象的值而不是更换reference,来保证所有引用的位置都能同步更新。
|
| 13 |
+
|
| 14 |
+
`self.target_lots[:] = target_lots`
|
| 15 |
+
这个写法涉及 Python 中的切片(slice)操作和对象的属性赋值。
|
| 16 |
+
|
| 17 |
+
`target_lots: nb.int64[:] # 目标持仓手数`,self.target_lots 是一个列表,`[:]` 是切片操作符,表示对整个列表进行切片。
|
| 18 |
+
|
| 19 |
+
### 详细解释:
|
| 20 |
+
|
| 21 |
+
1. **`self.target_lots[:] = target_lots`**:
|
| 22 |
+
- `self.target_lots` 是对象的一个属性,通常是一个列表(或者其它支持切片操作的可变序列)。
|
| 23 |
+
- `[:]` 是切片操作符,表示对整个列表进行切片。具体来说,`[:]` 是对列表的所有元素进行选择,这种写法可以用于复制列表或对整个列表内容进行替换。
|
| 24 |
+
|
| 25 |
+
2. **具体操作**:
|
| 26 |
+
- `self.target_lots[:] = target_lots` 不是直接将 `target_lots` 赋值给 `self.target_lots`,而是将 `target_lots` 中的所有元素替换 `self.target_lots` 中的所有元素。
|
| 27 |
+
- 这种做法的一个好处是不会改变 `self.target_lots` 对象的引用,而是修改它的内容。这在有其他对象引用 `self.target_lots` 时非常有用,确保所有引用者看到的列表内容都被更新,而不会因为重新赋值而改变列表的引用。
|
| 28 |
+
|
| 29 |
+
### 举个例子:
|
| 30 |
+
|
| 31 |
+
```python
|
| 32 |
+
a = [1, 2, 3]
|
| 33 |
+
b = a
|
| 34 |
+
a[:] = [4, 5, 6] # 只改变列表内容,不改变引用
|
| 35 |
+
|
| 36 |
+
print(a) # 输出: [4, 5, 6]
|
| 37 |
+
print(b) # 输出: [4, 5, 6],因为 a 和 b 引用的是同一个列表,修改 a 的内容也影响了 b
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
如果直接用 `a = [4, 5, 6]` 替换 `[:]` 操作,那么 `b` 就不会受到影响,因为 `a` 重新指向了一个新的列表对象。
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@jitclass
|
| 45 |
+
class Simulator:
|
| 46 |
+
equity: float # 账户权益, 单位 USDT
|
| 47 |
+
fee_rate: float # 手续费率(单边)
|
| 48 |
+
slippage_rate: float # 滑点率(单边,按成交额计)
|
| 49 |
+
min_order_limit: float # 最小下单金额
|
| 50 |
+
|
| 51 |
+
lot_sizes: nb.float64[:] # 每手币数,表示一手加密货币中包含的币数
|
| 52 |
+
lots: nb.int64[:] # 当前持仓手数
|
| 53 |
+
target_lots: nb.int64[:] # 目标持仓手数
|
| 54 |
+
|
| 55 |
+
last_prices: nb.float64[:] # 最新价格
|
| 56 |
+
has_last_prices: bool # 是否有最新价
|
| 57 |
+
|
| 58 |
+
def __init__(self, init_capital, lot_sizes, fee_rate, slippage_rate, init_lots, min_order_limit):
|
| 59 |
+
"""
|
| 60 |
+
初始化
|
| 61 |
+
:param init_capital: 初始资金
|
| 62 |
+
:param lot_sizes: 每个币种的最小下单量
|
| 63 |
+
:param fee_rate: 手续费率(单边)
|
| 64 |
+
:param slippage_rate: 滑点率(单边,按成交额计)
|
| 65 |
+
:param init_lots: 初始持仓
|
| 66 |
+
:param min_order_limit: 最小下单金额
|
| 67 |
+
"""
|
| 68 |
+
self.equity = init_capital # 账户权益
|
| 69 |
+
self.fee_rate = fee_rate # 手续费
|
| 70 |
+
self.slippage_rate = slippage_rate # 滑点
|
| 71 |
+
self.min_order_limit = min_order_limit # 最小下单金额
|
| 72 |
+
|
| 73 |
+
n = len(lot_sizes)
|
| 74 |
+
|
| 75 |
+
# 合约面值
|
| 76 |
+
self.lot_sizes = np.zeros(n, dtype=np.float64)
|
| 77 |
+
self.lot_sizes[:] = lot_sizes
|
| 78 |
+
|
| 79 |
+
# 前收盘价
|
| 80 |
+
self.last_prices = np.zeros(n, dtype=np.float64)
|
| 81 |
+
self.has_last_prices = False
|
| 82 |
+
|
| 83 |
+
# 当前持仓手数
|
| 84 |
+
self.lots = np.zeros(n, dtype=np.int64)
|
| 85 |
+
self.lots[:] = init_lots
|
| 86 |
+
|
| 87 |
+
# 目标持仓手数
|
| 88 |
+
self.target_lots = np.zeros(n, dtype=np.int64)
|
| 89 |
+
self.target_lots[:] = init_lots
|
| 90 |
+
|
| 91 |
+
def set_target_lots(self, target_lots):
|
| 92 |
+
self.target_lots[:] = target_lots
|
| 93 |
+
|
| 94 |
+
def fill_last_prices(self, prices):
|
| 95 |
+
mask = np.logical_not(np.isnan(prices))
|
| 96 |
+
self.last_prices[mask] = prices[mask]
|
| 97 |
+
self.has_last_prices = True
|
| 98 |
+
|
| 99 |
+
def settle_equity(self, prices):
|
| 100 |
+
"""
|
| 101 |
+
结算当前账户权益
|
| 102 |
+
:param prices: 当前价格
|
| 103 |
+
:return:
|
| 104 |
+
"""
|
| 105 |
+
mask = np.logical_and(self.lots != 0, np.logical_not(np.isnan(prices)))
|
| 106 |
+
# 计算公式:
|
| 107 |
+
# 1. 净值涨跌 = (最新价格 - 前最新价(前收盘价)) * 持币数量。
|
| 108 |
+
# 2. 其中,持币数量 = min_qty * 持仓手数。
|
| 109 |
+
# 3. 所有币种对应的净值涨跌累加起来
|
| 110 |
+
equity_delta = np.sum((prices[mask] - self.last_prices[mask]) * self.lot_sizes[mask] * self.lots[mask])
|
| 111 |
+
|
| 112 |
+
# 反映到净值上
|
| 113 |
+
self.equity += equity_delta
|
| 114 |
+
|
| 115 |
+
def on_open(self, open_prices, funding_rates, mark_prices):
|
| 116 |
+
"""
|
| 117 |
+
模拟: K 线开盘 -> K 线收盘时刻
|
| 118 |
+
:param open_prices: 开盘价
|
| 119 |
+
:param funding_rates: 资金费
|
| 120 |
+
:param mark_prices: 计算资金费的标记价格(目前就用开盘价来)
|
| 121 |
+
:return:
|
| 122 |
+
"""
|
| 123 |
+
if not self.has_last_prices:
|
| 124 |
+
self.fill_last_prices(open_prices)
|
| 125 |
+
|
| 126 |
+
# 根据开盘价和前最新价(前收盘价),结算当前账户权益
|
| 127 |
+
self.settle_equity(open_prices)
|
| 128 |
+
|
| 129 |
+
# 根据标记价格和资金费率,结算资金费盈亏
|
| 130 |
+
mask = np.logical_and(self.lots != 0, np.logical_not(np.isnan(mark_prices)))
|
| 131 |
+
pos_val = notional_value = self.lot_sizes[mask] * self.lots[mask] * mark_prices[mask]
|
| 132 |
+
funding_fee = np.sum(notional_value * funding_rates[mask])
|
| 133 |
+
self.equity -= funding_fee
|
| 134 |
+
|
| 135 |
+
# 最新价为开盘价
|
| 136 |
+
self.fill_last_prices(open_prices)
|
| 137 |
+
|
| 138 |
+
# 返回扣除资金费后开盘账户权益、资金费和带方向的仓位名义价值
|
| 139 |
+
return self.equity, funding_fee, pos_val
|
| 140 |
+
|
| 141 |
+
def on_execution(self, exec_prices):
|
| 142 |
+
"""
|
| 143 |
+
模拟: K 线开盘时刻 -> 调仓时刻
|
| 144 |
+
:param exec_prices: 执行价格
|
| 145 |
+
:return: 调仓后的账户权益、调仓后的仓位名义价值
|
| 146 |
+
"""
|
| 147 |
+
if not self.has_last_prices:
|
| 148 |
+
self.fill_last_prices(exec_prices)
|
| 149 |
+
|
| 150 |
+
# 根据调仓价和前最新价(开盘价),结算当前账户权益
|
| 151 |
+
self.settle_equity(exec_prices)
|
| 152 |
+
|
| 153 |
+
# 计算需要买入或卖出的合约数量
|
| 154 |
+
delta = self.target_lots - self.lots
|
| 155 |
+
mask = np.logical_and(delta != 0, np.logical_not(np.isnan(exec_prices)))
|
| 156 |
+
|
| 157 |
+
# 计算成交额
|
| 158 |
+
turnover = np.zeros(len(self.lot_sizes), dtype=np.float64)
|
| 159 |
+
turnover[mask] = np.abs(delta[mask]) * self.lot_sizes[mask] * exec_prices[mask]
|
| 160 |
+
|
| 161 |
+
# 成交额小于 min_order_limit 则无法调仓
|
| 162 |
+
mask = np.logical_and(mask, turnover >= self.min_order_limit)
|
| 163 |
+
|
| 164 |
+
# 本期调仓总成交额
|
| 165 |
+
turnover_total = turnover[mask].sum()
|
| 166 |
+
|
| 167 |
+
if np.isnan(turnover_total):
|
| 168 |
+
raise RuntimeError('Turnover is nan')
|
| 169 |
+
|
| 170 |
+
# 根据总成交额计算并扣除手续费 + 滑点(均按单边成交额计)
|
| 171 |
+
cost = turnover_total * (self.fee_rate + self.slippage_rate)
|
| 172 |
+
self.equity -= cost
|
| 173 |
+
|
| 174 |
+
# 更新已成功调仓的 symbol 持仓
|
| 175 |
+
self.lots[mask] = self.target_lots[mask]
|
| 176 |
+
|
| 177 |
+
# 最新价为调仓价
|
| 178 |
+
self.fill_last_prices(exec_prices)
|
| 179 |
+
|
| 180 |
+
# 返回扣除交易成本后的调仓后账户权益,成交额,和交易成本
|
| 181 |
+
return self.equity, turnover_total, cost
|
| 182 |
+
|
| 183 |
+
def on_close(self, close_prices):
|
| 184 |
+
"""
|
| 185 |
+
模拟: K 线收盘 -> K 线收盘时刻
|
| 186 |
+
:param close_prices: 收盘价
|
| 187 |
+
:return: 收盘后的账户权益
|
| 188 |
+
"""
|
| 189 |
+
if not self.has_last_prices:
|
| 190 |
+
self.fill_last_prices(close_prices)
|
| 191 |
+
|
| 192 |
+
# 模拟: 调仓时刻 -> K 线收盘时刻
|
| 193 |
+
|
| 194 |
+
# 根据收盘价和前最新价(调仓价),结算当前账户权益
|
| 195 |
+
self.settle_equity(close_prices)
|
| 196 |
+
|
| 197 |
+
# 最新价为收盘价
|
| 198 |
+
self.fill_last_prices(close_prices)
|
| 199 |
+
|
| 200 |
+
mask = np.logical_and(self.lots != 0, np.logical_not(np.isnan(close_prices)))
|
| 201 |
+
pos_val = self.lot_sizes[mask] * self.lots[mask] * close_prices[mask]
|
| 202 |
+
|
| 203 |
+
# 返回收盘账户权益
|
| 204 |
+
return self.equity, pos_val
|
基础库/common_core/backtest/version.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
version.py
|
| 4 |
+
"""
|
| 5 |
+
sys_name = 'select-strategy'
|
| 6 |
+
sys_version = '1.0.2'
|
| 7 |
+
build_version = 'v1.0.2.20241122'
|
基础库/common_core/backtest/进度条.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
Quant Unified 量化交易系统
|
| 4 |
+
[回测进度条模块]
|
| 5 |
+
|
| 6 |
+
功能:
|
| 7 |
+
提供统一的进度条显示,让用户知道回测进行到哪了、还要等多久。
|
| 8 |
+
底层使用 tqdm 库,但封装成中文接口方便使用。
|
| 9 |
+
|
| 10 |
+
使用方法:
|
| 11 |
+
```python
|
| 12 |
+
from 基础库.common_core.backtest.进度条 import 回测进度条
|
| 13 |
+
|
| 14 |
+
# 方式1: 作为上下文管理器
|
| 15 |
+
with 回测进度条(总数=len(prices), 描述="回测中") as 进度:
|
| 16 |
+
for i in range(len(prices)):
|
| 17 |
+
# 你的回测逻辑...
|
| 18 |
+
进度.更新(1)
|
| 19 |
+
|
| 20 |
+
# 方式2: 手动控制
|
| 21 |
+
进度 = 回测进度条(总数=1000, 描述="处理数据")
|
| 22 |
+
for i in range(1000):
|
| 23 |
+
# 处理逻辑...
|
| 24 |
+
进度.更新(1)
|
| 25 |
+
进度.关闭()
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
显示效果:
|
| 29 |
+
回测中: 45%|████████████░░░░░░░░░░░░| 450000/1000000 [01:23<01:42, 5432.10 it/s]
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from tqdm import tqdm
|
| 33 |
+
from typing import Optional
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class 回测进度条:
|
| 37 |
+
"""
|
| 38 |
+
回测进度条封装类
|
| 39 |
+
|
| 40 |
+
这个类就像一个"加载条":
|
| 41 |
+
当你在下载文件或安装软件时,会看到一个进度条告诉你:
|
| 42 |
+
- 完成了多少 (45%)
|
| 43 |
+
- 已经用了多久 (01:23)
|
| 44 |
+
- 还需要多久 (01:42)
|
| 45 |
+
- 速度多快 (5432 条/秒)
|
| 46 |
+
|
| 47 |
+
这里我们把它用在回测上,让你知道回测还要跑多久。
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(
|
| 51 |
+
self,
|
| 52 |
+
总数: int,
|
| 53 |
+
描述: str = "回测进行中",
|
| 54 |
+
单位: str = " bar",
|
| 55 |
+
刷新间隔: float = 0.1,
|
| 56 |
+
最小更新间隔: float = 0.5,
|
| 57 |
+
禁用: bool = False,
|
| 58 |
+
留存: bool = True
|
| 59 |
+
):
|
| 60 |
+
"""
|
| 61 |
+
初始化进度条
|
| 62 |
+
|
| 63 |
+
参数:
|
| 64 |
+
总数: 总共需要处理的数量 (比如 K线条数)
|
| 65 |
+
描述: 进度条前面显示的文字描述
|
| 66 |
+
单位: 显示的单位 (如 "条", "bar", "根K线")
|
| 67 |
+
刷新间隔: 进度条刷新频率 (秒)
|
| 68 |
+
最小更新间隔: 最小更新间隔 (秒),防止刷新太频繁拖慢速度
|
| 69 |
+
禁用: 是否禁用进度条 (静默模式)
|
| 70 |
+
留存: 完成后是否保留进度条显示
|
| 71 |
+
"""
|
| 72 |
+
self.总数 = 总数
|
| 73 |
+
self.禁用 = 禁用
|
| 74 |
+
|
| 75 |
+
# 配置 tqdm 进度条
|
| 76 |
+
self._进度条 = tqdm(
|
| 77 |
+
total=总数,
|
| 78 |
+
desc=描述,
|
| 79 |
+
unit=单位,
|
| 80 |
+
mininterval=刷新间隔,
|
| 81 |
+
miniters=max(1, 总数 // 1000), # 至少每 0.1% 更新一次
|
| 82 |
+
disable=禁用,
|
| 83 |
+
leave=留存,
|
| 84 |
+
ncols=100, # 进度条宽度
|
| 85 |
+
bar_format='{desc}: {percentage:3.0f}%|{bar:25}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]'
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
self._当前 = 0
|
| 89 |
+
|
| 90 |
+
def 更新(self, 步数: int = 1):
|
| 91 |
+
"""
|
| 92 |
+
更新进度
|
| 93 |
+
|
| 94 |
+
参数:
|
| 95 |
+
步数: 本次完成的数量 (默认为1)
|
| 96 |
+
"""
|
| 97 |
+
self._进度条.update(步数)
|
| 98 |
+
self._当前 += 步数
|
| 99 |
+
|
| 100 |
+
def 设置描述(self, 描述: str):
|
| 101 |
+
"""动态更新进度条描述文字"""
|
| 102 |
+
self._进度条.set_description(描述)
|
| 103 |
+
|
| 104 |
+
def 设置后缀(self, **kwargs):
|
| 105 |
+
"""
|
| 106 |
+
设置进度条后缀信息
|
| 107 |
+
|
| 108 |
+
示例:
|
| 109 |
+
进度.设置后缀(收益率="12.5%", 回撤="-3.2%")
|
| 110 |
+
"""
|
| 111 |
+
self._进度条.set_postfix(**kwargs)
|
| 112 |
+
|
| 113 |
+
def 关闭(self):
|
| 114 |
+
"""关闭进度条"""
|
| 115 |
+
self._进度条.close()
|
| 116 |
+
|
| 117 |
+
def __enter__(self):
|
| 118 |
+
"""进入上下文管理器"""
|
| 119 |
+
return self
|
| 120 |
+
|
| 121 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 122 |
+
"""退出上下文管理器,自动关闭进度条"""
|
| 123 |
+
self.关闭()
|
| 124 |
+
return False # 不吞掉异常
|
| 125 |
+
|
| 126 |
+
@property
|
| 127 |
+
def 已完成数量(self) -> int:
|
| 128 |
+
"""获取已完成的数量"""
|
| 129 |
+
return self._当前
|
| 130 |
+
|
| 131 |
+
@property
|
| 132 |
+
def 完成百分比(self) -> float:
|
| 133 |
+
"""获取完成百分比 (0-100)"""
|
| 134 |
+
if self.总数 == 0:
|
| 135 |
+
return 100.0
|
| 136 |
+
return (self._当前 / self.总数) * 100
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def 创建进度条(总数: int, 描述: str = "处理中", 禁用: bool = False) -> 回测进度条:
|
| 140 |
+
"""
|
| 141 |
+
快速创建进度条的便捷函数
|
| 142 |
+
|
| 143 |
+
使用方法:
|
| 144 |
+
进度 = 创建进度条(1000000, "回测ETH策略")
|
| 145 |
+
for i in range(1000000):
|
| 146 |
+
# 处理逻辑
|
| 147 |
+
进度.更新(1)
|
| 148 |
+
进度.关闭()
|
| 149 |
+
"""
|
| 150 |
+
return 回测进度条(总数=总数, 描述=描述, 禁用=禁用)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ============== 向量化回测专用 ==============
|
| 154 |
+
|
| 155 |
+
class 分块进度条:
|
| 156 |
+
"""
|
| 157 |
+
分块进度条 - 适用于向量化回测
|
| 158 |
+
|
| 159 |
+
向量化回测不是一条一条处理,而是一大块一大块处理。
|
| 160 |
+
这个进度条专门为这种情况设计。
|
| 161 |
+
|
| 162 |
+
使用方法:
|
| 163 |
+
进度 = 分块进度条(总步骤=5, 描述="向量化回测")
|
| 164 |
+
|
| 165 |
+
进度.完成步骤("加载数��")
|
| 166 |
+
# ... 加载数据 ...
|
| 167 |
+
|
| 168 |
+
进度.完成步骤("计算指标")
|
| 169 |
+
# ... 计算指标 ...
|
| 170 |
+
|
| 171 |
+
进度.完成步骤("生成信号")
|
| 172 |
+
# ... 生成信号 ...
|
| 173 |
+
|
| 174 |
+
进度.结束()
|
| 175 |
+
"""
|
| 176 |
+
|
| 177 |
+
def __init__(self, 总步骤: int = 5, 描述: str = "回测中"):
|
| 178 |
+
self.总步骤 = 总步骤
|
| 179 |
+
self.当前步骤 = 0
|
| 180 |
+
self.描述 = 描述
|
| 181 |
+
|
| 182 |
+
self._进度条 = tqdm(
|
| 183 |
+
total=总步骤,
|
| 184 |
+
desc=描述,
|
| 185 |
+
unit="步",
|
| 186 |
+
bar_format='{desc}: {n}/{total} |{bar:20}| {postfix}',
|
| 187 |
+
leave=True
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
def 完成步骤(self, 步骤名称: str):
|
| 191 |
+
"""标记一个步骤完成"""
|
| 192 |
+
self.当前步骤 += 1
|
| 193 |
+
self._进度条.set_postfix_str(f"✅ {步骤名称}")
|
| 194 |
+
self._进度条.update(1)
|
| 195 |
+
|
| 196 |
+
def 结束(self):
|
| 197 |
+
"""结束进度条"""
|
| 198 |
+
self._进度条.set_postfix_str("🎉 完成!")
|
| 199 |
+
self._进度条.close()
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# ============== 测试代码 ==============
|
| 203 |
+
|
| 204 |
+
if __name__ == "__main__":
|
| 205 |
+
import time
|
| 206 |
+
|
| 207 |
+
print("测试1: 基础进度条")
|
| 208 |
+
with 回测进度条(总数=100, 描述="处理K线") as 进度:
|
| 209 |
+
for i in range(100):
|
| 210 |
+
time.sleep(0.02) # 模拟处理
|
| 211 |
+
进度.更新(1)
|
| 212 |
+
if i % 20 == 0:
|
| 213 |
+
进度.设置后缀(收益="12.5%")
|
| 214 |
+
|
| 215 |
+
print("\n测试2: 分块进度条 (向量化)")
|
| 216 |
+
进度 = 分块进度条(总步骤=4, 描述="向量化回测")
|
| 217 |
+
|
| 218 |
+
time.sleep(0.3)
|
| 219 |
+
进度.完成步骤("加载数据")
|
| 220 |
+
|
| 221 |
+
time.sleep(0.3)
|
| 222 |
+
进度.完成步骤("计算指标")
|
| 223 |
+
|
| 224 |
+
time.sleep(0.3)
|
| 225 |
+
进度.完成步骤("生成信号")
|
| 226 |
+
|
| 227 |
+
time.sleep(0.3)
|
| 228 |
+
进度.完成步骤("计算收益")
|
| 229 |
+
|
| 230 |
+
进度.结束()
|
| 231 |
+
|
| 232 |
+
print("\n✅ 进度条模块测试通过!")
|
基础库/common_core/config/loader.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration Loading Utilities
|
| 3 |
+
"""
|
| 4 |
+
import importlib.util
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Type, TypeVar, Optional, Any, Dict
|
| 8 |
+
|
| 9 |
+
from pydantic import BaseModel
|
| 10 |
+
|
| 11 |
+
T = TypeVar("T", bound=BaseModel)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def load_config_from_module(
|
| 15 |
+
module_path: str,
|
| 16 |
+
model: Type[T],
|
| 17 |
+
variable_name: str = "config"
|
| 18 |
+
) -> T:
|
| 19 |
+
"""
|
| 20 |
+
Load configuration from a python module file and validate it against a Pydantic model.
|
| 21 |
+
|
| 22 |
+
:param module_path: Path to the python file (e.g., 'config.py')
|
| 23 |
+
:param model: Pydantic model class to validate against
|
| 24 |
+
:param variable_name: The variable name in the module to load (default: 'config')
|
| 25 |
+
:return: Instance of the Pydantic model
|
| 26 |
+
"""
|
| 27 |
+
path = Path(module_path).resolve()
|
| 28 |
+
if not path.exists():
|
| 29 |
+
raise FileNotFoundError(f"Config file not found: {path}")
|
| 30 |
+
|
| 31 |
+
spec = importlib.util.spec_from_file_location("dynamic_config", path)
|
| 32 |
+
if spec is None or spec.loader is None:
|
| 33 |
+
raise ImportError(f"Could not load spec from {path}")
|
| 34 |
+
|
| 35 |
+
module = importlib.util.module_from_spec(spec)
|
| 36 |
+
spec.loader.exec_module(module)
|
| 37 |
+
|
| 38 |
+
config_data = getattr(module, variable_name, None)
|
| 39 |
+
if config_data is None:
|
| 40 |
+
# Try to find a variable that matches the variable_name case-insensitive
|
| 41 |
+
# or if variable_name is a dict of exports expected
|
| 42 |
+
raise AttributeError(f"Variable '{variable_name}' not found in {module_path}")
|
| 43 |
+
|
| 44 |
+
return model.model_validate(config_data)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def load_dict_from_module(module_path: str, variable_names: list[str]) -> Dict[str, Any]:
|
| 48 |
+
"""
|
| 49 |
+
Load specific variables from a python module file.
|
| 50 |
+
|
| 51 |
+
:param module_path: Path to the python file
|
| 52 |
+
:param variable_names: List of variable names to retrieve
|
| 53 |
+
:return: Dictionary of variable names to values
|
| 54 |
+
"""
|
| 55 |
+
path = Path(module_path).resolve()
|
| 56 |
+
if not path.exists():
|
| 57 |
+
# Fallback: try relative to CWD
|
| 58 |
+
path = Path.cwd() / module_path
|
| 59 |
+
if not path.exists():
|
| 60 |
+
raise FileNotFoundError(f"Config file not found: {module_path}")
|
| 61 |
+
|
| 62 |
+
spec = importlib.util.spec_from_file_location("dynamic_config", path)
|
| 63 |
+
if spec is None or spec.loader is None:
|
| 64 |
+
raise ImportError(f"Could not load spec from {path}")
|
| 65 |
+
|
| 66 |
+
module = importlib.util.module_from_spec(spec)
|
| 67 |
+
spec.loader.exec_module(module)
|
| 68 |
+
|
| 69 |
+
result = {}
|
| 70 |
+
for name in variable_names:
|
| 71 |
+
if hasattr(module, name):
|
| 72 |
+
result[name] = getattr(module, name)
|
| 73 |
+
|
| 74 |
+
return result
|
基础库/common_core/config/models.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
配置模型定义
|
| 3 |
+
用于定义和验证系统各部分的配置结构,利用 Pydantic 提供类型检查和自动验证功能。
|
| 4 |
+
"""
|
| 5 |
+
from typing import Optional, List, Dict, Union, Set
|
| 6 |
+
from pydantic import BaseModel, Field, AnyHttpUrl
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ExchangeConfig(BaseModel):
|
| 10 |
+
"""交易所基础配置"""
|
| 11 |
+
exchange_name: str = Field(default="binance", description="交易所名称")
|
| 12 |
+
apiKey: str = Field(default="", description="API 密钥")
|
| 13 |
+
secret: str = Field(default="", description="API 私钥")
|
| 14 |
+
is_pure_long: bool = Field(default=False, description="是否为纯多头模式")
|
| 15 |
+
password: Optional[str] = None
|
| 16 |
+
uid: Optional[str] = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class FactorConfig(BaseModel):
|
| 20 |
+
"""因子配置"""
|
| 21 |
+
name: str
|
| 22 |
+
param: Union[int, float, str, dict, list]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class StrategyConfig(BaseModel):
|
| 26 |
+
"""策略配置"""
|
| 27 |
+
strategy_name: str
|
| 28 |
+
symbol_type: str = Field(default="swap", description="交易对类型:spot(现货) 或 swap(合约)")
|
| 29 |
+
hold_period: str = Field(default="1h", description="持仓周期")
|
| 30 |
+
# 在此添加其他通用策略字段
|
| 31 |
+
# 这是一个基础模型,具体策略可以继承扩展此模型
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class AccountConfig(BaseModel):
|
| 35 |
+
"""账户配置"""
|
| 36 |
+
name: str = Field(default="default_account", description="账户名称")
|
| 37 |
+
apiKey: Optional[str] = None
|
| 38 |
+
secret: Optional[str] = None
|
| 39 |
+
strategy: Dict = Field(default_factory=dict, description="策略配置字典")
|
| 40 |
+
strategy_short: Optional[Dict] = Field(default=None, description="做空策略配置字典")
|
| 41 |
+
|
| 42 |
+
# 风控设置
|
| 43 |
+
black_list: List[str] = Field(default_factory=list, description="黑名单币种")
|
| 44 |
+
white_list: List[str] = Field(default_factory=list, description="白名单币种")
|
| 45 |
+
leverage: int = Field(default=1, description="杠杆倍数")
|
| 46 |
+
|
| 47 |
+
# 数据获取设置
|
| 48 |
+
get_kline_num: int = Field(default=999, description="获取K线数量")
|
| 49 |
+
min_kline_num: int = Field(default=168, description="最小K线数量要求")
|
| 50 |
+
|
| 51 |
+
# 通知设置
|
| 52 |
+
wechat_webhook_url: Optional[str] = Field(default=None, description="企业微信 Webhook URL")
|
| 53 |
+
|
| 54 |
+
# 下单限制
|
| 55 |
+
order_spot_money_limit: float = Field(default=10.0, description="现货最小下单金额")
|
| 56 |
+
order_swap_money_limit: float = Field(default=5.0, description="合约最小下单金额")
|
| 57 |
+
|
| 58 |
+
# 高级设置
|
| 59 |
+
use_offset: bool = Field(default=False, description="是否使用 Offset")
|
| 60 |
+
is_pure_long: bool = Field(default=False, description="是否为纯多头模式")
|
基础库/common_core/exchange/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
"""
|
基础库/common_core/exchange/base_client.py
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
[交易所客户端基类]
|
| 4 |
+
功能:定义通用的连接管理、签名鉴权、错误处理逻辑,规范化所有交易所接口,作为 StandardClient 和 AsyncClient 的父类。
|
| 5 |
+
"""
|
| 6 |
+
# ==================================================================================================
|
| 7 |
+
# !!! 前置非常重要说明
|
| 8 |
+
# !!! 前置非常重要说明
|
| 9 |
+
# !!! 前置非常重要说明
|
| 10 |
+
# ---------------------------------------------------------------------------------------------------
|
| 11 |
+
# ** 方法名前缀规范 **
|
| 12 |
+
# 1. load_* 从硬盘获取数据
|
| 13 |
+
# 2. fetch_* 从接口获取数据
|
| 14 |
+
# 3. get_* 从对象获取数据,可能从硬盘,也可能从接口
|
| 15 |
+
# ====================================================================================================
|
| 16 |
+
|
| 17 |
+
import math
|
| 18 |
+
import time
|
| 19 |
+
import traceback
|
| 20 |
+
|
| 21 |
+
import ccxt
|
| 22 |
+
import numpy as np
|
| 23 |
+
import pandas as pd
|
| 24 |
+
|
| 25 |
+
from common_core.utils.commons import apply_precision
|
| 26 |
+
from common_core.utils.commons import retry_wrapper
|
| 27 |
+
from common_core.utils.dingding import send_wechat_work_msg, send_msg_for_order
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# 现货接口
|
| 31 |
+
# sapi
|
| 32 |
+
|
| 33 |
+
# 合约接口
|
| 34 |
+
# dapi:普通账户,包含币本位交易
|
| 35 |
+
# fapi,普通账户,包含U本位交易
|
| 36 |
+
|
| 37 |
+
# 统一账户
|
| 38 |
+
# papi, um的接口:U本位合约
|
| 39 |
+
# papi, cm的接口:币本位合约
|
| 40 |
+
# papi, margin:现货API,全仓杠杆现货
|
| 41 |
+
|
| 42 |
+
class BinanceClient:
|
| 43 |
+
diff_timestamp = 0
|
| 44 |
+
constants = dict()
|
| 45 |
+
|
| 46 |
+
market_info = {} # 缓存市场信息,并且自动更新,全局共享
|
| 47 |
+
|
| 48 |
+
def __init__(self, **config):
|
| 49 |
+
self.api_key: str = config.get('apiKey', '')
|
| 50 |
+
self.secret: str = config.get('secret', '')
|
| 51 |
+
|
| 52 |
+
# 默认配置
|
| 53 |
+
default_exchange_config = {
|
| 54 |
+
'timeout': 30000,
|
| 55 |
+
'rateLimit': 30,
|
| 56 |
+
'enableRateLimit': False,
|
| 57 |
+
'options': {'adjustForTimeDifference': True, 'recvWindow': 10000},
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
self.order_money_limit: dict = {
|
| 61 |
+
'spot': config.get('spot_order_money_limit', 10),
|
| 62 |
+
'swap': config.get('swap_order_money_limit', 5),
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
self.exchange = ccxt.binance(config.get('exchange_config', default_exchange_config))
|
| 66 |
+
self.wechat_webhook_url: str = config.get('wechat_webhook_url', '')
|
| 67 |
+
|
| 68 |
+
# 常用配置
|
| 69 |
+
self.utc_offset = config.get('utc_offset', 8)
|
| 70 |
+
self.stable_symbol = config.get('stable_symbol', ['USDC', 'USDP', 'TUSD', 'BUSD', 'FDUSD', 'DAI'])
|
| 71 |
+
|
| 72 |
+
self.swap_account = None
|
| 73 |
+
|
| 74 |
+
self.coin_margin: dict = config.get('coin_margin', {}) # 用做保证金的币种
|
| 75 |
+
|
| 76 |
+
# ====================================================================================================
|
| 77 |
+
# ** 市场信息 **
|
| 78 |
+
# ====================================================================================================
|
| 79 |
+
def _fetch_swap_exchange_info_list(self) -> list:
|
| 80 |
+
exchange_info = retry_wrapper(self.exchange.fapipublic_get_exchangeinfo, func_name='获取BN合约币种规则数据')
|
| 81 |
+
return exchange_info['symbols']
|
| 82 |
+
|
| 83 |
+
def _fetch_spot_exchange_info_list(self) -> list:
|
| 84 |
+
exchange_info = retry_wrapper(self.exchange.public_get_exchangeinfo, func_name='获取BN现货币种规则数据')
|
| 85 |
+
return exchange_info['symbols']
|
| 86 |
+
|
| 87 |
+
# region 市场信息数据获取
|
| 88 |
+
def fetch_market_info(self, symbol_type='swap', quote_symbol='USDT'):
|
| 89 |
+
"""
|
| 90 |
+
加载市场数据
|
| 91 |
+
:param symbol_type: 币种信息。swap为合约,spot为现货
|
| 92 |
+
:param quote_symbol: 报价币种
|
| 93 |
+
:return:
|
| 94 |
+
symbol_list 交易对列表
|
| 95 |
+
price_precision 币种价格精 例: 2 代表 0.01
|
| 96 |
+
{'BTCUSD_PERP': 1, 'BTCUSD_231229': 1, 'BTCUSD_240329': 1, 'BTCUSD_240628': 1, ...}
|
| 97 |
+
min_notional 最小下单金额 例: 5.0 代表 最小下单金额是5U
|
| 98 |
+
{'BTCUSDT': 5.0, 'ETHUSDT': 5.0, 'BCHUSDT': 5.0, 'XRPUSDT': 5.0...}
|
| 99 |
+
"""
|
| 100 |
+
print(f'🔄更新{symbol_type}市场数据...')
|
| 101 |
+
# ===获取所有币种信息
|
| 102 |
+
if symbol_type == 'swap': # 合约
|
| 103 |
+
exchange_info_list = self._fetch_swap_exchange_info_list()
|
| 104 |
+
else: # 现货
|
| 105 |
+
exchange_info_list = self._fetch_spot_exchange_info_list()
|
| 106 |
+
|
| 107 |
+
# ===获取币种列表
|
| 108 |
+
symbol_list = [] # 如果是合约,只包含永续合约。如果是现货,包含所有数据
|
| 109 |
+
full_symbol_list = [] # 包含所有币种信息
|
| 110 |
+
|
| 111 |
+
# ===获取各个交易对的精度、下单量等信息
|
| 112 |
+
min_qty = {} # 最小下单精度,例如bnb,一次最少买入0.001个
|
| 113 |
+
price_precision = {} # 币种价格精,例如bnb,价格是158.887,不能是158.8869
|
| 114 |
+
min_notional = {} # 最小下单金额,例如bnb,一次下单至少买入金额是5usdt
|
| 115 |
+
# 遍历获得想要的数据
|
| 116 |
+
for info in exchange_info_list:
|
| 117 |
+
symbol = info['symbol'] # 交易对信息
|
| 118 |
+
|
| 119 |
+
# 过滤掉非报价币对 , 非交易币对
|
| 120 |
+
if info['quoteAsset'] != quote_symbol or info['status'] != 'TRADING':
|
| 121 |
+
continue
|
| 122 |
+
|
| 123 |
+
full_symbol_list.append(symbol) # 添加到全量信息中
|
| 124 |
+
|
| 125 |
+
if (symbol_type == 'swap' and info['contractType'] != 'PERPETUAL') or info['baseAsset'] in self.stable_symbol:
|
| 126 |
+
pass # 获取合约的时候,非永续的symbol会被排除
|
| 127 |
+
else:
|
| 128 |
+
symbol_list.append(symbol)
|
| 129 |
+
|
| 130 |
+
for _filter in info['filters']: # 遍历获得想要的数据
|
| 131 |
+
if _filter['filterType'] == 'PRICE_FILTER': # 获取价格精度
|
| 132 |
+
price_precision[symbol] = int(math.log(float(_filter['tickSize']), 0.1))
|
| 133 |
+
elif _filter['filterType'] == 'LOT_SIZE': # 获取最小下单量
|
| 134 |
+
min_qty[symbol] = int(math.log(float(_filter['minQty']), 0.1))
|
| 135 |
+
elif _filter['filterType'] == 'MIN_NOTIONAL' and symbol_type == 'swap': # 合约的最小下单金额
|
| 136 |
+
min_notional[symbol] = float(_filter['notional'])
|
| 137 |
+
elif _filter['filterType'] == 'NOTIONAL' and symbol_type == 'spot': # 现货的最小下单金额
|
| 138 |
+
min_notional[symbol] = float(_filter['minNotional'])
|
| 139 |
+
|
| 140 |
+
self.market_info[symbol_type] = {
|
| 141 |
+
'symbol_list': symbol_list, # 如果是合约,只包含永续合约。如果是现货,包含所有数据
|
| 142 |
+
'full_symbol_list': full_symbol_list, # 包含所有币种信息
|
| 143 |
+
'min_qty': min_qty,
|
| 144 |
+
'price_precision': price_precision,
|
| 145 |
+
'min_notional': min_notional,
|
| 146 |
+
'last_update': int(time.time())
|
| 147 |
+
}
|
| 148 |
+
return self.market_info[symbol_type]
|
| 149 |
+
|
| 150 |
+
def get_market_info(self, symbol_type, expire_seconds: int = 3600 * 12, require_update: bool = False,
|
| 151 |
+
quote_symbol='USDT') -> dict:
|
| 152 |
+
if require_update: # 如果强制刷新的话,就当我们系统没有更新过
|
| 153 |
+
last_update = 0
|
| 154 |
+
else:
|
| 155 |
+
last_update = self.market_info.get(symbol_type, {}).get('last_update', 0)
|
| 156 |
+
if last_update + expire_seconds < int(time.time()):
|
| 157 |
+
self.fetch_market_info(symbol_type, quote_symbol)
|
| 158 |
+
|
| 159 |
+
return self.market_info[symbol_type]
|
| 160 |
+
|
| 161 |
+
# endregion
|
| 162 |
+
|
| 163 |
+
# ====================================================================================================
|
| 164 |
+
# ** 行情数据获取 **
|
| 165 |
+
# ====================================================================================================
|
| 166 |
+
# region 行情数据获取
|
| 167 |
+
"""K线数据获取"""
|
| 168 |
+
|
| 169 |
+
def get_candle_df(self, symbol, run_time, limit=1500, interval='1h', symbol_type='swap') -> pd.DataFrame:
|
| 170 |
+
# ===获取K线数据
|
| 171 |
+
_limit = limit
|
| 172 |
+
# 定义请求的参数:现货最大1000,合约最大499。
|
| 173 |
+
if limit > 1000: # 如果参数大于1000
|
| 174 |
+
if symbol_type == 'spot': # 如果是现货,最大设置1000
|
| 175 |
+
_limit = 1000
|
| 176 |
+
else: # 如果不是现货,那就设置499
|
| 177 |
+
_limit = 499
|
| 178 |
+
# limit = 1000 if limit > 1000 and symbol_type == 'spot' else limit # 现货最多获取1000根K
|
| 179 |
+
# 计算获取k线的开始时间
|
| 180 |
+
start_time_dt = run_time - pd.to_timedelta(interval) * limit
|
| 181 |
+
|
| 182 |
+
df_list = [] # 定义获取的k线数据
|
| 183 |
+
data_len = 0 # 记录数据长度
|
| 184 |
+
params = {
|
| 185 |
+
'symbol': symbol, # 获取币种
|
| 186 |
+
'interval': interval, # 获取k线周期
|
| 187 |
+
'limit': _limit, # 获取多少根
|
| 188 |
+
'startTime': int(time.mktime(start_time_dt.timetuple())) * 1000 # 获取币种开始时间
|
| 189 |
+
}
|
| 190 |
+
while True:
|
| 191 |
+
# 获取指定币种的k线数据
|
| 192 |
+
try:
|
| 193 |
+
if symbol_type == 'swap':
|
| 194 |
+
kline = retry_wrapper(
|
| 195 |
+
self.exchange.fapipublic_get_klines, params=params, func_name='获取币种K线',
|
| 196 |
+
if_exit=False
|
| 197 |
+
)
|
| 198 |
+
else:
|
| 199 |
+
kline = retry_wrapper(
|
| 200 |
+
self.exchange.public_get_klines, params=params, func_name='获取币种K线',
|
| 201 |
+
if_exit=False
|
| 202 |
+
)
|
| 203 |
+
except Exception as e:
|
| 204 |
+
print(e)
|
| 205 |
+
print(traceback.format_exc())
|
| 206 |
+
# 如果获取k线重试出错,直接返回,当前币种不参与交易
|
| 207 |
+
return pd.DataFrame()
|
| 208 |
+
|
| 209 |
+
# ===整理数据
|
| 210 |
+
# 将数据转换为DataFrame
|
| 211 |
+
df = pd.DataFrame(kline, dtype='float')
|
| 212 |
+
if df.empty:
|
| 213 |
+
break
|
| 214 |
+
# 对字段进行重命名,字段对应数据可以查询文档(https://binance-docs.github.io/apidocs/futures/cn/#k)
|
| 215 |
+
columns = {0: 'candle_begin_time', 1: 'open', 2: 'high', 3: 'low', 4: 'close', 5: 'volume', 6: 'close_time',
|
| 216 |
+
7: 'quote_volume',
|
| 217 |
+
8: 'trade_num', 9: 'taker_buy_base_asset_volume', 10: 'taker_buy_quote_asset_volume',
|
| 218 |
+
11: 'ignore'}
|
| 219 |
+
df.rename(columns=columns, inplace=True)
|
| 220 |
+
df['candle_begin_time'] = pd.to_datetime(df['candle_begin_time'], unit='ms')
|
| 221 |
+
df.sort_values(by=['candle_begin_time'], inplace=True) # 排序
|
| 222 |
+
|
| 223 |
+
# 数据追加
|
| 224 |
+
df_list.append(df)
|
| 225 |
+
data_len = data_len + df.shape[0] - 1
|
| 226 |
+
|
| 227 |
+
# 判断请求的数据是否足够
|
| 228 |
+
if data_len >= limit:
|
| 229 |
+
break
|
| 230 |
+
|
| 231 |
+
if params['startTime'] == int(df.iloc[-1]['candle_begin_time'].timestamp()) * 1000:
|
| 232 |
+
break
|
| 233 |
+
|
| 234 |
+
# 更新一下k线数据
|
| 235 |
+
params['startTime'] = int(df.iloc[-1]['candle_begin_time'].timestamp()) * 1000
|
| 236 |
+
# 下载太多的k线的时候,中间sleep一下
|
| 237 |
+
time.sleep(0.1)
|
| 238 |
+
|
| 239 |
+
if not df_list:
|
| 240 |
+
return pd.DataFrame()
|
| 241 |
+
|
| 242 |
+
all_df = pd.concat(df_list, ignore_index=True)
|
| 243 |
+
all_df['symbol'] = symbol # 添加symbol列
|
| 244 |
+
all_df['symbol_type'] = symbol_type # 添加类型字段
|
| 245 |
+
all_df.sort_values(by=['candle_begin_time'], inplace=True) # 排序
|
| 246 |
+
all_df.drop_duplicates(subset=['candle_begin_time'], keep='last', inplace=True) # 去重
|
| 247 |
+
|
| 248 |
+
# 删除runtime那根未走完的k线数据(交易所有时候会返回这条数据)
|
| 249 |
+
all_df = all_df[all_df['candle_begin_time'] + pd.Timedelta(hours=self.utc_offset) < run_time]
|
| 250 |
+
all_df.reset_index(drop=True, inplace=True)
|
| 251 |
+
|
| 252 |
+
return all_df
|
| 253 |
+
|
| 254 |
+
"""最新报价数据获取"""
|
| 255 |
+
|
| 256 |
+
def fetch_ticker_price(self, symbol: str = None, symbol_type: str = 'swap') -> dict:
|
| 257 |
+
params = {'symbol': symbol} if symbol else {}
|
| 258 |
+
match symbol_type:
|
| 259 |
+
case 'spot':
|
| 260 |
+
api_func = self.exchange.public_get_ticker_price
|
| 261 |
+
func_name = f'获取{symbol}现货的ticker数据' if symbol else '获取所有现货币种的ticker数据'
|
| 262 |
+
case 'swap':
|
| 263 |
+
api_func = self.exchange.fapipublic_get_ticker_price
|
| 264 |
+
func_name = f'获取{symbol}合约的ticker数据' if symbol else '获取所有合约币种的ticker数据'
|
| 265 |
+
case _:
|
| 266 |
+
raise ValueError(f'未知的symbol_type:{symbol_type}')
|
| 267 |
+
|
| 268 |
+
tickers = retry_wrapper(api_func, params=params, func_name=func_name)
|
| 269 |
+
return tickers
|
| 270 |
+
|
| 271 |
+
def fetch_spot_ticker_price(self, spot_symbol: str = None) -> dict:
|
| 272 |
+
return self.fetch_ticker_price(spot_symbol, symbol_type='spot')
|
| 273 |
+
|
| 274 |
+
def fetch_swap_ticker_price(self, swap_symbol: str = None) -> dict:
|
| 275 |
+
return self.fetch_ticker_price(swap_symbol, symbol_type='swap')
|
| 276 |
+
|
| 277 |
+
def get_spot_ticker_price_series(self) -> pd.Series:
|
| 278 |
+
ticker_price_df = pd.DataFrame(self.fetch_ticker_price(symbol_type='spot'))
|
| 279 |
+
ticker_price_df['price'] = pd.to_numeric(ticker_price_df['price'], errors='coerce')
|
| 280 |
+
return ticker_price_df.set_index(['symbol'])['price']
|
| 281 |
+
|
| 282 |
+
def get_swap_ticker_price_series(self) -> pd.Series:
|
| 283 |
+
ticker_price_df = pd.DataFrame(self.fetch_ticker_price(symbol_type='swap'))
|
| 284 |
+
ticker_price_df['price'] = pd.to_numeric(ticker_price_df['price'], errors='coerce')
|
| 285 |
+
return ticker_price_df.set_index(['symbol'])['price']
|
| 286 |
+
|
| 287 |
+
"""盘口数据获取"""
|
| 288 |
+
|
| 289 |
+
def fetch_book_ticker(self, symbol, symbol_type='swap') -> dict:
|
| 290 |
+
if symbol_type == 'swap':
|
| 291 |
+
# 获取合约的盘口数据
|
| 292 |
+
swap_book_ticker_data = retry_wrapper(
|
| 293 |
+
self.exchange.fapiPublicGetTickerBookTicker, params={'symbol': symbol}, func_name='获取合约盘口数据')
|
| 294 |
+
return swap_book_ticker_data
|
| 295 |
+
else:
|
| 296 |
+
# 获取现货的盘口数据
|
| 297 |
+
spot_book_ticker_data = retry_wrapper(
|
| 298 |
+
self.exchange.publicGetTickerBookTicker, params={'symbol': symbol}, func_name='获取现货盘口数据'
|
| 299 |
+
)
|
| 300 |
+
return spot_book_ticker_data
|
| 301 |
+
|
| 302 |
+
def fetch_spot_book_ticker(self, spot_symbol) -> dict:
|
| 303 |
+
return self.fetch_book_ticker(spot_symbol, symbol_type='spot')
|
| 304 |
+
|
| 305 |
+
def fetch_swap_book_ticker(self, swap_symbol) -> dict:
|
| 306 |
+
return self.fetch_book_ticker(swap_symbol, symbol_type='swap')
|
| 307 |
+
|
| 308 |
+
# endregion
|
| 309 |
+
|
| 310 |
+
# ====================================================================================================
|
| 311 |
+
# ** 资金费数据 **
|
| 312 |
+
# ====================================================================================================
|
| 313 |
+
def get_premium_index_df(self) -> pd.DataFrame:
|
| 314 |
+
"""
|
| 315 |
+
获取币安的最新资金费数据
|
| 316 |
+
"""
|
| 317 |
+
last_funding_df = retry_wrapper(self.exchange.fapipublic_get_premiumindex, func_name='获取最新的资金费数据')
|
| 318 |
+
last_funding_df = pd.DataFrame(last_funding_df)
|
| 319 |
+
|
| 320 |
+
last_funding_df['nextFundingTime'] = pd.to_numeric(last_funding_df['nextFundingTime'], errors='coerce')
|
| 321 |
+
last_funding_df['time'] = pd.to_numeric(last_funding_df['time'], errors='coerce')
|
| 322 |
+
|
| 323 |
+
last_funding_df['nextFundingTime'] = pd.to_datetime(last_funding_df['nextFundingTime'], unit='ms')
|
| 324 |
+
last_funding_df['time'] = pd.to_datetime(last_funding_df['time'], unit='ms')
|
| 325 |
+
last_funding_df = last_funding_df[['symbol', 'nextFundingTime', 'lastFundingRate']] # 保留部分字段
|
| 326 |
+
last_funding_df.rename(columns={'nextFundingTime': 'fundingTime', 'lastFundingRate': 'fundingRate'},
|
| 327 |
+
inplace=True)
|
| 328 |
+
|
| 329 |
+
return last_funding_df
|
| 330 |
+
|
| 331 |
+
def get_funding_rate_df(self, symbol, limit=1000) -> pd.DataFrame:
|
| 332 |
+
"""
|
| 333 |
+
获取币安的历史资金费数据
|
| 334 |
+
:param symbol: 币种名称
|
| 335 |
+
:param limit: 请求获取多少条数据,最大1000
|
| 336 |
+
"""
|
| 337 |
+
param = {'symbol': symbol, 'limit': limit}
|
| 338 |
+
# 获取历史数据
|
| 339 |
+
try:
|
| 340 |
+
funding_df = retry_wrapper(
|
| 341 |
+
self.exchange.fapipublic_get_fundingrate, params=param,
|
| 342 |
+
func_name='获取合约历史资金费数据'
|
| 343 |
+
)
|
| 344 |
+
except Exception as e:
|
| 345 |
+
print(e)
|
| 346 |
+
return pd.DataFrame()
|
| 347 |
+
funding_df = pd.DataFrame(funding_df)
|
| 348 |
+
if funding_df.empty:
|
| 349 |
+
return funding_df
|
| 350 |
+
|
| 351 |
+
funding_df['fundingTime'] = pd.to_datetime(funding_df['fundingTime'].astype(float) // 1000 * 1000,
|
| 352 |
+
unit='ms') # 时间戳内容含有一些纳秒数据需要处理
|
| 353 |
+
funding_df.sort_values('fundingTime', inplace=True)
|
| 354 |
+
|
| 355 |
+
return funding_df
|
| 356 |
+
|
| 357 |
+
# ====================================================================================================
|
| 358 |
+
# ** 账户设置 **
|
| 359 |
+
# ====================================================================================================
|
| 360 |
+
def fetch_transfer_history(self):
|
| 361 |
+
raise NotImplementedError
|
| 362 |
+
|
| 363 |
+
def set_single_side_position(self):
|
| 364 |
+
raise NotImplementedError
|
| 365 |
+
|
| 366 |
+
def set_multi_assets_margin(self):
|
| 367 |
+
"""
|
| 368 |
+
检查是否开启了联合保证金模式
|
| 369 |
+
"""
|
| 370 |
+
# 查询保证金模式
|
| 371 |
+
pass
|
| 372 |
+
|
| 373 |
+
def reset_max_leverage(self, max_leverage=5, coin_list=()):
|
| 374 |
+
"""
|
| 375 |
+
重置一下页面最大杠杆
|
| 376 |
+
:param max_leverage: 设置页面最大杠杆
|
| 377 |
+
:param coin_list: 对指定币种进行调整页面杠杆
|
| 378 |
+
"""
|
| 379 |
+
"""
|
| 380 |
+
重置一下页面最大杠杆
|
| 381 |
+
:param exchange: 交易所对象,用于获取数据
|
| 382 |
+
:param max_leverage: 设置页面最大杠杆
|
| 383 |
+
:param coin_list: 对指定币种进行调整页面杠杆
|
| 384 |
+
"""
|
| 385 |
+
# 获取账户持仓风险(这里有杠杆数据)
|
| 386 |
+
account_info = self.get_swap_account()
|
| 387 |
+
if account_info is None:
|
| 388 |
+
print(f'ℹ️获取账户持仓风险数据为空')
|
| 389 |
+
exit(1)
|
| 390 |
+
|
| 391 |
+
position_risk = pd.DataFrame(account_info['positions']) # 将数据转成DataFrame
|
| 392 |
+
if len(coin_list) > 0:
|
| 393 |
+
position_risk = position_risk[position_risk['symbol'].isin(coin_list)] # 只对选币池中的币种进行调整页面杠杆
|
| 394 |
+
position_risk.set_index('symbol', inplace=True) # 将symbol设为index
|
| 395 |
+
|
| 396 |
+
# 遍历每一个可以持仓的币种,修改页面最大杠杆
|
| 397 |
+
for symbol, row in position_risk.iterrows():
|
| 398 |
+
if int(row['leverage']) != max_leverage:
|
| 399 |
+
reset_leverage_func = getattr(self.exchange, self.constants.get('reset_page_leverage_api'))
|
| 400 |
+
# 设置杠杆
|
| 401 |
+
retry_wrapper(
|
| 402 |
+
reset_leverage_func,
|
| 403 |
+
params={'symbol': symbol, 'leverage': max_leverage, 'timestamp': ''},
|
| 404 |
+
func_name='设置杠杆'
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
# ====================================================================================================
|
| 408 |
+
# ** 交易函数 **
|
| 409 |
+
# ====================================================================================================
|
| 410 |
+
def cancel_all_spot_orders(self):
|
| 411 |
+
# 现货撤单
|
| 412 |
+
get_spot_open_orders_func = getattr(self.exchange, self.constants.get('get_spot_open_orders_api'))
|
| 413 |
+
orders = retry_wrapper(
|
| 414 |
+
get_spot_open_orders_func,
|
| 415 |
+
params={'timestamp': ''}, func_name='查询现货所有挂单'
|
| 416 |
+
)
|
| 417 |
+
symbols = [_['symbol'] for _ in orders]
|
| 418 |
+
symbols = list(set(symbols))
|
| 419 |
+
cancel_spot_open_orders_func = getattr(self.exchange, self.constants.get('cancel_spot_open_orders_api'))
|
| 420 |
+
for _ in symbols:
|
| 421 |
+
retry_wrapper(
|
| 422 |
+
cancel_spot_open_orders_func,
|
| 423 |
+
params={'symbol': _, 'timestamp': ''}, func_name='取消现货挂单'
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
def cancel_all_swap_orders(self):
|
| 427 |
+
# 合约撤单
|
| 428 |
+
get_swap_open_orders_func = getattr(self.exchange, self.constants.get('get_swap_open_orders_api'))
|
| 429 |
+
orders = retry_wrapper(
|
| 430 |
+
get_swap_open_orders_func,
|
| 431 |
+
params={'timestamp': ''}, func_name='查询U本位合约所有挂单'
|
| 432 |
+
)
|
| 433 |
+
symbols = [_['symbol'] for _ in orders]
|
| 434 |
+
symbols = list(set(symbols))
|
| 435 |
+
cancel_swap_open_orders_func = getattr(self.exchange, self.constants.get('cancel_swap_open_orders_api'))
|
| 436 |
+
for _ in symbols:
|
| 437 |
+
retry_wrapper(
|
| 438 |
+
cancel_swap_open_orders_func,
|
| 439 |
+
params={'symbol': _, 'timestamp': ''}, func_name='取消U本位合约挂单'
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
def prepare_order_params_list(
|
| 443 |
+
self, orders_df: pd.DataFrame, symbol_type: str, symbol_ticker_price: pd.Series,
|
| 444 |
+
slip_rate: float = 0.015) -> list:
|
| 445 |
+
"""
|
| 446 |
+
根据策略产生的订单数据,构建每个币种的下单参数
|
| 447 |
+
:param orders_df: 策略产生的订单信息
|
| 448 |
+
:param symbol_type: 下单类型。spot/swap
|
| 449 |
+
:param symbol_ticker_price: 每个币种最新价格
|
| 450 |
+
:param slip_rate: 滑点
|
| 451 |
+
:return: order_params_list 每个币种的下单参数
|
| 452 |
+
"""
|
| 453 |
+
orders_df.sort_values('实际下单资金', ascending=True, inplace=True)
|
| 454 |
+
orders_df.set_index('symbol', inplace=True) # 重新设置index
|
| 455 |
+
|
| 456 |
+
market_info = self.get_market_info(symbol_type)
|
| 457 |
+
min_qty = market_info['min_qty']
|
| 458 |
+
price_precision = market_info['price_precision']
|
| 459 |
+
min_notional = market_info['min_notional']
|
| 460 |
+
|
| 461 |
+
# 遍历symbol_order,构建每个币种的下单参数
|
| 462 |
+
order_params_list = []
|
| 463 |
+
for symbol, row in orders_df.iterrows():
|
| 464 |
+
# ===若当前币种没有最小下单精度、或最小价格精度,报错
|
| 465 |
+
if (symbol not in min_qty) or (symbol not in price_precision):
|
| 466 |
+
# 报错
|
| 467 |
+
print(f'❌当前币种{symbol}没有最小下单精度、或最小价格精度,币种信息异常')
|
| 468 |
+
continue
|
| 469 |
+
|
| 470 |
+
# ===计算下单量、方向、价格
|
| 471 |
+
quantity = row['实际下单量']
|
| 472 |
+
# 按照最小下单量对合约进行四舍五入,对现货就低不就高处理
|
| 473 |
+
# 注意点:合约有reduceOnly参数可以超过你持有的持仓量,现货不行,只能卖的时候留一点点残渣
|
| 474 |
+
quantity = round(quantity, min_qty[symbol]) if symbol_type == 'swap' else apply_precision(quantity,
|
| 475 |
+
min_qty[symbol])
|
| 476 |
+
# 计算下单方向、价格,并增加一定的滑点
|
| 477 |
+
if quantity > 0:
|
| 478 |
+
side = 'BUY'
|
| 479 |
+
price = symbol_ticker_price[symbol] * (1 + slip_rate)
|
| 480 |
+
elif quantity < 0:
|
| 481 |
+
side = 'SELL'
|
| 482 |
+
price = symbol_ticker_price[symbol] * (1 - slip_rate)
|
| 483 |
+
else:
|
| 484 |
+
print('⚠️下单量为0,不进行下单')
|
| 485 |
+
continue
|
| 486 |
+
# 下单量取绝对值
|
| 487 |
+
quantity = abs(quantity)
|
| 488 |
+
# 通过最小价格精度对下单价格进行四舍五入
|
| 489 |
+
price = round(price, price_precision[symbol])
|
| 490 |
+
|
| 491 |
+
# ===判断是否是清仓交易
|
| 492 |
+
reduce_only = True if row['交易模式'] == '清仓' and symbol_type == 'swap' else False
|
| 493 |
+
|
| 494 |
+
# ===判断交易金额是否小于最小下单金额(一般是5元),小于的跳过
|
| 495 |
+
if quantity * price < min_notional.get(symbol, self.order_money_limit[symbol_type]):
|
| 496 |
+
if not reduce_only: # 清仓状态不跳过
|
| 497 |
+
print(f'⚠️{symbol}交易金额是小于最小下单金额(一般合约是5元,现货是10元),跳过该笔交易')
|
| 498 |
+
print(f'ℹ️下单量:{quantity},价格:{price}')
|
| 499 |
+
continue
|
| 500 |
+
|
| 501 |
+
# ===构建下单参数
|
| 502 |
+
price = f'{price:.{price_precision[symbol]}f}' # 根据精度将价格转成str
|
| 503 |
+
quantity = np.format_float_positional(quantity).rstrip('.') # 解决科学计数法的问题
|
| 504 |
+
order_params = {
|
| 505 |
+
'symbol': symbol,
|
| 506 |
+
'side': side,
|
| 507 |
+
'type': 'LIMIT',
|
| 508 |
+
'price': price,
|
| 509 |
+
'quantity': quantity,
|
| 510 |
+
'newClientOrderId': str(int(time.time())),
|
| 511 |
+
'timeInForce': 'GTC',
|
| 512 |
+
'reduceOnly': str(bool(reduce_only)),
|
| 513 |
+
'timestamp': ''
|
| 514 |
+
}
|
| 515 |
+
# 如果是合约下单,添加进行下单列表中,放便后续批量下单
|
| 516 |
+
order_params_list.append(order_params)
|
| 517 |
+
return order_params_list
|
| 518 |
+
|
| 519 |
+
def place_spot_orders_bulk(self, orders_df, slip_rate=0.015):
|
| 520 |
+
symbol_last_price = self.get_spot_ticker_price_series()
|
| 521 |
+
order_params_list = self.prepare_order_params_list(orders_df, 'spot', symbol_last_price, slip_rate)
|
| 522 |
+
|
| 523 |
+
for order_param in order_params_list:
|
| 524 |
+
del order_param['reduceOnly'] # 现货没有这个参数,进行移除
|
| 525 |
+
self.place_spot_order(**order_param)
|
| 526 |
+
|
| 527 |
+
def place_swap_orders_bulk(self, orders_df, slip_rate=0.015):
|
| 528 |
+
symbol_last_price = self.get_swap_ticker_price_series()
|
| 529 |
+
order_params_list = self.prepare_order_params_list(orders_df, 'swap', symbol_last_price, slip_rate)
|
| 530 |
+
|
| 531 |
+
for order_params in order_params_list:
|
| 532 |
+
self.place_swap_order(**order_params)
|
| 533 |
+
|
| 534 |
+
def place_spot_order(self, symbol, side, quantity, price=None, **kwargs) -> dict:
|
| 535 |
+
print(f'`{symbol}`现货下单 {side} {quantity}', '.')
|
| 536 |
+
|
| 537 |
+
# 确定下单参数
|
| 538 |
+
params = {
|
| 539 |
+
'symbol': symbol,
|
| 540 |
+
'side': side,
|
| 541 |
+
'type': 'MARKET',
|
| 542 |
+
'quantity': str(quantity),
|
| 543 |
+
**kwargs
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
if price is not None:
|
| 547 |
+
params['price'] = str(price)
|
| 548 |
+
params['timeInForce'] = 'GTC'
|
| 549 |
+
params['type'] = 'LIMIT'
|
| 550 |
+
|
| 551 |
+
try:
|
| 552 |
+
print(f'ℹ️现货下单参数:{params}')
|
| 553 |
+
# 下单
|
| 554 |
+
order_res = retry_wrapper(
|
| 555 |
+
self.exchange.private_post_order,
|
| 556 |
+
params=params,
|
| 557 |
+
func_name='现货下单'
|
| 558 |
+
)
|
| 559 |
+
print(f'✅现货下单完成,现货下单信息结果:{order_res}')
|
| 560 |
+
except Exception as e:
|
| 561 |
+
print(f'❌现货下单出错:{e}')
|
| 562 |
+
send_wechat_work_msg(
|
| 563 |
+
f'现货 {symbol} 下单 {float(quantity) * float(price)}U 出错,请查看程序日志',
|
| 564 |
+
self.wechat_webhook_url
|
| 565 |
+
)
|
| 566 |
+
return {}
|
| 567 |
+
# 发送下单结果到钉钉
|
| 568 |
+
send_msg_for_order([params], [order_res], self.wechat_webhook_url)
|
| 569 |
+
return order_res
|
| 570 |
+
|
| 571 |
+
def place_swap_order(self, symbol, side, quantity, price=None, **kwargs) -> dict:
|
| 572 |
+
print(f'`{symbol}`U本位合约下单 {side} {quantity}', '.')
|
| 573 |
+
|
| 574 |
+
# 确定下单参数
|
| 575 |
+
params = {
|
| 576 |
+
'symbol': symbol,
|
| 577 |
+
'side': side,
|
| 578 |
+
'type': 'MARKET',
|
| 579 |
+
'quantity': str(quantity),
|
| 580 |
+
**kwargs
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
if price is not None:
|
| 584 |
+
params['price'] = str(price)
|
| 585 |
+
params['timeInForce'] = 'GTC'
|
| 586 |
+
params['type'] = 'LIMIT'
|
| 587 |
+
|
| 588 |
+
try:
|
| 589 |
+
print(f'ℹ️U本位合约下单参数:{params}')
|
| 590 |
+
# 下单
|
| 591 |
+
order_res = retry_wrapper(
|
| 592 |
+
self.exchange.fapiprivate_post_order,
|
| 593 |
+
params=params,
|
| 594 |
+
func_name='U本位合约下单'
|
| 595 |
+
)
|
| 596 |
+
print(f'✅U本位合约下单完成,U本位合约下单信息结果:{order_res}')
|
| 597 |
+
except Exception as e:
|
| 598 |
+
print(f'❌U本位合约下单出错:{e}')
|
| 599 |
+
send_wechat_work_msg(
|
| 600 |
+
f'U本位合约 {symbol} 下单 {float(quantity) * float(price)}U 出错,请查看程序日志',
|
| 601 |
+
self.wechat_webhook_url
|
| 602 |
+
)
|
| 603 |
+
return {}
|
| 604 |
+
send_msg_for_order([params], [order_res], self.wechat_webhook_url)
|
| 605 |
+
return order_res
|
| 606 |
+
|
| 607 |
+
def get_spot_position_df(self) -> pd.DataFrame:
|
| 608 |
+
"""
|
| 609 |
+
获取账户净值
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
:return:
|
| 613 |
+
swap_equity=1000 (表示账户里资金总价值为 1000U )
|
| 614 |
+
|
| 615 |
+
"""
|
| 616 |
+
# 获取U本位合约账户净值(不包含未实现盈亏)
|
| 617 |
+
position_df = retry_wrapper(self.exchange.private_get_account, params={'timestamp': ''},
|
| 618 |
+
func_name='获取现货账户净值') # 获取账户净值
|
| 619 |
+
position_df = pd.DataFrame(position_df['balances'])
|
| 620 |
+
|
| 621 |
+
position_df['free'] = pd.to_numeric(position_df['free'])
|
| 622 |
+
position_df['locked'] = pd.to_numeric(position_df['locked'])
|
| 623 |
+
|
| 624 |
+
position_df['free'] += position_df['locked']
|
| 625 |
+
position_df = position_df[position_df['free'] != 0]
|
| 626 |
+
|
| 627 |
+
position_df.rename(columns={'asset': 'symbol', 'free': '当前持仓量'}, inplace=True)
|
| 628 |
+
|
| 629 |
+
# 保留指定字段
|
| 630 |
+
position_df = position_df[['symbol', '当前持仓量']]
|
| 631 |
+
position_df['仓位价值'] = None # 设置默认值
|
| 632 |
+
|
| 633 |
+
return position_df
|
| 634 |
+
|
| 635 |
+
# =====获取持仓
|
| 636 |
+
# 获取币安账户的实际持仓
|
| 637 |
+
def get_swap_position_df(self) -> pd.DataFrame:
|
| 638 |
+
"""
|
| 639 |
+
获取币安账户的实际持仓
|
| 640 |
+
|
| 641 |
+
:return:
|
| 642 |
+
|
| 643 |
+
当前持仓量 均价 持仓盈亏
|
| 644 |
+
symbol
|
| 645 |
+
RUNEUSDT -82.0 1.208 -0.328000
|
| 646 |
+
FTMUSDT 523.0 0.189 1.208156
|
| 647 |
+
|
| 648 |
+
"""
|
| 649 |
+
# 获取原始数据
|
| 650 |
+
get_swap_position_func = getattr(self.exchange, self.constants.get('get_swap_position_api'))
|
| 651 |
+
position_df = retry_wrapper(get_swap_position_func, params={'timestamp': ''}, func_name='获取账户持仓风险')
|
| 652 |
+
if position_df is None or len(position_df) == 0:
|
| 653 |
+
return pd.DataFrame(columns=['symbol', '当前持仓量', '均价', '持仓盈亏', '当前标记价格', '仓位价值'])
|
| 654 |
+
|
| 655 |
+
position_df = pd.DataFrame(position_df) # 将原始数据转化为dataframe
|
| 656 |
+
|
| 657 |
+
# 整理数据
|
| 658 |
+
columns = {'positionAmt': '当前持仓量', 'entryPrice': '均价', 'unRealizedProfit': '持仓盈亏',
|
| 659 |
+
'markPrice': '当前标记价格'}
|
| 660 |
+
position_df.rename(columns=columns, inplace=True)
|
| 661 |
+
for col in columns.values(): # 转成数字
|
| 662 |
+
position_df[col] = pd.to_numeric(position_df[col])
|
| 663 |
+
|
| 664 |
+
position_df = position_df[position_df['当前持仓量'] != 0] # 只保留有仓位的币种
|
| 665 |
+
position_df.set_index('symbol', inplace=True) # 将symbol设置为index
|
| 666 |
+
position_df['仓位价值'] = position_df['当前持仓量'] * position_df['当前标记价格']
|
| 667 |
+
|
| 668 |
+
# 保留指定字段
|
| 669 |
+
position_df = position_df[['当前持仓量', '均价', '持仓盈亏', '当前标记价格', '仓位价值']]
|
| 670 |
+
|
| 671 |
+
return position_df
|
| 672 |
+
|
| 673 |
+
def update_swap_account(self) -> dict:
|
| 674 |
+
self.swap_account = retry_wrapper(
|
| 675 |
+
self.exchange.fapiprivatev2_get_account, params={'timestamp': ''},
|
| 676 |
+
func_name='获取U本位合约账户信息'
|
| 677 |
+
)
|
| 678 |
+
return self.swap_account
|
| 679 |
+
|
| 680 |
+
def get_swap_account(self, require_update: bool = False) -> dict:
|
| 681 |
+
if self.swap_account is None or require_update:
|
| 682 |
+
self.update_swap_account()
|
| 683 |
+
return self.swap_account
|
| 684 |
+
|
| 685 |
+
def get_account_overview(self):
|
| 686 |
+
raise NotImplementedError
|
| 687 |
+
|
| 688 |
+
def fetch_spot_trades(self, symbol, end_time) -> pd.DataFrame:
|
| 689 |
+
# =设置获取订单时的参数
|
| 690 |
+
params = {
|
| 691 |
+
'symbol': symbol, # 设置获取订单的币种
|
| 692 |
+
'endTime': int(time.mktime(end_time.timetuple())) * 1000, # 设置获取订单的截止时间
|
| 693 |
+
'limit': 1000, # 最大获取1000条订单信息
|
| 694 |
+
'timestamp': ''
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
# =调用API获取订单信息
|
| 698 |
+
get_spot_my_trades_func = getattr(self.exchange, self.constants.get('get_spot_my_trades_api'))
|
| 699 |
+
trades = retry_wrapper(get_spot_my_trades_func, params=params, func_name='获取币种历史订单信息',
|
| 700 |
+
if_exit=False) # 获取账户净值
|
| 701 |
+
# 如果获取订单数据失败,进行容错处理,返回空df
|
| 702 |
+
if trades is None:
|
| 703 |
+
return pd.DataFrame()
|
| 704 |
+
|
| 705 |
+
trades = pd.DataFrame(trades) # 转成df格式
|
| 706 |
+
# =如果获取到的该币种的订单数据是空的,则跳过,继续获取另外一个币种
|
| 707 |
+
if trades.empty:
|
| 708 |
+
return pd.DataFrame()
|
| 709 |
+
|
| 710 |
+
# 转换数据格式
|
| 711 |
+
for col in ('isBuyer', 'price', 'qty', 'quoteQty', 'commission'):
|
| 712 |
+
trades[col] = pd.to_numeric(trades[col], errors='coerce')
|
| 713 |
+
|
| 714 |
+
# =如果isBuyer为1则为买入,否则为卖出
|
| 715 |
+
trades['方向'] = np.where(trades['isBuyer'] == 1, 1, -1)
|
| 716 |
+
# =整理下有用的数据
|
| 717 |
+
trades = trades[['time', 'symbol', 'price', 'qty', 'quoteQty', 'commission', 'commissionAsset', '方向']]
|
| 718 |
+
|
| 719 |
+
return trades
|
| 720 |
+
|
| 721 |
+
@classmethod
|
| 722 |
+
def get_dummy_client(cls) -> 'BinanceClient':
|
| 723 |
+
return cls()
|
基础库/common_core/exchange/binance_async.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
[币安异步交易客户端]
|
| 4 |
+
功能:基于 AsyncIO 实现高并发行情获取与交易指令发送,大幅提升数据下载和实盘响应速度。
|
| 5 |
+
"""
|
| 6 |
+
import asyncio
|
| 7 |
+
import math
|
| 8 |
+
import time
|
| 9 |
+
import traceback
|
| 10 |
+
import ccxt.async_support as ccxt # 异步 CCXT
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from common_core.exchange.base_client import BinanceClient
|
| 15 |
+
from common_core.utils.async_commons import async_retry_wrapper
|
| 16 |
+
|
| 17 |
+
class AsyncBinanceClient(BinanceClient):
|
| 18 |
+
def __init__(self, **config):
|
| 19 |
+
super().__init__(**config)
|
| 20 |
+
# 使用异步版本覆盖 self.exchange
|
| 21 |
+
default_exchange_config = {
|
| 22 |
+
'timeout': 30000,
|
| 23 |
+
'rateLimit': 30,
|
| 24 |
+
'enableRateLimit': False,
|
| 25 |
+
'options': {'adjustForTimeDifference': True, 'recvWindow': 10000},
|
| 26 |
+
}
|
| 27 |
+
self.exchange = ccxt.binance(config.get('exchange_config', default_exchange_config))
|
| 28 |
+
|
| 29 |
+
async def close(self):
|
| 30 |
+
await self.exchange.close()
|
| 31 |
+
|
| 32 |
+
async def _fetch_swap_exchange_info_list(self) -> list:
|
| 33 |
+
exchange_info = await async_retry_wrapper(self.exchange.fapipublic_get_exchangeinfo, func_name='获取BN合约币种规则数据')
|
| 34 |
+
return exchange_info['symbols']
|
| 35 |
+
|
| 36 |
+
async def _fetch_spot_exchange_info_list(self) -> list:
|
| 37 |
+
exchange_info = await async_retry_wrapper(self.exchange.public_get_exchangeinfo, func_name='获取BN现货币种规则数据')
|
| 38 |
+
return exchange_info['symbols']
|
| 39 |
+
|
| 40 |
+
async def fetch_market_info(self, symbol_type='swap', quote_symbol='USDT'):
|
| 41 |
+
print(f'🔄(Async) 更新{symbol_type}市场数据...')
|
| 42 |
+
if symbol_type == 'swap':
|
| 43 |
+
exchange_info_list = await self._fetch_swap_exchange_info_list()
|
| 44 |
+
else:
|
| 45 |
+
exchange_info_list = await self._fetch_spot_exchange_info_list()
|
| 46 |
+
|
| 47 |
+
# 复用基类逻辑?
|
| 48 |
+
# 逻辑是处理列表。我们可以复制或提取它。
|
| 49 |
+
# 为了速度,我将在这里复制处理逻辑。
|
| 50 |
+
|
| 51 |
+
symbol_list = []
|
| 52 |
+
full_symbol_list = []
|
| 53 |
+
min_qty = {}
|
| 54 |
+
price_precision = {}
|
| 55 |
+
min_notional = {}
|
| 56 |
+
|
| 57 |
+
for info in exchange_info_list:
|
| 58 |
+
symbol = info['symbol']
|
| 59 |
+
if info['quoteAsset'] != quote_symbol or info['status'] != 'TRADING':
|
| 60 |
+
continue
|
| 61 |
+
full_symbol_list.append(symbol)
|
| 62 |
+
|
| 63 |
+
if (symbol_type == 'swap' and info['contractType'] != 'PERPETUAL') or info['baseAsset'] in self.stable_symbol:
|
| 64 |
+
pass
|
| 65 |
+
else:
|
| 66 |
+
symbol_list.append(symbol)
|
| 67 |
+
|
| 68 |
+
for _filter in info['filters']:
|
| 69 |
+
if _filter['filterType'] == 'PRICE_FILTER':
|
| 70 |
+
price_precision[symbol] = int(math.log(float(_filter['tickSize']), 0.1))
|
| 71 |
+
elif _filter['filterType'] == 'LOT_SIZE':
|
| 72 |
+
min_qty[symbol] = int(math.log(float(_filter['minQty']), 0.1))
|
| 73 |
+
elif _filter['filterType'] == 'MIN_NOTIONAL' and symbol_type == 'swap':
|
| 74 |
+
min_notional[symbol] = float(_filter['notional'])
|
| 75 |
+
elif _filter['filterType'] == 'NOTIONAL' and symbol_type == 'spot':
|
| 76 |
+
min_notional[symbol] = float(_filter['minNotional'])
|
| 77 |
+
|
| 78 |
+
self.market_info[symbol_type] = {
|
| 79 |
+
'symbol_list': symbol_list,
|
| 80 |
+
'full_symbol_list': full_symbol_list,
|
| 81 |
+
'min_qty': min_qty,
|
| 82 |
+
'price_precision': price_precision,
|
| 83 |
+
'min_notional': min_notional,
|
| 84 |
+
'last_update': int(time.time())
|
| 85 |
+
}
|
| 86 |
+
return self.market_info[symbol_type]
|
| 87 |
+
|
| 88 |
+
async def get_market_info(self, symbol_type, expire_seconds: int = 3600 * 12, require_update: bool = False, quote_symbol='USDT'):
|
| 89 |
+
if require_update:
|
| 90 |
+
last_update = 0
|
| 91 |
+
else:
|
| 92 |
+
last_update = self.market_info.get(symbol_type, {}).get('last_update', 0)
|
| 93 |
+
|
| 94 |
+
if last_update + expire_seconds < int(time.time()):
|
| 95 |
+
await self.fetch_market_info(symbol_type, quote_symbol)
|
| 96 |
+
|
| 97 |
+
return self.market_info[symbol_type]
|
| 98 |
+
|
| 99 |
+
async def get_candle_df(self, symbol, run_time, limit=1500, interval='1h', symbol_type='swap') -> pd.DataFrame:
|
| 100 |
+
_limit = limit
|
| 101 |
+
if limit > 1000:
|
| 102 |
+
if symbol_type == 'spot':
|
| 103 |
+
_limit = 1000
|
| 104 |
+
else:
|
| 105 |
+
_limit = 499
|
| 106 |
+
|
| 107 |
+
start_time_dt = run_time - pd.to_timedelta(interval) * limit
|
| 108 |
+
df_list = []
|
| 109 |
+
data_len = 0
|
| 110 |
+
params = {
|
| 111 |
+
'symbol': symbol,
|
| 112 |
+
'interval': interval,
|
| 113 |
+
'limit': _limit,
|
| 114 |
+
'startTime': int(time.mktime(start_time_dt.timetuple())) * 1000
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
while True:
|
| 118 |
+
try:
|
| 119 |
+
if symbol_type == 'swap':
|
| 120 |
+
kline = await async_retry_wrapper(
|
| 121 |
+
self.exchange.fapipublic_get_klines, params=params, func_name=f'获取{symbol}K线', if_exit=False
|
| 122 |
+
)
|
| 123 |
+
else:
|
| 124 |
+
kline = await async_retry_wrapper(
|
| 125 |
+
self.exchange.public_get_klines, params=params, func_name=f'获取{symbol}K线', if_exit=False
|
| 126 |
+
)
|
| 127 |
+
except Exception as e:
|
| 128 |
+
print(f"Error fetching {symbol}: {e}")
|
| 129 |
+
return pd.DataFrame()
|
| 130 |
+
|
| 131 |
+
if not kline:
|
| 132 |
+
break
|
| 133 |
+
|
| 134 |
+
df = pd.DataFrame(kline, dtype='float')
|
| 135 |
+
if df.empty:
|
| 136 |
+
break
|
| 137 |
+
|
| 138 |
+
columns = {0: 'candle_begin_time', 1: 'open', 2: 'high', 3: 'low', 4: 'close', 5: 'volume', 6: 'close_time',
|
| 139 |
+
7: 'quote_volume', 8: 'trade_num', 9: 'taker_buy_base_asset_volume', 10: 'taker_buy_quote_asset_volume',
|
| 140 |
+
11: 'ignore'}
|
| 141 |
+
df.rename(columns=columns, inplace=True)
|
| 142 |
+
df['candle_begin_time'] = pd.to_datetime(df['candle_begin_time'], unit='ms')
|
| 143 |
+
|
| 144 |
+
# 优化:稍后进行排序和去重
|
| 145 |
+
|
| 146 |
+
df_list.append(df)
|
| 147 |
+
data_len += df.shape[0]
|
| 148 |
+
|
| 149 |
+
if data_len >= limit:
|
| 150 |
+
break
|
| 151 |
+
|
| 152 |
+
last_time = int(df.iloc[-1]['candle_begin_time'].timestamp()) * 1000
|
| 153 |
+
if params['startTime'] == last_time:
|
| 154 |
+
break
|
| 155 |
+
|
| 156 |
+
params['startTime'] = last_time
|
| 157 |
+
# 异步 sleep 通常在这里不需要,因为我们要尽可能快,但为了安全起见:
|
| 158 |
+
# await asyncio.sleep(0.01)
|
| 159 |
+
|
| 160 |
+
if not df_list:
|
| 161 |
+
return pd.DataFrame()
|
| 162 |
+
|
| 163 |
+
all_df = pd.concat(df_list, ignore_index=True)
|
| 164 |
+
all_df['symbol'] = symbol
|
| 165 |
+
all_df['symbol_type'] = symbol_type
|
| 166 |
+
all_df.sort_values(by=['candle_begin_time'], inplace=True)
|
| 167 |
+
all_df.drop_duplicates(subset=['candle_begin_time'], keep='last', inplace=True)
|
| 168 |
+
|
| 169 |
+
all_df = all_df[all_df['candle_begin_time'] + pd.Timedelta(hours=self.utc_offset) < run_time]
|
| 170 |
+
all_df.reset_index(drop=True, inplace=True)
|
| 171 |
+
|
| 172 |
+
return all_df
|
基础库/common_core/exchange/standard_client.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
[币安标准同步客户端]
|
| 4 |
+
功能:提供传统的同步接口调用方式,用于兼容旧代码或低频操作场景,支持现货和合约的下单、撤单、查询等操作。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
from common_core.exchange.base_client import BinanceClient
|
| 13 |
+
from common_core.utils.commons import retry_wrapper
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class StandardClient(BinanceClient):
|
| 17 |
+
constants = dict(
|
| 18 |
+
spot_account_type='SPOT',
|
| 19 |
+
reset_page_leverage_api='fapiprivate_post_leverage',
|
| 20 |
+
get_swap_position_api='fapiprivatev2_get_positionrisk',
|
| 21 |
+
get_spot_open_orders_api='private_get_openorders',
|
| 22 |
+
cancel_spot_open_orders_api='private_delete_openorders',
|
| 23 |
+
get_swap_open_orders_api='fapiprivate_get_openorders',
|
| 24 |
+
cancel_swap_open_orders_api='fapiprivate_delete_allopenorders',
|
| 25 |
+
get_spot_my_trades_api='private_get_mytrades',
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
def __init__(self, **config):
|
| 29 |
+
super().__init__(**config)
|
| 30 |
+
self.is_pure_long: bool = config.get('is_pure_long', False)
|
| 31 |
+
|
| 32 |
+
def _set_position_side(self, dual_side_position=False):
|
| 33 |
+
"""
|
| 34 |
+
检查是否是单向持仓模式
|
| 35 |
+
"""
|
| 36 |
+
params = {'dualSidePosition': 'true' if dual_side_position else 'false', 'timestamp': ''}
|
| 37 |
+
retry_wrapper(self.exchange.fapiprivate_post_positionside_dual, params=params,
|
| 38 |
+
func_name='fapiprivate_post_positionside_dual', if_exit=False)
|
| 39 |
+
print(f'ℹ️修改持仓模式为单向持仓')
|
| 40 |
+
|
| 41 |
+
def set_single_side_position(self):
|
| 42 |
+
|
| 43 |
+
# 查询持仓模式
|
| 44 |
+
res = retry_wrapper(
|
| 45 |
+
self.exchange.fapiprivate_get_positionside_dual, params={'timestamp': ''}, func_name='设置单向持仓',
|
| 46 |
+
if_exit=False
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
is_duel_side_position = bool(res['dualSidePosition'])
|
| 50 |
+
|
| 51 |
+
# 判断是否是单向持仓模式
|
| 52 |
+
if is_duel_side_position: # 若当前持仓模式不是单向持仓模式,则调用接口修改持仓模式为单向持仓模式
|
| 53 |
+
self._set_position_side(dual_side_position=False)
|
| 54 |
+
|
| 55 |
+
def set_multi_assets_margin(self):
|
| 56 |
+
"""
|
| 57 |
+
检查是否开启了联合保证金模式
|
| 58 |
+
"""
|
| 59 |
+
# 查询保证金模式
|
| 60 |
+
res = retry_wrapper(self.exchange.fapiprivate_get_multiassetsmargin, params={'timestamp': ''},
|
| 61 |
+
func_name='fapiprivate_get_multiassetsmargin', if_exit=False)
|
| 62 |
+
# 判断是否开启了联合保证金模式
|
| 63 |
+
if not bool(res['multiAssetsMargin']): # 若联合保证金模式没有开启,则调用接口开启一下联合保证金模式
|
| 64 |
+
params = {'multiAssetsMargin': 'true', 'timestamp': ''}
|
| 65 |
+
retry_wrapper(self.exchange.fapiprivate_post_multiassetsmargin, params=params,
|
| 66 |
+
func_name='fapiprivate_post_multiassetsmargin', if_exit=False)
|
| 67 |
+
print('✅开启联合保证金模式')
|
| 68 |
+
|
| 69 |
+
def get_account_overview(self):
|
| 70 |
+
spot_ticker_data = self.fetch_spot_ticker_price()
|
| 71 |
+
spot_ticker = {_['symbol']: float(_['price']) for _ in spot_ticker_data}
|
| 72 |
+
|
| 73 |
+
swap_account = self.get_swap_account()
|
| 74 |
+
equity = pd.DataFrame(swap_account['assets'])
|
| 75 |
+
swap_usdt_balance = float(equity[equity['asset'] == 'USDT']['walletBalance']) # 获取usdt资产
|
| 76 |
+
# 计算联合保证金
|
| 77 |
+
if self.coin_margin:
|
| 78 |
+
for _symbol, _coin_balance in self.coin_margin.items():
|
| 79 |
+
if _symbol.replace('USDT', '') in equity['asset'].to_list():
|
| 80 |
+
swap_usdt_balance += _coin_balance
|
| 81 |
+
else:
|
| 82 |
+
print(f'⚠️合约账户未找到 {_symbol} 的资产,无法计算 {_symbol} 的保证金')
|
| 83 |
+
|
| 84 |
+
swap_position_df = self.get_swap_position_df()
|
| 85 |
+
spot_position_df = self.get_spot_position_df()
|
| 86 |
+
print('✅获取账户资产数据完成\n')
|
| 87 |
+
|
| 88 |
+
print(f'ℹ️准备处理资产数据...')
|
| 89 |
+
# 获取当前账号现货U的数量
|
| 90 |
+
if 'USDT' in spot_position_df['symbol'].to_list():
|
| 91 |
+
spot_usdt_balance = spot_position_df.loc[spot_position_df['symbol'] == 'USDT', '当前持仓量'].iloc[0]
|
| 92 |
+
# 去除掉USDT现货
|
| 93 |
+
spot_position_df = spot_position_df[spot_position_df['symbol'] != 'USDT']
|
| 94 |
+
else:
|
| 95 |
+
spot_usdt_balance = 0
|
| 96 |
+
# 追加USDT后缀,方便计算usdt价值
|
| 97 |
+
spot_position_df.loc[spot_position_df['symbol'] != 'USDT', 'symbol'] = spot_position_df['symbol'] + 'USDT'
|
| 98 |
+
spot_position_df['仓位价值'] = spot_position_df.apply(
|
| 99 |
+
lambda row: row['当前持仓量'] * spot_ticker.get(row["symbol"], 0), axis=1)
|
| 100 |
+
|
| 101 |
+
# 过滤掉不含报价的币
|
| 102 |
+
spot_position_df = spot_position_df[spot_position_df['仓位价值'] != 0]
|
| 103 |
+
# 仓位价值 小于 5U,无法下单的碎币,单独记录
|
| 104 |
+
dust_spot_df = spot_position_df[spot_position_df['仓位价值'] < 5]
|
| 105 |
+
# 过滤掉仓位价值 小于 5U
|
| 106 |
+
spot_position_df = spot_position_df[spot_position_df['仓位价值'] > 5]
|
| 107 |
+
# 过滤掉BNB,用于抵扣手续费,不参与现货交易
|
| 108 |
+
spot_position_df = spot_position_df[spot_position_df['symbol'] != 'BNBUSDT']
|
| 109 |
+
|
| 110 |
+
# 现货净值
|
| 111 |
+
spot_equity = spot_position_df['仓位价值'].sum() + spot_usdt_balance
|
| 112 |
+
|
| 113 |
+
# 持仓盈亏
|
| 114 |
+
account_pnl = swap_position_df['持仓盈亏'].sum()
|
| 115 |
+
|
| 116 |
+
# =====处理现货持仓列表信息
|
| 117 |
+
# 构建币种的balance信息
|
| 118 |
+
# 币种 : 价值
|
| 119 |
+
spot_assets_pos_dict = spot_position_df[['symbol', '仓位价值']].to_dict(orient='records')
|
| 120 |
+
spot_assets_pos_dict = {_['symbol']: _['仓位价值'] for _ in spot_assets_pos_dict}
|
| 121 |
+
|
| 122 |
+
# 币种 : 数量
|
| 123 |
+
spot_asset_amount_dict = spot_position_df[['symbol', '当前持仓量']].to_dict(orient='records')
|
| 124 |
+
spot_asset_amount_dict = {_['symbol']: _['当前持仓量'] for _ in spot_asset_amount_dict}
|
| 125 |
+
|
| 126 |
+
# =====处理合约持仓列表信息
|
| 127 |
+
# 币种 : 价值
|
| 128 |
+
swap_position_df.reset_index(inplace=True)
|
| 129 |
+
swap_assets_pos_dict = swap_position_df[['symbol', '仓位价值']].to_dict(orient='records')
|
| 130 |
+
swap_assets_pos_dict = {_['symbol']: _['仓位价值'] for _ in swap_assets_pos_dict}
|
| 131 |
+
|
| 132 |
+
# 币种 : 数量
|
| 133 |
+
swap_asset_amount_dict = swap_position_df[['symbol', '当前持仓量']].to_dict(orient='records')
|
| 134 |
+
swap_asset_amount_dict = {_['symbol']: _['当前持仓量'] for _ in swap_asset_amount_dict}
|
| 135 |
+
|
| 136 |
+
# 币种 : pnl
|
| 137 |
+
swap_asset_pnl_dict = swap_position_df[['symbol', '持仓盈亏']].to_dict(orient='records')
|
| 138 |
+
swap_asset_pnl_dict = {_['symbol']: _['持仓盈亏'] for _ in swap_asset_pnl_dict}
|
| 139 |
+
|
| 140 |
+
# 处理完成之后在设置index
|
| 141 |
+
swap_position_df.set_index('symbol', inplace=True)
|
| 142 |
+
|
| 143 |
+
# 账户总净值 = 现货总价值 + 合约usdt + 持仓盈亏
|
| 144 |
+
account_equity = (spot_equity + swap_usdt_balance + account_pnl)
|
| 145 |
+
|
| 146 |
+
print('✅处理资产数据完成\n')
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
'usdt_balance': spot_usdt_balance + swap_usdt_balance,
|
| 150 |
+
'negative_balance': 0,
|
| 151 |
+
'account_pnl': account_pnl,
|
| 152 |
+
'account_equity': account_equity,
|
| 153 |
+
'spot_assets': {
|
| 154 |
+
'assets_pos_value': spot_assets_pos_dict,
|
| 155 |
+
'assets_amount': spot_asset_amount_dict,
|
| 156 |
+
'usdt': spot_usdt_balance,
|
| 157 |
+
'equity': spot_equity,
|
| 158 |
+
'dust_spot_df': dust_spot_df,
|
| 159 |
+
'spot_position_df': spot_position_df
|
| 160 |
+
},
|
| 161 |
+
'swap_assets': {
|
| 162 |
+
'assets_pos_value': swap_assets_pos_dict,
|
| 163 |
+
'assets_amount': swap_asset_amount_dict,
|
| 164 |
+
'assets_pnl': swap_asset_pnl_dict,
|
| 165 |
+
'usdt': swap_usdt_balance,
|
| 166 |
+
'equity': swap_usdt_balance + account_pnl,
|
| 167 |
+
'swap_position_df': swap_position_df
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
def fetch_transfer_history(self, start_time=datetime.now()):
|
| 172 |
+
"""
|
| 173 |
+
获取划转记录
|
| 174 |
+
|
| 175 |
+
MAIN_UMFUTURE 现货钱包转向U本位合约钱包
|
| 176 |
+
MAIN_MARGIN 现货钱包转向杠杆全仓钱包
|
| 177 |
+
|
| 178 |
+
UMFUTURE_MAIN U本位合约钱包转向现货钱包
|
| 179 |
+
UMFUTURE_MARGIN U本位合约钱包转向杠杆全仓钱包
|
| 180 |
+
|
| 181 |
+
CMFUTURE_MAIN 币本位合约钱包转向现货钱包
|
| 182 |
+
|
| 183 |
+
MARGIN_MAIN 杠杆全仓钱包转向现货钱包
|
| 184 |
+
MARGIN_UMFUTURE 杠杆全仓钱包转向U本位合约钱包
|
| 185 |
+
|
| 186 |
+
MAIN_FUNDING 现货钱包转向资金钱包
|
| 187 |
+
FUNDING_MAIN 资金钱包转向现货钱包
|
| 188 |
+
|
| 189 |
+
FUNDING_UMFUTURE 资金钱包转向U本位合约钱包
|
| 190 |
+
UMFUTURE_FUNDING U本位合约钱包转向资金钱包
|
| 191 |
+
|
| 192 |
+
MAIN_OPTION 现货钱包转向期权钱包
|
| 193 |
+
OPTION_MAIN 期权钱包转向现货钱包
|
| 194 |
+
|
| 195 |
+
UMFUTURE_OPTION U本位合约钱包转向期权钱包
|
| 196 |
+
OPTION_UMFUTURE 期权钱包转向U本位合约钱包
|
| 197 |
+
|
| 198 |
+
MAIN_PORTFOLIO_MARGIN 现货钱包转向统一账户钱包
|
| 199 |
+
PORTFOLIO_MARGIN_MAIN 统一账户钱包转向现货钱包
|
| 200 |
+
|
| 201 |
+
MAIN_ISOLATED_MARGIN 现货钱包转向逐仓账户钱包
|
| 202 |
+
ISOLATED_MARGIN_MAIN 逐仓钱包转向现货账户钱包
|
| 203 |
+
"""
|
| 204 |
+
start_time = start_time - pd.Timedelta(days=10)
|
| 205 |
+
add_type = ['CMFUTURE_MAIN', 'MARGIN_MAIN', 'MARGIN_UMFUTURE', 'FUNDING_MAIN', 'FUNDING_UMFUTURE',
|
| 206 |
+
'OPTION_MAIN', 'OPTION_UMFUTURE', 'PORTFOLIO_MARGIN_MAIN', 'ISOLATED_MARGIN_MAIN']
|
| 207 |
+
reduce_type = ['MAIN_MARGIN', 'UMFUTURE_MARGIN', 'MAIN_FUNDING', 'UMFUTURE_FUNDING', 'MAIN_OPTION',
|
| 208 |
+
'UMFUTURE_OPTION', 'MAIN_PORTFOLIO_MARGIN', 'MAIN_ISOLATED_MARGIN']
|
| 209 |
+
|
| 210 |
+
result = []
|
| 211 |
+
for _ in add_type + reduce_type:
|
| 212 |
+
params = {
|
| 213 |
+
'fromSymbol': 'USDT',
|
| 214 |
+
'startTime': int(start_time.timestamp() * 1000),
|
| 215 |
+
'type': _,
|
| 216 |
+
'timestamp': int(round(time.time() * 1000)),
|
| 217 |
+
'size': 100,
|
| 218 |
+
}
|
| 219 |
+
if _ == 'MAIN_ISOLATED_MARGIN':
|
| 220 |
+
params['toSymbol'] = 'USDT'
|
| 221 |
+
del params['fromSymbol']
|
| 222 |
+
# 获取划转信息(取上一小时到当前时间的划转记录)
|
| 223 |
+
try:
|
| 224 |
+
account_info = self.exchange.sapi_get_asset_transfer(params)
|
| 225 |
+
except BaseException as e:
|
| 226 |
+
print(e)
|
| 227 |
+
print(f'当前账户查询类型【{_}】失败,不影响后续操作,请忽略')
|
| 228 |
+
continue
|
| 229 |
+
if account_info and int(account_info['total']) > 0:
|
| 230 |
+
res = pd.DataFrame(account_info['rows'])
|
| 231 |
+
res['timestamp'] = pd.to_datetime(res['timestamp'], unit='ms')
|
| 232 |
+
res.loc[res['type'].isin(add_type), 'flag'] = 1
|
| 233 |
+
res.loc[res['type'].isin(reduce_type), 'flag'] = -1
|
| 234 |
+
res = res[res['status'] == 'CONFIRMED']
|
| 235 |
+
result.append(res)
|
| 236 |
+
|
| 237 |
+
# 获取主账号与子账号之间划转记录
|
| 238 |
+
result2 = []
|
| 239 |
+
for transfer_type in [1, 2]: # 1: 划入。从主账号划转进来 2: 划出。从子账号划转出去
|
| 240 |
+
params = {
|
| 241 |
+
'asset': 'USDT',
|
| 242 |
+
'type': transfer_type,
|
| 243 |
+
'startTime': int(start_time.timestamp() * 1000),
|
| 244 |
+
}
|
| 245 |
+
try:
|
| 246 |
+
account_info = self.exchange.sapi_get_sub_account_transfer_subuserhistory(params)
|
| 247 |
+
except BaseException as e:
|
| 248 |
+
print(e)
|
| 249 |
+
print(f'当前账户查询类型【{transfer_type}】失败,不影响后续操作,请忽略')
|
| 250 |
+
continue
|
| 251 |
+
if account_info and len(account_info):
|
| 252 |
+
res = pd.DataFrame(account_info)
|
| 253 |
+
res['time'] = pd.to_datetime(res['time'], unit='ms')
|
| 254 |
+
res.rename(columns={'qty': 'amount', 'time': 'timestamp'}, inplace=True)
|
| 255 |
+
res.loc[res['toAccountType'] == 'SPOT', 'flag'] = 1 if transfer_type == 1 else -1
|
| 256 |
+
res.loc[res['toAccountType'] == 'USDT_FUTURE', 'flag'] = 1 if transfer_type == 1 else -1
|
| 257 |
+
res = res[res['status'] == 'SUCCESS']
|
| 258 |
+
res = res[res['toAccountType'].isin(['SPOT', 'USDT_FUTURE'])]
|
| 259 |
+
result2.append(res)
|
| 260 |
+
|
| 261 |
+
# 将账号之间的划转与单账号内部换转数据合并
|
| 262 |
+
result.extend(result2)
|
| 263 |
+
if not len(result):
|
| 264 |
+
return pd.DataFrame()
|
| 265 |
+
|
| 266 |
+
all_df = pd.concat(result, ignore_index=True)
|
| 267 |
+
all_df.drop_duplicates(subset=['timestamp', 'tranId', 'flag'], inplace=True)
|
| 268 |
+
all_df = all_df[all_df['asset'] == 'USDT']
|
| 269 |
+
all_df.sort_values('timestamp', inplace=True)
|
| 270 |
+
|
| 271 |
+
all_df['amount'] = all_df['amount'].astype(float) * all_df['flag']
|
| 272 |
+
all_df.rename(columns={'amount': '账户总净值'}, inplace=True)
|
| 273 |
+
all_df['type'] = 'transfer'
|
| 274 |
+
all_df = all_df[['timestamp', '账户总净值', 'type']]
|
| 275 |
+
all_df['timestamp'] = all_df['timestamp'] + pd.Timedelta(hours=self.utc_offset)
|
| 276 |
+
all_df.reset_index(inplace=True, drop=True)
|
| 277 |
+
|
| 278 |
+
all_df['time'] = all_df['timestamp']
|
| 279 |
+
result_df = all_df.resample(rule='1H', on='timestamp').agg(
|
| 280 |
+
{'time': 'last', '账户总净值': 'sum', 'type': 'last'})
|
| 281 |
+
result_df = result_df[result_df['type'].notna()]
|
| 282 |
+
result_df.reset_index(inplace=True, drop=True)
|
| 283 |
+
|
| 284 |
+
return result_df
|
基础库/common_core/pyproject.toml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "quant-unified-common-core"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Quant Unified 通用核心库:交易所客户端、回测、风控与工具集"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.11"
|
| 11 |
+
authors = [{ name = "Quant Unified Team" }]
|
| 12 |
+
license = { text = "Proprietary" }
|
| 13 |
+
dependencies = [
|
| 14 |
+
"ccxt>=4.3.0",
|
| 15 |
+
"pandas>=2.2.0",
|
| 16 |
+
"numpy>=1.26.0",
|
| 17 |
+
"aiohttp>=3.9.0",
|
| 18 |
+
"numba>=0.59.0",
|
| 19 |
+
"plotly>=5.18.0",
|
| 20 |
+
"scipy>=1.10.0",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
[tool.setuptools]
|
| 24 |
+
packages = { find = { where = ["."] } }
|
| 25 |
+
|
基础库/common_core/quant_unified_common_core.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: quant-unified-common-core
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: Quant Unified 通用核心库:交易所客户端、回测、风控与工具集
|
| 5 |
+
Author: Quant Unified Team
|
| 6 |
+
License: Proprietary
|
| 7 |
+
Requires-Python: >=3.11
|
| 8 |
+
Description-Content-Type: text/markdown
|
| 9 |
+
Requires-Dist: ccxt>=4.3.0
|
| 10 |
+
Requires-Dist: pandas>=2.2.0
|
| 11 |
+
Requires-Dist: numpy>=1.26.0
|
| 12 |
+
Requires-Dist: aiohttp>=3.9.0
|
| 13 |
+
Requires-Dist: numba>=0.59.0
|
| 14 |
+
Requires-Dist: plotly>=5.18.0
|
| 15 |
+
Requires-Dist: scipy>=1.10.0
|
基础库/common_core/quant_unified_common_core.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pyproject.toml
|
| 2 |
+
backtest/__init__.py
|
| 3 |
+
backtest/equity.py
|
| 4 |
+
backtest/evaluate.py
|
| 5 |
+
backtest/figure.py
|
| 6 |
+
backtest/rebalance.py
|
| 7 |
+
backtest/simulator.py
|
| 8 |
+
backtest/version.py
|
| 9 |
+
exchange/__init__.py
|
| 10 |
+
exchange/base_client.py
|
| 11 |
+
exchange/binance_async.py
|
| 12 |
+
exchange/standard_client.py
|
| 13 |
+
quant_unified_common_core.egg-info/PKG-INFO
|
| 14 |
+
quant_unified_common_core.egg-info/SOURCES.txt
|
| 15 |
+
quant_unified_common_core.egg-info/dependency_links.txt
|
| 16 |
+
quant_unified_common_core.egg-info/requires.txt
|
| 17 |
+
quant_unified_common_core.egg-info/top_level.txt
|
| 18 |
+
risk_ctrl/liquidation.py
|
| 19 |
+
risk_ctrl/test_liquidation.py
|
| 20 |
+
utils/__init__.py
|
| 21 |
+
utils/async_commons.py
|
| 22 |
+
utils/commons.py
|
| 23 |
+
utils/dingding.py
|
| 24 |
+
utils/factor_hub.py
|
| 25 |
+
utils/functions.py
|
| 26 |
+
utils/path_kit.py
|
基础库/common_core/quant_unified_common_core.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
基础库/common_core/quant_unified_common_core.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ccxt>=4.3.0
|
| 2 |
+
pandas>=2.2.0
|
| 3 |
+
numpy>=1.26.0
|
| 4 |
+
aiohttp>=3.9.0
|
| 5 |
+
numba>=0.59.0
|
| 6 |
+
plotly>=5.18.0
|
| 7 |
+
scipy>=1.10.0
|
基础库/common_core/quant_unified_common_core.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
backtest
|
| 2 |
+
exchange
|
| 3 |
+
risk_ctrl
|
| 4 |
+
utils
|
基础库/common_core/risk_ctrl/liquidation.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
[爆仓检查模块]
|
| 4 |
+
功能:提供通用的保证金率计算与爆仓检测逻辑,支持单币种和多币种组合模式。
|
| 5 |
+
"""
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
class LiquidationChecker:
|
| 9 |
+
"""
|
| 10 |
+
通用爆仓检查器
|
| 11 |
+
"""
|
| 12 |
+
def __init__(self, min_margin_rate=0.005):
|
| 13 |
+
"""
|
| 14 |
+
初始化
|
| 15 |
+
:param min_margin_rate: 维持保证金率 (默认 0.5%)
|
| 16 |
+
"""
|
| 17 |
+
self.min_margin_rate = min_margin_rate
|
| 18 |
+
|
| 19 |
+
def check_margin_rate(self, equity, position_value):
|
| 20 |
+
"""
|
| 21 |
+
检查保证金率
|
| 22 |
+
:param equity: 当前账户权益 (USDT)
|
| 23 |
+
:param position_value: 当前持仓名义价值 (USDT, 绝对值之和)
|
| 24 |
+
:return: (is_liquidated, margin_rate)
|
| 25 |
+
is_liquidated: 是否爆仓 (True/False)
|
| 26 |
+
margin_rate: 当前保证金率
|
| 27 |
+
"""
|
| 28 |
+
if position_value < 1e-8:
|
| 29 |
+
# 无持仓,无限安全
|
| 30 |
+
return False, 999.0
|
| 31 |
+
|
| 32 |
+
margin_rate = equity / float(position_value)
|
| 33 |
+
|
| 34 |
+
is_liquidated = margin_rate < self.min_margin_rate
|
| 35 |
+
|
| 36 |
+
return is_liquidated, margin_rate
|
| 37 |
+
|
| 38 |
+
@staticmethod
|
| 39 |
+
def calculate_margin_rate(equity, position_value):
|
| 40 |
+
"""
|
| 41 |
+
静态方法:纯计算保证金率
|
| 42 |
+
"""
|
| 43 |
+
if position_value < 1e-8:
|
| 44 |
+
return 999.0
|
| 45 |
+
return equity / float(position_value)
|
基础库/common_core/risk_ctrl/test_liquidation.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import unittest
|
| 3 |
+
from common_core.risk_ctrl.liquidation import LiquidationChecker
|
| 4 |
+
|
| 5 |
+
class TestLiquidationChecker(unittest.TestCase):
|
| 6 |
+
def setUp(self):
|
| 7 |
+
self.checker = LiquidationChecker(min_margin_rate=0.005)
|
| 8 |
+
|
| 9 |
+
def test_safe_state(self):
|
| 10 |
+
# 权益 10000, 持仓 10000 -> 保证金率 100% -> 安全
|
| 11 |
+
is_liq, rate = self.checker.check_margin_rate(10000, 10000)
|
| 12 |
+
self.assertFalse(is_liq)
|
| 13 |
+
self.assertEqual(rate, 1.0)
|
| 14 |
+
|
| 15 |
+
def test_warning_state(self):
|
| 16 |
+
# 权益 100, 持仓 10000 -> 保证金率 1% -> 安全但危险
|
| 17 |
+
is_liq, rate = self.checker.check_margin_rate(100, 10000)
|
| 18 |
+
self.assertFalse(is_liq)
|
| 19 |
+
self.assertEqual(rate, 0.01)
|
| 20 |
+
|
| 21 |
+
def test_liquidation_state(self):
|
| 22 |
+
# 权益 40, 持仓 10000 -> 保证金率 0.4% < 0.5% -> 爆仓
|
| 23 |
+
is_liq, rate = self.checker.check_margin_rate(40, 10000)
|
| 24 |
+
self.assertTrue(is_liq)
|
| 25 |
+
self.assertEqual(rate, 0.004)
|
| 26 |
+
|
| 27 |
+
def test_zero_position(self):
|
| 28 |
+
# 无持仓
|
| 29 |
+
is_liq, rate = self.checker.check_margin_rate(10000, 0)
|
| 30 |
+
self.assertFalse(is_liq)
|
| 31 |
+
self.assertEqual(rate, 999.0)
|
| 32 |
+
|
| 33 |
+
if __name__ == '__main__':
|
| 34 |
+
unittest.main()
|
基础库/common_core/tests/test_orderbook_replay.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
import unittest
|
| 5 |
+
from Quant_Unified.基础库.common_core.utils.orderbook_replay import OrderBook
|
| 6 |
+
|
| 7 |
+
class TestOrderBookReplay(unittest.TestCase):
|
| 8 |
+
def setUp(self):
|
| 9 |
+
self.ob = OrderBook("BTCUSDT")
|
| 10 |
+
|
| 11 |
+
def test_apply_delta_basic(self):
|
| 12 |
+
# 增加买单
|
| 13 |
+
self.ob.apply_delta("buy", 6000000, 1000)
|
| 14 |
+
self.ob.apply_delta("buy", 6000100, 2000)
|
| 15 |
+
|
| 16 |
+
# 增加卖单
|
| 17 |
+
self.ob.apply_delta("sell", 6000200, 1500)
|
| 18 |
+
self.ob.apply_delta("sell", 6000300, 2500)
|
| 19 |
+
|
| 20 |
+
snap = self.ob.get_snapshot(depth=5)
|
| 21 |
+
|
| 22 |
+
# 验证排序: Bids 应该降序
|
| 23 |
+
self.assertEqual(snap['bids'][0][0], 6000100)
|
| 24 |
+
self.assertEqual(snap['bids'][1][0], 6000000)
|
| 25 |
+
|
| 26 |
+
# 验证排序: Asks 应该升序
|
| 27 |
+
self.assertEqual(snap['asks'][0][0], 6000200)
|
| 28 |
+
self.assertEqual(snap['asks'][1][0], 6000300)
|
| 29 |
+
|
| 30 |
+
def test_update_and_delete(self):
|
| 31 |
+
self.ob.apply_delta("buy", 6000000, 1000)
|
| 32 |
+
# 更新
|
| 33 |
+
self.ob.apply_delta("buy", 6000000, 5000)
|
| 34 |
+
self.assertEqual(self.ob.bids[6000000], 5000)
|
| 35 |
+
|
| 36 |
+
# 删除
|
| 37 |
+
self.ob.apply_delta("buy", 6000000, 0)
|
| 38 |
+
self.assertNotIn(6000000, self.ob.bids)
|
| 39 |
+
|
| 40 |
+
def test_flat_snapshot(self):
|
| 41 |
+
self.ob.apply_delta("bid", 100, 10)
|
| 42 |
+
self.ob.apply_delta("ask", 110, 20)
|
| 43 |
+
|
| 44 |
+
flat = self.ob.get_flat_snapshot(depth=2)
|
| 45 |
+
self.assertEqual(flat['bid1_p'], 100)
|
| 46 |
+
self.assertEqual(flat['bid1_q'], 10)
|
| 47 |
+
self.assertEqual(flat['ask1_p'], 110)
|
| 48 |
+
self.assertEqual(flat['ask1_q'], 20)
|
| 49 |
+
# 深度不够的应该补 0
|
| 50 |
+
self.assertEqual(flat['bid2_p'], 0)
|
| 51 |
+
|
| 52 |
+
if __name__ == '__main__':
|
| 53 |
+
unittest.main()
|
基础库/common_core/utils/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
"""
|
基础库/common_core/utils/async_commons.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
[异步工具函数库]
|
| 4 |
+
功能:提供 async/await 语境下的辅助工具,重点包含异步重试装饰器,确保高并发任务的健壮性。
|
| 5 |
+
"""
|
| 6 |
+
import asyncio
|
| 7 |
+
import traceback
|
| 8 |
+
from functools import wraps
|
| 9 |
+
|
| 10 |
+
async def async_retry_wrapper(func, params={}, func_name='', if_exit=True):
|
| 11 |
+
"""
|
| 12 |
+
Async retry wrapper
|
| 13 |
+
"""
|
| 14 |
+
max_retries = 3
|
| 15 |
+
for i in range(max_retries):
|
| 16 |
+
try:
|
| 17 |
+
if params:
|
| 18 |
+
return await func(params)
|
| 19 |
+
else:
|
| 20 |
+
return await func()
|
| 21 |
+
except Exception as e:
|
| 22 |
+
print(f'❌{func_name} 出错: {e}')
|
| 23 |
+
if i < max_retries - 1:
|
| 24 |
+
print(f'⏳正在重试 ({i+1}/{max_retries})...')
|
| 25 |
+
await asyncio.sleep(1)
|
| 26 |
+
else:
|
| 27 |
+
print(traceback.format_exc())
|
| 28 |
+
if if_exit:
|
| 29 |
+
raise e
|
| 30 |
+
return None
|
基础库/common_core/utils/commons.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
[通用工具函数库]
|
| 4 |
+
功能:包含重试装饰器、时间计算、精度处理等常用辅助功能,是系统稳定运行的基础组件。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
import traceback
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
|
| 11 |
+
import pandas as pd
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# ===重试机制
|
| 15 |
+
def retry_wrapper(func, params=None, func_name='', retry_times=5, sleep_seconds=5, if_exit=True):
|
| 16 |
+
"""
|
| 17 |
+
需要在出错时不断重试的函数,例如和交易所交互,可以使用本函数调用。
|
| 18 |
+
:param func: 需要重试的函数名
|
| 19 |
+
:param params: 参数
|
| 20 |
+
:param func_name: 方法名称
|
| 21 |
+
:param retry_times: 重试次数
|
| 22 |
+
:param sleep_seconds: 报错后的sleep时间
|
| 23 |
+
:param if_exit: 报错是否退出程序
|
| 24 |
+
:return:
|
| 25 |
+
"""
|
| 26 |
+
if params is None:
|
| 27 |
+
params = {}
|
| 28 |
+
for _ in range(retry_times):
|
| 29 |
+
try:
|
| 30 |
+
if 'timestamp' in params:
|
| 31 |
+
from core.binance.base_client import BinanceClient
|
| 32 |
+
params['timestamp'] = int(time.time() * 1000) - BinanceClient.diff_timestamp
|
| 33 |
+
result = func(params=params)
|
| 34 |
+
return result
|
| 35 |
+
except Exception as e:
|
| 36 |
+
print(f'❌{func_name} 报错,程序暂停{sleep_seconds}(秒)')
|
| 37 |
+
print(e)
|
| 38 |
+
print(params)
|
| 39 |
+
msg = str(e).strip()
|
| 40 |
+
# 出现1021错误码之后,刷新与交易所的时差
|
| 41 |
+
if 'binance Account has insufficient balance for requested action' in msg:
|
| 42 |
+
print(f'⚠️{func_name} 现货下单资金不足')
|
| 43 |
+
raise ValueError(func_name, '现货下单资金不足')
|
| 44 |
+
elif '-2022' in msg:
|
| 45 |
+
print(f'⚠️{func_name} ReduceOnly订单被拒绝, 合约仓位已经平完')
|
| 46 |
+
raise ValueError(func_name, 'ReduceOnly订单被拒绝, 合约仓位已经平完')
|
| 47 |
+
elif '-4118' in msg:
|
| 48 |
+
print(f'⚠️{func_name} 统一账户 ReduceOnly订单被拒绝, 合约仓位已经平完')
|
| 49 |
+
raise ValueError(func_name, '统一账户 ReduceOnly订单被拒绝, 合约仓位已经平完')
|
| 50 |
+
elif '-2019' in msg:
|
| 51 |
+
print(f'⚠️{func_name} 合约下单资金不足')
|
| 52 |
+
raise ValueError(func_name, '合约下单资金不足')
|
| 53 |
+
elif '-2015' in msg and 'Invalid API-key' in msg:
|
| 54 |
+
# {"code":-2015,"msg":"Invalid API-key, IP, or permissions for action, request ip: xxx.xxx.xxx.xxx"}
|
| 55 |
+
print(f'❌{func_name} API配置错误,可能写错或未配置权限')
|
| 56 |
+
break
|
| 57 |
+
elif '-1121' in msg and 'Invalid symbol' in msg:
|
| 58 |
+
# {"code":-2015,"msg":"Invalid API-key, IP, or permissions for action, request ip: xxx.xxx.xxx.xxx"}
|
| 59 |
+
print(f'❌{func_name} 没有交易对')
|
| 60 |
+
break
|
| 61 |
+
elif '-5013' in msg and 'Asset transfer failed' in msg:
|
| 62 |
+
print(f'❌{func_name} 余额不足,无法资金划转')
|
| 63 |
+
break
|
| 64 |
+
else:
|
| 65 |
+
print(f'❌{e},报错内容如下')
|
| 66 |
+
print(traceback.format_exc())
|
| 67 |
+
time.sleep(sleep_seconds)
|
| 68 |
+
else:
|
| 69 |
+
if if_exit:
|
| 70 |
+
raise ValueError(func_name, '报错重试次数超过上限,程序退出。')
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ===下次运行时间
|
| 74 |
+
def next_run_time(time_interval, ahead_seconds=5):
|
| 75 |
+
"""
|
| 76 |
+
根据time_interval,计算下次运行的时间。
|
| 77 |
+
PS:目前只支持分钟和小时。
|
| 78 |
+
:param time_interval: 运行的周期,15m,1h
|
| 79 |
+
:param ahead_seconds: 预留的目标时间和当前时间之间计算的间隙
|
| 80 |
+
:return: 下次运行的时间
|
| 81 |
+
|
| 82 |
+
案例:
|
| 83 |
+
15m 当前时间为:12:50:51 返回时间为:13:00:00
|
| 84 |
+
15m 当前时间为:12:39:51 返回时间为:12:45:00
|
| 85 |
+
|
| 86 |
+
10m 当前时间为:12:38:51 返回时间为:12:40:00
|
| 87 |
+
10m 当前时间为:12:11:01 返回时间为:12:20:00
|
| 88 |
+
|
| 89 |
+
5m 当前时间为:12:33:51 返回时间为:12:35:00
|
| 90 |
+
5m 当前时间为:12:34:51 返回时间为:12:40:00
|
| 91 |
+
|
| 92 |
+
30m 当前时间为:21日的23:33:51 返回时间为:22日的00:00:00
|
| 93 |
+
30m 当前时间为:14:37:51 返回时间为:14:56:00
|
| 94 |
+
|
| 95 |
+
1h 当前时间为:14:37:51 返回时间为:15:00:00
|
| 96 |
+
"""
|
| 97 |
+
# 检测 time_interval 是否配置正确,并将 时间单位 转换成 可以解析的时间单位
|
| 98 |
+
if time_interval.endswith('m') or time_interval.endswith('h'):
|
| 99 |
+
pass
|
| 100 |
+
elif time_interval.endswith('T'): # 分钟兼容使用T配置,例如 15T 30T
|
| 101 |
+
time_interval = time_interval.replace('T', 'm')
|
| 102 |
+
elif time_interval.endswith('H'): # 小时兼容使用H配置, 例如 1H 2H
|
| 103 |
+
time_interval = time_interval.replace('H', 'h')
|
| 104 |
+
else:
|
| 105 |
+
print('⚠️time_interval格式不符合规范。程序exit')
|
| 106 |
+
exit()
|
| 107 |
+
|
| 108 |
+
# 将 time_interval 转换成 时间类型
|
| 109 |
+
ti = pd.to_timedelta(time_interval)
|
| 110 |
+
# 获取当前时间
|
| 111 |
+
now_time = datetime.now()
|
| 112 |
+
# 计算��日时间的 00:00:00
|
| 113 |
+
this_midnight = now_time.replace(hour=0, minute=0, second=0, microsecond=0)
|
| 114 |
+
# 每次计算时间最小时间单位1分钟
|
| 115 |
+
min_step = timedelta(minutes=1)
|
| 116 |
+
# 目标时间:设置成默认时间,并将 秒,毫秒 置零
|
| 117 |
+
target_time = now_time.replace(second=0, microsecond=0)
|
| 118 |
+
|
| 119 |
+
while True:
|
| 120 |
+
# 增加一个最小时间单位
|
| 121 |
+
target_time = target_time + min_step
|
| 122 |
+
# 获取目标时间已经从当日 00:00:00 走了多少时间
|
| 123 |
+
delta = target_time - this_midnight
|
| 124 |
+
# delta 时间可以整除 time_interval,表明时间是 time_interval 的倍数,是一个 整时整分的时间
|
| 125 |
+
# 目标时间 与 当前时间的 间隙超过 ahead_seconds,说明 目标时间 比当前时间大,是最靠近的一个周期时间
|
| 126 |
+
if int(delta.total_seconds()) % int(ti.total_seconds()) == 0 and int(
|
| 127 |
+
(target_time - now_time).total_seconds()) >= ahead_seconds:
|
| 128 |
+
break
|
| 129 |
+
|
| 130 |
+
return target_time
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ===依据时间间隔, 自动计算并休眠到指定时间
|
| 134 |
+
def sleep_until_run_time(time_interval, ahead_time=1, if_sleep=True, cheat_seconds=0):
|
| 135 |
+
"""
|
| 136 |
+
根据next_run_time()函数计算出下次程序运行的时候,然后sleep至该时间
|
| 137 |
+
:param time_interval: 时间周期配置,用于计算下个周期的时间
|
| 138 |
+
:param if_sleep: 是否进行sleep
|
| 139 |
+
:param ahead_time: 最小时间误差
|
| 140 |
+
:param cheat_seconds: 相对于下个周期时间,提前或延后多长时间, 100: 提前100秒; -50:延后50秒
|
| 141 |
+
:return:
|
| 142 |
+
"""
|
| 143 |
+
# 计算下次运行时间
|
| 144 |
+
run_time = next_run_time(time_interval, ahead_time)
|
| 145 |
+
# 计算延迟之后的目标时间
|
| 146 |
+
target_time = run_time
|
| 147 |
+
# 配置 cheat_seconds ,对目标时间进行 提前 或者 延后
|
| 148 |
+
if cheat_seconds != 0:
|
| 149 |
+
target_time = run_time - timedelta(seconds=cheat_seconds)
|
| 150 |
+
print(f'⏳程序等待下次运行,下次时间:{target_time}')
|
| 151 |
+
|
| 152 |
+
# sleep
|
| 153 |
+
if if_sleep:
|
| 154 |
+
# 计算获得的 run_time 小于 now, sleep就会一直sleep
|
| 155 |
+
_now = datetime.now()
|
| 156 |
+
if target_time > _now: # 计算的下个周期时间超过当前时间,直接追加一个时间周期
|
| 157 |
+
time.sleep(max(0, (target_time - _now).seconds))
|
| 158 |
+
while True: # 在靠近目标时间时
|
| 159 |
+
if datetime.now() > target_time:
|
| 160 |
+
time.sleep(1)
|
| 161 |
+
break
|
| 162 |
+
|
| 163 |
+
return run_time
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ===根据精度对数字进行就低不就高处理
|
| 167 |
+
def apply_precision(number: int | float, decimals: int) -> float:
|
| 168 |
+
"""
|
| 169 |
+
根据精度对数字进行就低不就高处理
|
| 170 |
+
:param number: 数字
|
| 171 |
+
:param decimals: 精度
|
| 172 |
+
:return:
|
| 173 |
+
(360.731, 0)结果是360,
|
| 174 |
+
(123.65, 1)结果是123.6
|
| 175 |
+
"""
|
| 176 |
+
multiplier = 10 ** decimals
|
| 177 |
+
return int(number * multiplier) / multiplier
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def bool_str(true_or_false):
|
| 181 |
+
return '🔵[OK]' if true_or_false else '🟡[NO]'
|
基础库/common_core/utils/dingding.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
[消息推送工具]
|
| 4 |
+
功能:集成钉钉/企业微信 Webhook,用于发送实盘交易信号、报错报警和状态监控,让您随时掌握系统动态。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import base64
|
| 8 |
+
import hashlib
|
| 9 |
+
import os.path
|
| 10 |
+
import requests
|
| 11 |
+
import json
|
| 12 |
+
import traceback
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from config import proxy
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# 企业微信通知
|
| 18 |
+
def send_wechat_work_msg(content, url):
|
| 19 |
+
if not url:
|
| 20 |
+
print('未配置wechat_webhook_url,不发送信息')
|
| 21 |
+
return
|
| 22 |
+
try:
|
| 23 |
+
data = {
|
| 24 |
+
"msgtype": "text",
|
| 25 |
+
"text": {
|
| 26 |
+
"content": content + '\n' + datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
r = requests.post(url, data=json.dumps(data), timeout=10, proxies=proxy)
|
| 30 |
+
print(f'调用企业微信接口返回: {r.text}')
|
| 31 |
+
print('成功发送企业微信')
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"发送企业微信失败:{e}")
|
| 34 |
+
print(traceback.format_exc())
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# 上传图片,解析bytes
|
| 38 |
+
class MyEncoder(json.JSONEncoder):
|
| 39 |
+
|
| 40 |
+
def default(self, obj):
|
| 41 |
+
"""
|
| 42 |
+
只要检查到了是bytes类型的数据就把它转为str类型
|
| 43 |
+
:param obj:
|
| 44 |
+
:return:
|
| 45 |
+
"""
|
| 46 |
+
if isinstance(obj, bytes):
|
| 47 |
+
return str(obj, encoding='utf-8')
|
| 48 |
+
return json.JSONEncoder.default(self, obj)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# 企业微信发送图片
|
| 52 |
+
def send_wechat_work_img(file_path, url):
|
| 53 |
+
if not os.path.exists(file_path):
|
| 54 |
+
print('找不到图片')
|
| 55 |
+
return
|
| 56 |
+
if not url:
|
| 57 |
+
print('未配置wechat_webhook_url,不发送信息')
|
| 58 |
+
return
|
| 59 |
+
try:
|
| 60 |
+
with open(file_path, 'rb') as f:
|
| 61 |
+
image_content = f.read()
|
| 62 |
+
image_base64 = base64.b64encode(image_content).decode('utf-8')
|
| 63 |
+
md5 = hashlib.md5()
|
| 64 |
+
md5.update(image_content)
|
| 65 |
+
image_md5 = md5.hexdigest()
|
| 66 |
+
data = {
|
| 67 |
+
'msgtype': 'image',
|
| 68 |
+
'image': {
|
| 69 |
+
'base64': image_base64,
|
| 70 |
+
'md5': image_md5
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
# 服务器上传bytes图片的时候,json.dumps解析会出错,需要自己手动去转一下
|
| 74 |
+
r = requests.post(url, data=json.dumps(data, cls=MyEncoder, indent=4), timeout=10, proxies=proxy)
|
| 75 |
+
print(f'调用企业微信接口返回: {r.text}')
|
| 76 |
+
print('成功发送企业微信')
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"发送企业微信失败:{e}")
|
| 79 |
+
print(traceback.format_exc())
|
| 80 |
+
finally:
|
| 81 |
+
if os.path.exists(file_path):
|
| 82 |
+
os.remove(file_path)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def send_msg_for_order(order_param, order_res, url):
|
| 86 |
+
"""
|
| 87 |
+
发送下单信息,只有出问题才会推送,正常下单不在推送信息
|
| 88 |
+
"""
|
| 89 |
+
if not url:
|
| 90 |
+
print('未配置wechat_webhook_url,不发送信息')
|
| 91 |
+
return
|
| 92 |
+
msg = ''
|
| 93 |
+
try:
|
| 94 |
+
for _ in range(len(order_param)):
|
| 95 |
+
if 'msg' in order_res[_].keys():
|
| 96 |
+
msg += f'币种:{order_param[_]["symbol"]}\n'
|
| 97 |
+
msg += f'方向:{"做多" if order_param[_]["side"] == "BUY" else "做空"}\n'
|
| 98 |
+
msg += f'价格:{order_param[_]["price"]}\n'
|
| 99 |
+
msg += f'数量:{order_param[_]["quantity"]}\n'
|
| 100 |
+
msg += f'下单结果:{order_res[_]["msg"]}'
|
| 101 |
+
msg += '\n' * 2
|
| 102 |
+
except BaseException as e:
|
| 103 |
+
print('send_msg_for_order ERROR', e)
|
| 104 |
+
print(traceback.format_exc())
|
| 105 |
+
|
| 106 |
+
if msg:
|
| 107 |
+
send_wechat_work_msg(msg, url)
|
基础库/common_core/utils/factor_hub.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
选币框架
|
| 3 |
+
"""
|
| 4 |
+
import importlib
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class DummyFactor:
|
| 10 |
+
"""
|
| 11 |
+
!!!!抽象因子对象,仅用于代码提示!!!!
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
def signal(self, *args) -> pd.DataFrame:
|
| 15 |
+
raise NotImplementedError
|
| 16 |
+
|
| 17 |
+
def signal_multi_params(self, df, param_list: list | set | tuple) -> dict:
|
| 18 |
+
raise NotImplementedError
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class FactorHub:
|
| 22 |
+
_factor_cache = {}
|
| 23 |
+
|
| 24 |
+
# noinspection PyTypeChecker
|
| 25 |
+
@staticmethod
|
| 26 |
+
def get_by_name(factor_name) -> DummyFactor:
|
| 27 |
+
if factor_name in FactorHub._factor_cache:
|
| 28 |
+
return FactorHub._factor_cache[factor_name]
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
# 构造模块名
|
| 32 |
+
module_name = f"factors.{factor_name}"
|
| 33 |
+
|
| 34 |
+
# 动态导入模块
|
| 35 |
+
factor_module = importlib.import_module(module_name)
|
| 36 |
+
|
| 37 |
+
# 创建一个包含模块变量和函数的字典
|
| 38 |
+
factor_content = {
|
| 39 |
+
name: getattr(factor_module, name) for name in dir(factor_module)
|
| 40 |
+
if not name.startswith("__")
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# 创建一个包含这些变量和函数的对象
|
| 44 |
+
factor_instance = type(factor_name, (), factor_content)
|
| 45 |
+
|
| 46 |
+
# 缓存策略对象
|
| 47 |
+
FactorHub._factor_cache[factor_name] = factor_instance
|
| 48 |
+
|
| 49 |
+
return factor_instance
|
| 50 |
+
except ModuleNotFoundError:
|
| 51 |
+
raise ValueError(f"Factor {factor_name} not found.")
|
| 52 |
+
except AttributeError:
|
| 53 |
+
raise ValueError(f"Error accessing factor content in module {factor_name}.")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# 使用示例
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
factor = FactorHub.get_by_name("PctChange")
|
基础库/common_core/utils/functions.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
选币框架
|
| 3 |
+
"""
|
| 4 |
+
import warnings
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Dict
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
from config import stable_symbol
|
| 12 |
+
|
| 13 |
+
warnings.filterwarnings('ignore')
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# =====策略相关函数
|
| 17 |
+
def del_insufficient_data(symbol_candle_data) -> Dict[str, pd.DataFrame]:
|
| 18 |
+
"""
|
| 19 |
+
删除数据长度不足的币种信息
|
| 20 |
+
|
| 21 |
+
:param symbol_candle_data:
|
| 22 |
+
:return
|
| 23 |
+
"""
|
| 24 |
+
# ===删除成交量为0的线数据、k线数不足的币种
|
| 25 |
+
symbol_list = list(symbol_candle_data.keys())
|
| 26 |
+
for symbol in symbol_list:
|
| 27 |
+
# 删除空的数据
|
| 28 |
+
if symbol_candle_data[symbol] is None or symbol_candle_data[symbol].empty:
|
| 29 |
+
del symbol_candle_data[symbol]
|
| 30 |
+
continue
|
| 31 |
+
# 删除该币种成交量=0的k线
|
| 32 |
+
# symbol_candle_data[symbol] = symbol_candle_data[symbol][symbol_candle_data[symbol]['volume'] > 0]
|
| 33 |
+
|
| 34 |
+
return symbol_candle_data
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def ignore_error(anything):
|
| 38 |
+
return anything
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def load_min_qty(file_path: Path) -> (int, Dict[str, int]):
|
| 42 |
+
# 读取min_qty文件并转为dict格式
|
| 43 |
+
min_qty_df = pd.read_csv(file_path, encoding='utf-8-sig')
|
| 44 |
+
min_qty_df['最小下单量'] = -np.log10(min_qty_df['最小下单量']).round().astype(int)
|
| 45 |
+
default_min_qty = min_qty_df['最小下单量'].max()
|
| 46 |
+
min_qty_df.set_index('币种', inplace=True)
|
| 47 |
+
min_qty_dict = min_qty_df['最小下单量'].to_dict()
|
| 48 |
+
|
| 49 |
+
return default_min_qty, min_qty_dict
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def is_trade_symbol(symbol, black_list=()) -> bool:
|
| 53 |
+
"""
|
| 54 |
+
过滤掉不能用于交易的币种,比如稳定币、非USDT交易对,以及一些杠杆币
|
| 55 |
+
:param symbol: 交易对
|
| 56 |
+
:param black_list: 黑名单
|
| 57 |
+
:return: 是否可以进入交易,True可以参与选币,False不参与
|
| 58 |
+
"""
|
| 59 |
+
# 如果symbol为空
|
| 60 |
+
# 或者是.开头的隐藏文件
|
| 61 |
+
# 或者不是USDT结尾的币种
|
| 62 |
+
# 或者在黑名单里
|
| 63 |
+
if not symbol or symbol.startswith('.') or not symbol.endswith('USDT') or symbol in black_list:
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
# 筛选杠杆币
|
| 67 |
+
base_symbol = symbol.upper().replace('-USDT', 'USDT')[:-4]
|
| 68 |
+
if base_symbol.endswith(('UP', 'DOWN', 'BEAR', 'BULL')) and base_symbol != 'JUP' or base_symbol in stable_symbol:
|
| 69 |
+
return False
|
| 70 |
+
else:
|
| 71 |
+
return True
|
基础库/common_core/utils/orderbook_replay.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
|
| 4 |
+
"""
|
| 5 |
+
L2 订单簿重放引擎
|
| 6 |
+
功能:根据增量更新(Delta)维护内存中的盘口状态,并支持快照提取。
|
| 7 |
+
支持价格和数量的整数存储以提高性能。
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from sortedcontainers import SortedDict
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
class OrderBook:
|
| 16 |
+
def __init__(self, symbol: str):
|
| 17 |
+
self.symbol = symbol
|
| 18 |
+
# bids 降序排列 (最高价在最前)
|
| 19 |
+
self.bids = SortedDict(lambda x: -x)
|
| 20 |
+
# asks 升序排列 (最低价在最前)
|
| 21 |
+
self.asks = SortedDict()
|
| 22 |
+
self.last_update_id = 0
|
| 23 |
+
self.timestamp = None
|
| 24 |
+
|
| 25 |
+
def apply_delta(self, side: str, price_int: int, amount_int: int):
|
| 26 |
+
"""
|
| 27 |
+
应用单条增量更新
|
| 28 |
+
:param side: 'buy' 或 'sell' (Tardis 格式) 或 'bid'/'ask'
|
| 29 |
+
:param price_int: 整数价格
|
| 30 |
+
:param amount_int: 整数数量 (为 0 表示删除该档位)
|
| 31 |
+
"""
|
| 32 |
+
target_dict = self.bids if side in ['buy', 'bid'] else self.asks
|
| 33 |
+
|
| 34 |
+
if amount_int <= 0:
|
| 35 |
+
target_dict.pop(price_int, None)
|
| 36 |
+
else:
|
| 37 |
+
target_dict[price_int] = amount_int
|
| 38 |
+
|
| 39 |
+
def reset(self):
|
| 40 |
+
"""重置盘口"""
|
| 41 |
+
self.bids.clear()
|
| 42 |
+
self.asks.clear()
|
| 43 |
+
|
| 44 |
+
def get_snapshot(self, depth: int = 50) -> dict:
|
| 45 |
+
"""
|
| 46 |
+
获取当前盘口的快照
|
| 47 |
+
:param depth: 档位深度
|
| 48 |
+
:return: 包含 bids 和 asks 列表的字典
|
| 49 |
+
"""
|
| 50 |
+
bid_keys = list(self.bids.keys())[:depth]
|
| 51 |
+
bid_list = [(p, self.bids[p]) for p in bid_keys]
|
| 52 |
+
|
| 53 |
+
ask_keys = list(self.asks.keys())[:depth]
|
| 54 |
+
ask_list = [(p, self.asks[p]) for p in ask_keys]
|
| 55 |
+
|
| 56 |
+
return {
|
| 57 |
+
"symbol": self.symbol,
|
| 58 |
+
"bids": bid_list,
|
| 59 |
+
"asks": ask_list
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
def get_flat_snapshot(self, depth: int = 50) -> dict:
|
| 63 |
+
"""
|
| 64 |
+
获取打平的快照格式,方便直接喂给模型 (例如 bid1_p, bid1_q ...)
|
| 65 |
+
"""
|
| 66 |
+
result = {}
|
| 67 |
+
|
| 68 |
+
bid_keys = list(self.bids.keys())
|
| 69 |
+
# 处理 Bids
|
| 70 |
+
for i in range(depth):
|
| 71 |
+
if i < len(bid_keys):
|
| 72 |
+
price = bid_keys[i]
|
| 73 |
+
amount = self.bids[price]
|
| 74 |
+
result[f"bid{i+1}_p"] = price
|
| 75 |
+
result[f"bid{i+1}_q"] = amount
|
| 76 |
+
else:
|
| 77 |
+
result[f"bid{i+1}_p"] = 0
|
| 78 |
+
result[f"bid{i+1}_q"] = 0
|
| 79 |
+
|
| 80 |
+
ask_keys = list(self.asks.keys())
|
| 81 |
+
# 处理 Asks
|
| 82 |
+
for i in range(depth):
|
| 83 |
+
if i < len(ask_keys):
|
| 84 |
+
price = ask_keys[i]
|
| 85 |
+
amount = self.asks[price]
|
| 86 |
+
result[f"ask{i+1}_p"] = price
|
| 87 |
+
result[f"ask{i+1}_q"] = amount
|
| 88 |
+
else:
|
| 89 |
+
result[f"ask{i+1}_p"] = 0
|
| 90 |
+
result[f"ask{i+1}_q"] = 0
|
| 91 |
+
|
| 92 |
+
return result
|
基础库/common_core/utils/path_kit.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Quant Unified 量化交易系统
|
| 3 |
+
[路径管理工具]
|
| 4 |
+
功能:自动识别操作系统,统一管理数据、配置、日志等文件夹路径,解决硬编码路径带来的跨平台问题。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
# 通过当前文件的位置,获取项目根目录
|
| 11 |
+
PROJECT_ROOT = os.path.abspath(os.path.join(__file__, os.path.pardir, os.path.pardir, os.path.pardir, os.path.pardir))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# ====================================================================================================
|
| 15 |
+
# ** 功能函数 **
|
| 16 |
+
# - get_folder_by_root: 获取基于某一个地址的绝对路径
|
| 17 |
+
# - get_folder_path: 获取相对于项目根目录的,文件夹的绝对路径
|
| 18 |
+
# - get_file_path: 获取相对于项目根目录的,文件的绝对路径
|
| 19 |
+
# ====================================================================================================
|
| 20 |
+
def get_folder_by_root(root, *paths, auto_create=True) -> str:
|
| 21 |
+
"""
|
| 22 |
+
获取基于某一个地址的绝对路径
|
| 23 |
+
:param root: 相对的地址,默认为运行脚本同目录
|
| 24 |
+
:param paths: 路径
|
| 25 |
+
:param auto_create: 是否自动创建需要的文件夹们
|
| 26 |
+
:return: 绝对路径
|
| 27 |
+
"""
|
| 28 |
+
_full_path = os.path.join(root, *paths)
|
| 29 |
+
if auto_create and (not os.path.exists(_full_path)): # 判断文件夹是否存在
|
| 30 |
+
try:
|
| 31 |
+
os.makedirs(_full_path) # 不存在则创建
|
| 32 |
+
except FileExistsError:
|
| 33 |
+
pass # 并行过程中,可能造成冲突
|
| 34 |
+
return str(_full_path)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def get_folder_path(*paths, auto_create=True, as_path_type=False) -> str | Path:
|
| 38 |
+
"""
|
| 39 |
+
获取相对于项目根目录的,文件夹的绝对路径
|
| 40 |
+
:param paths: 文件夹路径
|
| 41 |
+
:param auto_create: 是否自动创建
|
| 42 |
+
:param as_path_type: 是否返回Path对象
|
| 43 |
+
:return: 文件夹绝对路径
|
| 44 |
+
"""
|
| 45 |
+
_p = get_folder_by_root(PROJECT_ROOT, *paths, auto_create=auto_create)
|
| 46 |
+
if as_path_type:
|
| 47 |
+
return Path(_p)
|
| 48 |
+
return _p
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def get_file_path(*paths, auto_create=True, as_path_type=False) -> str | Path:
|
| 52 |
+
"""
|
| 53 |
+
获取相对于项目根目录的,文件的绝对路径
|
| 54 |
+
:param paths: 文件路径
|
| 55 |
+
:param auto_create: 是否自动创建
|
| 56 |
+
:param as_path_type: 是否返回Path对象
|
| 57 |
+
:return: 文件绝对路径
|
| 58 |
+
"""
|
| 59 |
+
parent = get_folder_path(*paths[:-1], auto_create=auto_create, as_path_type=True)
|
| 60 |
+
_p = parent / paths[-1]
|
| 61 |
+
if as_path_type:
|
| 62 |
+
return _p
|
| 63 |
+
return str(_p)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
if __name__ == '__main__':
|
| 67 |
+
"""
|
| 68 |
+
DEMO
|
| 69 |
+
"""
|
| 70 |
+
print(get_file_path('data', 'xxx.pkl'))
|
| 71 |
+
print(get_folder_path('系统日志'))
|
| 72 |
+
print(get_folder_by_root('data', 'center', 'yyds', auto_create=False))
|
基础库/通用选币回测框架/因子库/9_SharpeMomentum.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
多头动量因子9 | 夏普动量因子
|
| 4 |
+
author: D Luck
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
def signal(*args):
|
| 11 |
+
df, n, factor_name = args
|
| 12 |
+
|
| 13 |
+
ret = df['close'].pct_change()
|
| 14 |
+
mean_ = ret.rolling(n, min_periods=1).mean()
|
| 15 |
+
std_ = ret.rolling(n, min_periods=1).std()
|
| 16 |
+
|
| 17 |
+
df[factor_name] = mean_ / (std_ + 1e-12)
|
| 18 |
+
return df
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def signal_multi_params(df, param_list) -> dict:
|
| 22 |
+
ret_dic = {}
|
| 23 |
+
ret = df['close'].pct_change()
|
| 24 |
+
|
| 25 |
+
for param in param_list:
|
| 26 |
+
n = int(param)
|
| 27 |
+
|
| 28 |
+
mean_ = ret.rolling(n).mean()
|
| 29 |
+
std_ = ret.rolling(n).std()
|
| 30 |
+
|
| 31 |
+
ret_dic[str(param)] = mean_ / (std_ + 1e-12)
|
| 32 |
+
|
| 33 |
+
return ret_dic
|
基础库/通用选币回测框架/因子库/ADX.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def signal(*args):
|
| 6 |
+
df = args[0].copy() # 避免修改原数据
|
| 7 |
+
N = args[1]
|
| 8 |
+
factor_name = args[2]
|
| 9 |
+
|
| 10 |
+
# 1. 计算 True Range (TR)
|
| 11 |
+
prev_close = df['close'].shift(1)
|
| 12 |
+
tr1 = df['high'] - df['low']
|
| 13 |
+
tr2 = (df['high'] - prev_close).abs()
|
| 14 |
+
tr3 = (df['low'] - prev_close).abs()
|
| 15 |
+
df['tr'] = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
| 16 |
+
|
| 17 |
+
# 2. 计算 +DM 和 -DM
|
| 18 |
+
up_move = df['high'].diff()
|
| 19 |
+
down_move = (-df['low'].diff())
|
| 20 |
+
|
| 21 |
+
df['plus_dm'] = up_move.where((up_move > down_move) & (up_move > 0), 0)
|
| 22 |
+
df['minus_dm'] = down_move.where((down_move > up_move) & (down_move > 0), 0)
|
| 23 |
+
|
| 24 |
+
# 3. Wilders 平滑函数(关键!)
|
| 25 |
+
def wilders_smooth(series, n):
|
| 26 |
+
return series.ewm(alpha=1 / n, adjust=False).mean()
|
| 27 |
+
|
| 28 |
+
df['tr_smooth'] = wilders_smooth(df['tr'], N)
|
| 29 |
+
df['plus_dm_smooth'] = wilders_smooth(df['plus_dm'], N)
|
| 30 |
+
df['minus_dm_smooth'] = wilders_smooth(df['minus_dm'], N)
|
| 31 |
+
|
| 32 |
+
# 4. 计算 +DI 和 -DI
|
| 33 |
+
df['plus_di'] = (df['plus_dm_smooth'] / df['tr_smooth']) * 100
|
| 34 |
+
df['minus_di'] = (df['minus_dm_smooth'] / df['tr_smooth']) * 100
|
| 35 |
+
|
| 36 |
+
# 5. 计算 DX
|
| 37 |
+
di_sum = df['plus_di'] + df['minus_di']
|
| 38 |
+
di_diff = (df['plus_di'] - df['minus_di']).abs()
|
| 39 |
+
|
| 40 |
+
# 防止除零
|
| 41 |
+
df['dx'] = np.where(di_sum > 0, (di_diff / di_sum) * 100, 0)
|
| 42 |
+
|
| 43 |
+
# 6. ADX = DX 的 Wilders 平滑
|
| 44 |
+
df['adx'] = wilders_smooth(df['dx'], N)
|
| 45 |
+
|
| 46 |
+
# 7. 输出到指定列名
|
| 47 |
+
df[factor_name] = df['adx']
|
| 48 |
+
|
| 49 |
+
return df
|
基础库/通用选币回测框架/因子库/AO.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AO 因子(Awesome Oscillator,动量震荡)— 量纲归一版本
|
| 3 |
+
|
| 4 |
+
定义
|
| 5 |
+
- 使用中位价 `mid=(high+low)/2` 的短/长均线差作为动量源
|
| 6 |
+
- 先对均线差做比例归一:`(SMA_s - SMA_l) / SMA_l`
|
| 7 |
+
- 再对结果以 `ATR(l)` 做风险归一,得到跨币种可比的无量纲指标
|
| 8 |
+
|
| 9 |
+
用途
|
| 10 |
+
- 作为前置过滤(方向确认):AO > 0 表示短期动量强于长期动量
|
| 11 |
+
- 也可作为选币因子,但推荐保留主因子(如 VWapBias)主导排名
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pandas as pd
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _parse_param(param):
|
| 19 |
+
"""解析参数,支持 (s,l)、"s,l" 两种写法;默认 (5,34)"""
|
| 20 |
+
if isinstance(param, (tuple, list)) and len(param) >= 2:
|
| 21 |
+
return int(param[0]), int(param[1])
|
| 22 |
+
if isinstance(param, str) and "," in param:
|
| 23 |
+
a, b = param.split(",")
|
| 24 |
+
return int(a), int(b)
|
| 25 |
+
return 5, 34
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _atr(df, n):
|
| 29 |
+
"""计算 ATR(n):真实波动范围的均值,用于风险归一"""
|
| 30 |
+
prev_close = df["close"].shift(1).fillna(df["open"]) # 前收盘价(首根用开盘补齐)
|
| 31 |
+
# 真实波动 TR = max(高-低, |高-前收|, |低-前收|)
|
| 32 |
+
tr = np.maximum(
|
| 33 |
+
df["high"] - df["low"],
|
| 34 |
+
np.maximum(np.abs(df["high"] - prev_close), np.abs(df["low"] - prev_close)),
|
| 35 |
+
)
|
| 36 |
+
return pd.Series(tr).rolling(n, min_periods=1).mean() # 滚动均值作为 ATR
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def signal(*args):
|
| 40 |
+
"""计算单参数 AO 因子并写入列名 `factor_name`"""
|
| 41 |
+
df = args[0] # K线数据
|
| 42 |
+
param = args[1] # 参数(支持 "s,l")
|
| 43 |
+
factor_name = args[2] # 因子列名
|
| 44 |
+
s, l = _parse_param(param) # 解析短/长窗口
|
| 45 |
+
eps = 1e-12 # 防除零微量
|
| 46 |
+
mid = (df["high"] + df["low"]) / 2.0 # 中位价
|
| 47 |
+
sma_s = mid.rolling(s, min_periods=1).mean() # 短均线
|
| 48 |
+
sma_l = mid.rolling(l, min_periods=1).mean() # 长均线
|
| 49 |
+
atr_l = _atr(df, l) # 长窗 ATR
|
| 50 |
+
# 比例/ATR 归一:跨币种可比、抗量纲
|
| 51 |
+
ao = ((sma_s - sma_l) / (sma_l + eps)) / (atr_l + eps)
|
| 52 |
+
df[factor_name] = ao.replace([np.inf, -np.inf], 0).fillna(0) # 安全处理
|
| 53 |
+
return df
|
基础库/通用选币回测框架/因子库/ATR.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""
|
| 3 |
+
ATR 波动率因子(时间序列)
|
| 4 |
+
基于真实波幅的均值衡量价格波动率
|
| 5 |
+
author: 邢不行框架适配
|
| 6 |
+
"""
|
| 7 |
+
def signal(*args):
|
| 8 |
+
df = args[0]
|
| 9 |
+
n = args[1] # ATR计算窗口
|
| 10 |
+
factor_name = args[2]
|
| 11 |
+
|
| 12 |
+
# 1) 计算 True Range (TR)
|
| 13 |
+
df['pre_close'] = df['close'].shift(1) # 前一周期收盘价
|
| 14 |
+
df['tr1'] = df['high'] - df['low']
|
| 15 |
+
df['tr2'] = (df['high'] - df['pre_close']).abs()
|
| 16 |
+
df['tr3'] = (df['low'] - df['pre_close']).abs()
|
| 17 |
+
df['TR'] = df[['tr1', 'tr2', 'tr3']].max(axis=1) # 三者取最大:contentReference[oaicite:1]{index=1}
|
| 18 |
+
|
| 19 |
+
# 2) 计算 ATR:TR 的 n 期滚动均值作为波动率指标
|
| 20 |
+
df[factor_name] = df['TR'].rolling(window=n, min_periods=1).mean()
|
| 21 |
+
|
| 22 |
+
# 3) 清理临时字段
|
| 23 |
+
del df['pre_close'], df['tr1'], df['tr2'], df['tr3'], df['TR']
|
| 24 |
+
|
| 25 |
+
return df
|
基础库/通用选币回测框架/因子库/ActiveBuyRatio.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
def signal(*args):
|
| 3 |
+
df = args[0]
|
| 4 |
+
n = args[1]
|
| 5 |
+
factor_name = args[2]
|
| 6 |
+
|
| 7 |
+
# 计算主动买入占比因子
|
| 8 |
+
# taker_buy_base_asset_volume: 主动买入的基础资产数量
|
| 9 |
+
# volume: 总交易量(基础资产)
|
| 10 |
+
# 主动买入占比 = 主动买入量 / 总交易量
|
| 11 |
+
df['active_buy_ratio'] = df['taker_buy_base_asset_volume'] / (df['volume'] + 1e-9)
|
| 12 |
+
|
| 13 |
+
# 对占比进行滚动窗口处理(可选)
|
| 14 |
+
# 计算n周期内的平均主动买入占比
|
| 15 |
+
df[factor_name] = df['active_buy_ratio'].rolling(
|
| 16 |
+
window=n,
|
| 17 |
+
min_periods=1
|
| 18 |
+
).mean()
|
| 19 |
+
|
| 20 |
+
# 清理临时列
|
| 21 |
+
df.drop('active_buy_ratio', axis=1, inplace=True, errors='ignore')
|
| 22 |
+
|
| 23 |
+
return df
|
基础库/通用选币回测框架/因子库/AdxMinus.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""邢不行™️选币框架
|
| 2 |
+
Python数字货币量化投资课程
|
| 3 |
+
|
| 4 |
+
版权所有 ©️ 邢不行
|
| 5 |
+
微信: xbx8662
|
| 6 |
+
|
| 7 |
+
未经授权,不得复制、修改、或使用本代码的全部或部分内容。仅限个人学习用途,禁止商业用途。
|
| 8 |
+
|
| 9 |
+
Author: 邢不行
|
| 10 |
+
--------------------------------------------------------------------------------
|
| 11 |
+
|
| 12 |
+
# ** 因子文件功能说明 **
|
| 13 |
+
1. 因子库中的每个 Python 文件需实现 `signal` 函数,用于计算因子值。
|
| 14 |
+
2. 除 `signal` 外,可根据需求添加辅助函数,不影响因子计算逻辑。
|
| 15 |
+
|
| 16 |
+
# ** signal 函数参数与返回值说明 **
|
| 17 |
+
1. `signal` 函数的第一个参数为 `candle_df`,用于接收单个币种的 K 线数据。
|
| 18 |
+
2. `signal` 函数的第二个参数用于因子计算的主要参数,具体用法见函数实现。
|
| 19 |
+
3. `signal` 函数可以接收其他可选参数,按实际因子计算逻辑使用。
|
| 20 |
+
4. `signal` 函数的返回值应为包含因子数据的 K 线数据。
|
| 21 |
+
|
| 22 |
+
# ** candle_df 示例 **
|
| 23 |
+
candle_begin_time symbol open high low close volume quote_volume trade_num taker_buy_base_asset_volume taker_buy_quote_asset_volume funding_fee first_candle_time 是否交易
|
| 24 |
+
0 2023-11-22 1000BONK-USDT 0.004780 0.004825 0.004076 0.004531 12700997933 5.636783e+07 320715 6184933232 2.746734e+07 0.001012 2023-11-22 14:00:00 1
|
| 25 |
+
1 2023-11-23 1000BONK-USDT 0.004531 0.004858 0.003930 0.004267 18971334686 8.158966e+07 573386 8898242083 3.831782e+07 0.001634 2023-11-22 14:00:00 1
|
| 26 |
+
2 2023-11-24 1000BONK-USDT 0.004267 0.004335 0.003835 0.004140 17168511399 6.992947e+07 475254 7940993618 3.239266e+07 0.005917 2023-11-22 14:00:00 1
|
| 27 |
+
|
| 28 |
+
# ** signal 参数示例 **
|
| 29 |
+
- 如果策略配置中 `factor_list` 包含 ('AdxMinus', True, 14, 1),则 `param` 为 14,`args[0]` 为 'AdxMinus_14'。
|
| 30 |
+
- 如果策略配置中 `filter_list` 包含 ('AdxMinus', 14, 'pct:<0.8'),则 `param` 为 14,`args[0]` 为 'AdxMinus_14'。
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
"""ADX- (DI-) 下跌趋势强度指标,用于衡量下跌动能的强度"""
|
| 35 |
+
import pandas as pd
|
| 36 |
+
import numpy as np
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def signal(candle_df, param, *args):
|
| 40 |
+
"""
|
| 41 |
+
计算ADX- (DI-) 下跌趋势强度指标
|
| 42 |
+
DI-反映价格下跌动能的强度,数值越高表示下跌趋势越强
|
| 43 |
+
|
| 44 |
+
计算原理:
|
| 45 |
+
1. 计算真实波幅TR
|
| 46 |
+
2. 计算下跌方向移动DM-
|
| 47 |
+
3. 对TR和DM-进行平滑处理
|
| 48 |
+
4. DI- = (平滑DM- / 平滑TR) * 100
|
| 49 |
+
|
| 50 |
+
:param candle_df: 单个币种的K线数据
|
| 51 |
+
:param param: 计算周期参数,通常为14
|
| 52 |
+
:param args: 其他可选参数,args[0]为因子名称
|
| 53 |
+
:return: 包含因子数据的 K 线数据
|
| 54 |
+
"""
|
| 55 |
+
factor_name = args[0] # 从额外参数中获取因子名称
|
| 56 |
+
n = param # 计算周期
|
| 57 |
+
|
| 58 |
+
# 步骤1: 计算真实波幅TR (True Range)
|
| 59 |
+
# TR衡量价格的真实波动幅度,考虑跳空因素
|
| 60 |
+
tr1 = candle_df['high'] - candle_df['low'] # 当日最高价-最低价
|
| 61 |
+
tr2 = abs(candle_df['high'] - candle_df['close'].shift(1)) # 当日最高价-前日收盘价的绝对值
|
| 62 |
+
tr3 = abs(candle_df['low'] - candle_df['close'].shift(1)) # 当日最低价-前日收盘价的绝对值
|
| 63 |
+
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
| 64 |
+
|
| 65 |
+
# 步骤2: 计算下跌方向移动DM- (Directional Movement Minus)
|
| 66 |
+
# DM-衡量价格向下突破的动能
|
| 67 |
+
high_diff = candle_df['high'] - candle_df['high'].shift(1) # 最高价变化
|
| 68 |
+
low_diff = candle_df['low'].shift(1) - candle_df['low'] # 最低价变化(前日-当日,正值表示下跌)
|
| 69 |
+
|
| 70 |
+
# 只有当下跌幅度大于上涨幅度且确实下跌时,才记录为负向动能
|
| 71 |
+
dm_minus = np.where((low_diff > high_diff) & (low_diff > 0), low_diff, 0)
|
| 72 |
+
|
| 73 |
+
# 步骤3: 使用Wilder's平滑方法计算平滑TR和DM-
|
| 74 |
+
# Wilder's平滑:类似指数移动平均,但平滑系数为1/n
|
| 75 |
+
tr_smooth = tr.ewm(alpha=1/n, adjust=False).mean()
|
| 76 |
+
dm_minus_smooth = pd.Series(dm_minus).ewm(alpha=1/n, adjust=False).mean()
|
| 77 |
+
|
| 78 |
+
# 步骤4: 计算DI- (Directional Indicator Minus)
|
| 79 |
+
# DI-表示下跌趋势的相对强度,范围0-100
|
| 80 |
+
di_minus = (dm_minus_smooth / tr_smooth) * 100
|
| 81 |
+
|
| 82 |
+
# 将计算结果赋值给因子列
|
| 83 |
+
candle_df[factor_name] = di_minus
|
| 84 |
+
|
| 85 |
+
return candle_df
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# 使用说明:
|
| 89 |
+
# 1. DI-数值含义:
|
| 90 |
+
# - DI- > 25: 下跌趋势较强
|
| 91 |
+
# - DI- > 40: 强下跌趋势
|
| 92 |
+
# - DI- < 20: 下跌动能较弱
|
| 93 |
+
#
|
| 94 |
+
# 2. 交易信号参考:
|
| 95 |
+
# - DI-持续上升:下跌趋势加强,可考虑做空或避险
|
| 96 |
+
# - DI-开始下降:下跌动能减弱,可能见底反弹
|
| 97 |
+
# - DI-与DI+交叉:趋势可能转换
|
| 98 |
+
#
|
| 99 |
+
# 3. 风险管理应用:
|
| 100 |
+
# - DI-快速上升:及时止损,避免深度套牢
|
| 101 |
+
# - DI-高位钝化:下跌动能衰竭,关注反弹机会
|
| 102 |
+
# - DI-与价格背离:可能出现趋���反转信号
|
| 103 |
+
#
|
| 104 |
+
# 4. 最佳实践:
|
| 105 |
+
# - 结合ADX使用:ADX>25时DI-信号更可靠
|
| 106 |
+
# - 结合成交量:放量下跌时DI-信号更强
|
| 107 |
+
# - 关注支撑位:在重要支撑位DI-减弱可能是买入机会
|
| 108 |
+
#
|
| 109 |
+
# 5. 在config.py中的配置示例:
|
| 110 |
+
# factor_list = [
|
| 111 |
+
# ('AdxMinus', True, 14, 1), # 14周期DI-
|
| 112 |
+
# ('AdxMinus', True, 21, 1), # 21周期DI-(更平滑)
|
| 113 |
+
# ]
|
| 114 |
+
#
|
| 115 |
+
# filter_list = [
|
| 116 |
+
# ('AdxMinus', 14, 'pct:<0.3'), # 过滤掉DI-排名前30%的币种(避开强下跌趋势)
|
| 117 |
+
# ]
|
| 118 |
+
#
|
| 119 |
+
# 6. 与其他指标组合:
|
| 120 |
+
# - DI- + RSI: DI-高位+RSI超卖可能是反弹信号
|
| 121 |
+
# - DI- + 布林带: DI-上升+价格触及下轨可能是超跌
|
| 122 |
+
# - DI- + MACD: DI-与MACD背离可能预示趋势转换
|
基础库/通用选币回测框架/因子库/AdxPlus.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""邢不行™️选币框架
|
| 2 |
+
Python数字货币量化投资课程
|
| 3 |
+
|
| 4 |
+
版权所有 ©️ 邢不行
|
| 5 |
+
微信: xbx8662
|
| 6 |
+
|
| 7 |
+
未经授权,不得复制、修改、或使用本代码的全部或部分内容。仅限个人学习用途,禁止商业用途。
|
| 8 |
+
|
| 9 |
+
Author: 邢不行
|
| 10 |
+
--------------------------------------------------------------------------------
|
| 11 |
+
|
| 12 |
+
# ** 因子文件功能说明 **
|
| 13 |
+
1. 因子库中的每个 Python 文件需实现 `signal` 函数,用于计算因子值。
|
| 14 |
+
2. 除 `signal` 外,可根据需求添加辅助函数,不影响因子计算逻辑。
|
| 15 |
+
|
| 16 |
+
# ** signal 函数参数与返回值说明 **
|
| 17 |
+
1. `signal` 函数的第一个参数为 `candle_df`,用于接收单个币种的 K 线数据。
|
| 18 |
+
2. `signal` 函数的第二个参数用于因子计算的主要参数,具体用法见函数实现。
|
| 19 |
+
3. `signal` 函数可以接收其他可选参数,按实际因子计算逻辑使用。
|
| 20 |
+
4. `signal` 函数的返回值应为包含因子数据的 K 线数据。
|
| 21 |
+
|
| 22 |
+
# ** candle_df 示例 **
|
| 23 |
+
candle_begin_time symbol open high low close volume quote_volume trade_num taker_buy_base_asset_volume taker_buy_quote_asset_volume funding_fee first_candle_time 是否交易
|
| 24 |
+
0 2023-11-22 1000BONK-USDT 0.004780 0.004825 0.004076 0.004531 12700997933 5.636783e+07 320715 6184933232 2.746734e+07 0.001012 2023-11-22 14:00:00 1
|
| 25 |
+
1 2023-11-23 1000BONK-USDT 0.004531 0.004858 0.003930 0.004267 18971334686 8.158966e+07 573386 8898242083 3.831782e+07 0.001634 2023-11-22 14:00:00 1
|
| 26 |
+
2 2023-11-24 1000BONK-USDT 0.004267 0.004335 0.003835 0.004140 17168511399 6.992947e+07 475254 7940993618 3.239266e+07 0.005917 2023-11-22 14:00:00 1
|
| 27 |
+
|
| 28 |
+
# ** signal 参数示例 **
|
| 29 |
+
- 如果策略配置中 `factor_list` 包含 ('AdxPlus', True, 14, 1),则 `param` 为 14,`args[0]` 为 'AdxPlus_14'。
|
| 30 |
+
- 如果策略配置中 `filter_list` 包含 ('AdxPlus', 14, 'pct:<0.8'),则 `param` 为 14,`args[0]` 为 'AdxPlus_14'。
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
"""ADX+ (DI+) 上涨趋势强度指标,用于衡量上涨动能的强度"""
|
| 35 |
+
import pandas as pd
|
| 36 |
+
import numpy as np
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def signal(candle_df, param, *args):
|
| 40 |
+
"""
|
| 41 |
+
计算ADX+ (DI+) 上涨趋势强度指标
|
| 42 |
+
DI+反映价格上涨动能的强度,数值越高表示上涨趋势越强
|
| 43 |
+
|
| 44 |
+
计算原理:
|
| 45 |
+
1. 计算真实波幅TR
|
| 46 |
+
2. 计算上涨方向移动DM+
|
| 47 |
+
3. 对TR和DM+进行平滑处理
|
| 48 |
+
4. DI+ = (平滑DM+ / 平滑TR) * 100
|
| 49 |
+
|
| 50 |
+
:param candle_df: 单个币种的K线数据
|
| 51 |
+
:param param: 计算周期参数,通常为14
|
| 52 |
+
:param args: 其他可选参数,args[0]为因子名称
|
| 53 |
+
:return: 包含因子数据的 K 线数据
|
| 54 |
+
"""
|
| 55 |
+
factor_name = args[0] # 从额外参数中获取因子名称
|
| 56 |
+
n = param # 计算周期
|
| 57 |
+
|
| 58 |
+
# 步骤1: 计算真实波幅TR (True Range)
|
| 59 |
+
# TR衡量价格的真实波动幅度,考虑跳空因素
|
| 60 |
+
tr1 = candle_df['high'] - candle_df['low'] # 当日最高价-最低价
|
| 61 |
+
tr2 = abs(candle_df['high'] - candle_df['close'].shift(1)) # 当日最高价-前日收盘价的绝对值
|
| 62 |
+
tr3 = abs(candle_df['low'] - candle_df['close'].shift(1)) # 当日最低价-前日收盘价的绝对值
|
| 63 |
+
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
| 64 |
+
|
| 65 |
+
# 步骤2: 计算上涨方向移动DM+ (Directional Movement Plus)
|
| 66 |
+
# DM+衡量价格向上突破的动能
|
| 67 |
+
high_diff = candle_df['high'] - candle_df['high'].shift(1) # 最高价变化
|
| 68 |
+
low_diff = candle_df['low'].shift(1) - candle_df['low'] # 最低价变化(前日-当日)
|
| 69 |
+
|
| 70 |
+
# 只有当上涨幅度大于下跌幅度且确实上涨时,才记录为正向动能
|
| 71 |
+
dm_plus = np.where((high_diff > low_diff) & (high_diff > 0), high_diff, 0)
|
| 72 |
+
|
| 73 |
+
# 步骤3: 使用Wilder's平滑方法计算平滑TR和DM+
|
| 74 |
+
# Wilder's平滑:类似指数移动平均,但平滑系数为1/n
|
| 75 |
+
tr_smooth = tr.ewm(alpha=1/n, adjust=False).mean()
|
| 76 |
+
dm_plus_smooth = pd.Series(dm_plus).ewm(alpha=1/n, adjust=False).mean()
|
| 77 |
+
|
| 78 |
+
# 步骤4: 计算DI+ (Directional Indicator Plus)
|
| 79 |
+
# DI+表示上涨趋势的相对强度,范围0-100
|
| 80 |
+
di_plus = (dm_plus_smooth / tr_smooth) * 100
|
| 81 |
+
|
| 82 |
+
# 将计算结果赋值给因子列
|
| 83 |
+
candle_df[factor_name] = di_plus
|
| 84 |
+
|
| 85 |
+
return candle_df
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# 使用说明:
|
| 89 |
+
# 1. DI+数值含义:
|
| 90 |
+
# - DI+ > 25: 上涨趋势较强
|
| 91 |
+
# - DI+ > 40: 强上涨趋势
|
| 92 |
+
# - DI+ < 20: 上涨动能较弱
|
| 93 |
+
#
|
| 94 |
+
# 2. 交易信号参考:
|
| 95 |
+
# - DI+持续上升:上涨趋势加强,可考虑做多
|
| 96 |
+
# - DI+开始下降:上涨动能减弱,注意风险
|
| 97 |
+
# - DI+与DI-交叉:趋势可能转换
|
| 98 |
+
#
|
| 99 |
+
# 3. 最佳实践:
|
| 100 |
+
# - 结合ADX使用:ADX>25时DI+信号更可靠
|
| 101 |
+
# - 结合价格形态:在支撑位附近DI+上升信号更强
|
| 102 |
+
# - 避免震荡市:横盘整理时DI+信号容易失效
|
| 103 |
+
#
|
| 104 |
+
# 4. 在config.py中的配置示���:
|
| 105 |
+
# factor_list = [
|
| 106 |
+
# ('AdxPlus', True, 14, 1), # 14周期DI+
|
| 107 |
+
# ('AdxPlus', True, 21, 1), # 21周期DI+(更平滑)
|
| 108 |
+
# ]
|
| 109 |
+
#
|
| 110 |
+
# filter_list = [
|
| 111 |
+
# ('AdxPlus', 14, 'pct:>0.7'), # 选择DI+排名前30%的币种
|
| 112 |
+
# ]
|
基础库/通用选币回测框架/因子库/AdxVolWaves.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from .TMV import calculate_adx
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def signal(candle_df, param, *args):
|
| 7 |
+
factor_name = args[0] if args else "AdxVolWaves"
|
| 8 |
+
|
| 9 |
+
if isinstance(param, tuple):
|
| 10 |
+
bb_length = param[0] if len(param) > 0 else 20
|
| 11 |
+
bb_mult = param[1] if len(param) > 1 else 1.5
|
| 12 |
+
adx_length = param[2] if len(param) > 2 else 14
|
| 13 |
+
adx_influence = param[3] if len(param) > 3 else 0.8
|
| 14 |
+
zone_offset = param[4] if len(param) > 4 else 1.0
|
| 15 |
+
zone_expansion = param[5] if len(param) > 5 else 1.0
|
| 16 |
+
smooth_length = param[6] if len(param) > 6 else 50
|
| 17 |
+
signal_cooldown = param[7] if len(param) > 7 else 20
|
| 18 |
+
else:
|
| 19 |
+
bb_length = int(param)
|
| 20 |
+
bb_mult = 1.5
|
| 21 |
+
adx_length = 14
|
| 22 |
+
adx_influence = 0.8
|
| 23 |
+
zone_offset = 1.0
|
| 24 |
+
zone_expansion = 1.0
|
| 25 |
+
smooth_length = 50
|
| 26 |
+
signal_cooldown = 20
|
| 27 |
+
|
| 28 |
+
close = candle_df["close"]
|
| 29 |
+
high = candle_df["high"]
|
| 30 |
+
low = candle_df["low"]
|
| 31 |
+
|
| 32 |
+
adx_df = calculate_adx(candle_df, adx_length)
|
| 33 |
+
adx = adx_df["adx"]
|
| 34 |
+
di_plus = adx_df["di_plus"]
|
| 35 |
+
di_minus = adx_df["di_minus"]
|
| 36 |
+
adx_normalized = adx / 100.0
|
| 37 |
+
|
| 38 |
+
bb_basis = close.rolling(window=bb_length, min_periods=1).mean()
|
| 39 |
+
bb_dev = close.rolling(window=bb_length, min_periods=1).std()
|
| 40 |
+
|
| 41 |
+
adx_multiplier = 1.0 + adx_normalized * adx_influence
|
| 42 |
+
bb_dev_adjusted = bb_mult * bb_dev * adx_multiplier
|
| 43 |
+
|
| 44 |
+
bb_upper = bb_basis + bb_dev_adjusted
|
| 45 |
+
bb_lower = bb_basis - bb_dev_adjusted
|
| 46 |
+
|
| 47 |
+
bb_basis_safe = bb_basis.replace(0, np.nan)
|
| 48 |
+
bb_width = (bb_upper - bb_lower) / bb_basis_safe * 100.0
|
| 49 |
+
bb_width = bb_width.replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
| 50 |
+
|
| 51 |
+
bb_upper_smooth = bb_upper.rolling(window=smooth_length, min_periods=1).mean()
|
| 52 |
+
bb_lower_smooth = bb_lower.rolling(window=smooth_length, min_periods=1).mean()
|
| 53 |
+
bb_range_smooth = bb_upper_smooth - bb_lower_smooth
|
| 54 |
+
|
| 55 |
+
offset_distance = bb_range_smooth * zone_offset
|
| 56 |
+
|
| 57 |
+
top_zone_bottom = bb_upper_smooth + offset_distance
|
| 58 |
+
top_zone_top = top_zone_bottom + bb_range_smooth * zone_expansion
|
| 59 |
+
|
| 60 |
+
bottom_zone_top = bb_lower_smooth - offset_distance
|
| 61 |
+
bottom_zone_bottom = bottom_zone_top - bb_range_smooth * zone_expansion
|
| 62 |
+
|
| 63 |
+
price_in_top_zone = close > top_zone_bottom
|
| 64 |
+
price_in_bottom_zone = close < bottom_zone_top
|
| 65 |
+
|
| 66 |
+
bb_width_ma = bb_width.rolling(window=50, min_periods=1).mean()
|
| 67 |
+
is_squeeze = bb_width < bb_width_ma
|
| 68 |
+
|
| 69 |
+
price_in_top_zone_prev = price_in_top_zone.shift(1).fillna(False)
|
| 70 |
+
price_in_bottom_zone_prev = price_in_bottom_zone.shift(1).fillna(False)
|
| 71 |
+
|
| 72 |
+
n = len(candle_df)
|
| 73 |
+
enter_top = np.zeros(n, dtype=bool)
|
| 74 |
+
enter_bottom = np.zeros(n, dtype=bool)
|
| 75 |
+
|
| 76 |
+
top_vals = price_in_top_zone.to_numpy()
|
| 77 |
+
bottom_vals = price_in_bottom_zone.to_numpy()
|
| 78 |
+
top_prev_vals = price_in_top_zone_prev.to_numpy()
|
| 79 |
+
bottom_prev_vals = price_in_bottom_zone_prev.to_numpy()
|
| 80 |
+
|
| 81 |
+
last_buy_bar = -10**9
|
| 82 |
+
last_sell_bar = -10**9
|
| 83 |
+
|
| 84 |
+
for i in range(n):
|
| 85 |
+
if top_vals[i] and (not top_prev_vals[i]) and (i - last_sell_bar >= signal_cooldown):
|
| 86 |
+
enter_top[i] = True
|
| 87 |
+
last_sell_bar = i
|
| 88 |
+
if bottom_vals[i] and (not bottom_prev_vals[i]) and (i - last_buy_bar >= signal_cooldown):
|
| 89 |
+
enter_bottom[i] = True
|
| 90 |
+
last_buy_bar = i
|
| 91 |
+
|
| 92 |
+
factor_main = np.where(enter_bottom, 1.0, 0.0)
|
| 93 |
+
factor_main = np.where(enter_top, -1.0, factor_main)
|
| 94 |
+
|
| 95 |
+
candle_df[factor_name] = factor_main
|
| 96 |
+
candle_df[f"{factor_name}_ADX"] = adx
|
| 97 |
+
candle_df[f"{factor_name}_DI_Plus"] = di_plus
|
| 98 |
+
candle_df[f"{factor_name}_DI_Minus"] = di_minus
|
| 99 |
+
candle_df[f"{factor_name}_BB_Upper"] = bb_upper
|
| 100 |
+
candle_df[f"{factor_name}_BB_Lower"] = bb_lower
|
| 101 |
+
candle_df[f"{factor_name}_TopZoneBottom"] = top_zone_bottom
|
| 102 |
+
candle_df[f"{factor_name}_BottomZoneTop"] = bottom_zone_top
|
| 103 |
+
candle_df[f"{factor_name}_Squeeze"] = is_squeeze.astype(int)
|
| 104 |
+
candle_df[f"{factor_name}_EnterTop"] = enter_top.astype(int)
|
| 105 |
+
candle_df[f"{factor_name}_EnterBottom"] = enter_bottom.astype(int)
|
| 106 |
+
|
| 107 |
+
return candle_df
|
| 108 |
+
|