Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de83f97c05 | |||
| fd5337f163 | |||
| 5b1412eab0 | |||
| 363efd6d2d | |||
| 6494f43ddd | |||
| ee8fc60eae | |||
| c59c1b9fc4 | |||
| f938c453e1 | |||
| 2e3202968d | |||
| 2d8a0c3bca | |||
| ef4c1cca32 | |||
| 1816d585bf | |||
| db910e03d6 |
@@ -6,3 +6,7 @@ starter.dist/starter.dll
|
|||||||
build/
|
build/
|
||||||
.vscode/
|
.vscode/
|
||||||
example.db.bak
|
example.db.bak
|
||||||
|
venv/
|
||||||
|
flet_desktop/
|
||||||
|
.flet/
|
||||||
|
sfgrid.log
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# SFGrid 网格交易策略流程图
|
||||||
|
|
||||||
|
## 1. 总览:策略生命周期
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["SFGridStrategy.__init__()"] --> B["订阅事件总线<br/>onOrderCreateAsync / onOrderTrade / onOrderError"]
|
||||||
|
B --> C["获取涨跌停价<br/>todayUpStopPrice / todayDownStopPrice"]
|
||||||
|
C --> D["loadExistOrders()<br/>从券商侧恢复未成交订单到 orderGrid"]
|
||||||
|
D --> E["enabledTrading(enabled)"]
|
||||||
|
E --> F{"enabled ?"}
|
||||||
|
F -->|True| G["启用交易流程 → 见 §3"]
|
||||||
|
F -->|False| H["停用交易流程 → 见 §3"]
|
||||||
|
G --> I["saveProxy() 持久化"]
|
||||||
|
H --> I
|
||||||
|
I --> J["构造完成,进入事件循环<br/>等待 QMT 回调 / UI 操作"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 核心:refreshGridOrder() 网格下单
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
START["refreshGridOrder()"] --> CHECK1{"qmtv.isMarketActive<br/>AND<br/>tradeTarget.enabled ?"}
|
||||||
|
CHECK1 -->|No| SKIP["跳过不下单"]
|
||||||
|
CHECK1 -->|Yes| QUERY["查询未成交订单<br/>queryPendingOrder()"]
|
||||||
|
|
||||||
|
QUERY --> STATUS{"tradeTarget.status ?"}
|
||||||
|
|
||||||
|
STATUS -->|"= 0 未建仓"| CHECK_INIT{"已存在建仓单?<br/>remark = 'INIT,1,{code}'"}
|
||||||
|
CHECK_INIT -->|"No 没有"| PLACE_INIT["下建仓单 (STOCK_BUY)<br/>价格 = getPriceGrid()[0]<br/>remark = 'INIT,1,{code}'"]
|
||||||
|
CHECK_INIT -->|"Yes 已有"| DONE_INIT["建仓单已在途,跳过"]
|
||||||
|
|
||||||
|
STATUS -->|"= 1 已建仓"| GET_IDX["currentIdx = grid_index"]
|
||||||
|
GET_IDX --> SELL_CHECK{"currentIdx > 0 ?<br/>(grid_index 不是最低点)"}
|
||||||
|
SELL_CHECK -->|"Yes 可挂卖单"| SELL_EXIST{"已存在同 remark 卖单?<br/>remark='SELL,{idx-1},{code}'"}
|
||||||
|
SELL_EXIST -->|"No 没有"| SELL_PLACE["下卖出单 (STOCK_SELL)<br/>价格 = grid[sellIdx]<br/>sellIdx = currentIdx - 1"]
|
||||||
|
SELL_EXIST -->|"Yes 已有"| SELL_SKIP["跳过,避免重复"]
|
||||||
|
SELL_CHECK -->|"No 价格已最低"| SELL_SKIP2["无卖出空间"]
|
||||||
|
|
||||||
|
SELL_PLACE --> BUY_CHECK
|
||||||
|
SELL_SKIP --> BUY_CHECK
|
||||||
|
SELL_SKIP2 --> BUY_CHECK
|
||||||
|
|
||||||
|
BUY_CHECK{"currentIdx < len(grid)-1 ?<br/>(grid_index 不是最高点)"}
|
||||||
|
BUY_CHECK -->|"Yes 可挂买单"| BUY_EXIST{"已存在同价同类型买单?<br/>order_type=BUY AND price=buyPrice"}
|
||||||
|
BUY_EXIST -->|"No 没有"| BUY_PLACE["下买入单 (STOCK_BUY)<br/>价格 = grid[buyIdx]<br/>buyIdx = currentIdx + 1"]
|
||||||
|
BUY_EXIST -->|"Yes 已有"| BUY_SKIP["跳过,避免重复"]
|
||||||
|
BUY_CHECK -->|"No 价格已最高"| BUY_SKIP2["无买入空间"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 交易启停:enabledTrading()
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
START["enabledTrading(enabled)"] --> SET["self.tradeTarget.enabled = enabled"]
|
||||||
|
SET --> BRANCH{"enabled ?"}
|
||||||
|
|
||||||
|
BRANCH -->|"True 启用"| STATUS{"tradeTarget.status ?"}
|
||||||
|
STATUS -->|"= 0 未建仓"| INIT_IDX{"grid_index == 0 ?"}
|
||||||
|
INIT_IDX -->|"Yes"| SET1["grid_index = 1<br/>(默认建仓位置)"]
|
||||||
|
INIT_IDX -->|"No"| KEEP["保留现有 grid_index"]
|
||||||
|
SET1 --> REFRESH1["refreshGridOrder()"]
|
||||||
|
KEEP --> REFRESH1
|
||||||
|
|
||||||
|
STATUS -->|"= 1 已建仓"| CALC["计算最小需求仓位<br/>min = grid_volume × grid_index"]
|
||||||
|
CALC --> CHECK{"current_position >= min ?"}
|
||||||
|
CHECK -->|"Yes 充足"| REFRESH2["refreshGridOrder()"]
|
||||||
|
CHECK -->|"No 不足"| DENY["拒绝启用<br/>enabled = False<br/>(风控保护)"]
|
||||||
|
|
||||||
|
BRANCH -->|"False 停用"| CANCEL["取消所有未成交订单<br/>cancel_order_stock_async()"]
|
||||||
|
CANCEL --> LOG["记录取消数量"]
|
||||||
|
|
||||||
|
REFRESH1 --> SAVE["saveProxy() 持久化"]
|
||||||
|
REFRESH2 --> SAVE
|
||||||
|
DENY --> SAVE
|
||||||
|
LOG --> SAVE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 事件回调链
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph QMT["QMT / xtquant 层"]
|
||||||
|
OA["orderAsync()<br/>返回 seq"]
|
||||||
|
PUSH_ERR["C扩展推送<br/>XtOrderError"]
|
||||||
|
PUSH_RESP["C扩展推送<br/>XtOrderResponse"]
|
||||||
|
PUSH_TRADE["C扩展推送<br/>XtTrade"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph BUS["事件总线 event_bus"]
|
||||||
|
EVT_ERR["MarketOrderError"]
|
||||||
|
EVT_RESP["MarketOrderCreated"]
|
||||||
|
EVT_TRADE["MarketOrderTraded"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph STG["SFGridStrategy 回调"]
|
||||||
|
OE["onOrderError()"]
|
||||||
|
OC["onOrderCreateAsync()"]
|
||||||
|
OT["onOrderTrade()"]
|
||||||
|
end
|
||||||
|
|
||||||
|
OA --> PUSH_RESP
|
||||||
|
OA --> PUSH_ERR
|
||||||
|
PUSH_ERR --> EVT_ERR --> OE
|
||||||
|
PUSH_RESP --> EVT_RESP --> OC
|
||||||
|
PUSH_TRADE --> EVT_TRADE --> OT
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. onOrderError() 委托失败处理
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
START["onOrderError(order_error)"] --> CHK1{"order_remark 非空 ?"}
|
||||||
|
CHK1 -->|"No 空"| EXIT1["无法解析,忽略"]
|
||||||
|
CHK1 -->|"Yes"| PARSE["解析 remark<br/>'{type},{gridIdx},{stockCode}'"]
|
||||||
|
PARSE --> CHK2{"len(parts) >= 3 ?"}
|
||||||
|
CHK2 -->|"No"| EXIT1
|
||||||
|
CHK2 -->|"Yes"| CHK3{"strategy_name == 'SFGRID'<br/>AND<br/>stockCode 匹配本标的 ?"}
|
||||||
|
CHK3 -->|"No 不匹配"| EXIT1
|
||||||
|
CHK3 -->|"Yes"| LOCK["获取 dataUpdateLock"]
|
||||||
|
LOCK --> DEL{"gridIdx in orderGrid ?"}
|
||||||
|
DEL -->|"Yes"| REMOVE["del orderGrid[gridIdx]<br/>清理孤立条目"]
|
||||||
|
DEL -->|"No"| LOG_ERR["记录错误日志<br/>error_id / error_msg"]
|
||||||
|
REMOVE --> LOG_ERR
|
||||||
|
LOG_ERR --> UNLOCK["释放 dataUpdateLock"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. onOrderCreateAsync() 订单确认
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
START["onOrderCreateAsync(response)"] --> PARSE["解析 remark<br/>'{type},{gridIdx},{stockCode}'"]
|
||||||
|
PARSE --> FILTER{"strategy_name == 'SFGRID'<br/>AND len(parts) >= 3<br/>AND stockCode 匹配 ?"}
|
||||||
|
FILTER -->|"No"| EXIT["忽略"]
|
||||||
|
FILTER -->|"Yes"| LOCK["获取 dataUpdateLock"]
|
||||||
|
LOCK --> UPDATE["orderGrid[gridIdx] = response.order_id<br/>seq → order_id 替换"]
|
||||||
|
UPDATE --> UNLOCK["释放 dataUpdateLock"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. onOrderTrade() 成交处理
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
START["onOrderTrade(trade)"] --> PARSE["解析 remark<br/>'{type},{gridIdx},{stockCode}'"]
|
||||||
|
PARSE --> FILTER{"strategy_name == 'SFGRID'<br/>AND len(parts) >= 3<br/>AND stockCode 匹配 ?"}
|
||||||
|
FILTER -->|"No"| EXIT["忽略"]
|
||||||
|
FILTER -->|"Yes"| LOCK["获取 dataUpdateLock"]
|
||||||
|
|
||||||
|
LOCK --> TYPE{"orderType ?"}
|
||||||
|
|
||||||
|
TYPE -->|"INIT 建仓单"| INIT["status = 1<br/>init_price = traded_price<br/>grid_index = 1"]
|
||||||
|
TYPE -->|"BUY / SELL 网格单"| CMP{"gridIdx vs grid_index ?"}
|
||||||
|
|
||||||
|
CMP -->|"gridIdx > grid_index<br/>(买入成交)"| DOWN["grid_index += 1<br/>下移一格"]
|
||||||
|
CMP -->|"gridIdx < grid_index<br/>(卖出成交)"| UP["grid_index -= 1<br/>上移一格<br/>match_count += 1<br/>total_profit += grid_size × volume"]
|
||||||
|
CMP -->|"gridIdx == grid_index<br/>(异常)"| SAME["日志: 理论上不应该输出"]
|
||||||
|
|
||||||
|
INIT --> POST
|
||||||
|
DOWN --> POST
|
||||||
|
UP --> POST
|
||||||
|
SAME --> POST
|
||||||
|
|
||||||
|
POST["成交后处理"] --> SAVE["saveProxy() 持久化状态"]
|
||||||
|
SAVE --> DEL["del orderGrid[gridIdx]<br/>移除已成交订单"]
|
||||||
|
DEL --> REPORT["打印成交报告<br/>成交价/量/手续费"]
|
||||||
|
REPORT --> REFRESH["refreshGridOrder()<br/>在新位置挂新的网格单"]
|
||||||
|
REFRESH --> UNLOCK["释放 dataUpdateLock"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 网格交易完整状态机
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> 未建仓: 创建 SFGridStrategy
|
||||||
|
未建仓 --> 建仓中: enabledTrading(True)<br/>下建仓单 INIT
|
||||||
|
|
||||||
|
建仓中 --> 已建仓: onOrderTrade(INIT)<br/>建仓单成交
|
||||||
|
建仓中 --> 建仓失败: onOrderError(INIT)<br/>委托被拒
|
||||||
|
|
||||||
|
建仓失败 --> 建仓中: refreshGridOrder()<br/>重新下建仓单
|
||||||
|
|
||||||
|
已建仓 --> 网格运行: refreshGridOrder()<br/>上下各挂一单
|
||||||
|
|
||||||
|
网格运行 --> 网格运行: onOrderTrade(SELL)<br/>卖出成交 → 上移<br/>重新挂单
|
||||||
|
网格运行 --> 网格运行: onOrderTrade(BUY)<br/>买入成交 → 下移<br/>重新挂单
|
||||||
|
网格运行 --> 单边挂单: onOrderError<br/>某方向委托失败
|
||||||
|
|
||||||
|
单边挂单 --> 网格运行: refreshGridOrder()<br/>重新补挂失败方向的单
|
||||||
|
|
||||||
|
已建仓 --> 已停用: enabledTrading(False)<br/>取消所有挂单
|
||||||
|
网格运行 --> 已停用: enabledTrading(False)
|
||||||
|
单边挂单 --> 已停用: enabledTrading(False)
|
||||||
|
|
||||||
|
已停用 --> 已建仓: enabledTrading(True)<br/>仓位检查通过
|
||||||
|
已停用 --> 已停用: enabledTrading(True)<br/>仓位不足,回退
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 网格价格示意
|
||||||
|
|
||||||
|
```
|
||||||
|
价格
|
||||||
|
↑
|
||||||
|
│ grid[5] = 12.00 ← 最贵(顶部)
|
||||||
|
│ grid[4] = 11.50
|
||||||
|
│ grid[3] = 11.00 ← 当前位置 grid_index=3
|
||||||
|
│ grid[2] = 10.50 上方挂卖单 @10.50 (sellIdx=2, grid_index-1)
|
||||||
|
│ grid[1] = 10.00 下方挂买单 @10.00 (buyIdx=1, 已成交位置)
|
||||||
|
│ grid[0] = 9.50 ← 最便宜(底部/建仓价)
|
||||||
|
│
|
||||||
|
└──────────────────────→
|
||||||
|
|
||||||
|
grid_index=3 时:
|
||||||
|
卖单挂在 grid[2] @10.50 → 价格跌到 10.50 卖出(上移一格,赚差价)
|
||||||
|
买单挂在 grid[4] @11.50 → 价格涨到 11.50 买入(下移一格,补仓)
|
||||||
|
|
||||||
|
grid_size = grid[i] - grid[i-1] = 0.50(每格利润空间)
|
||||||
|
```
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
[config]
|
|
||||||
miniqmtpath = D:/Programs/DTQMT/userdata_mini
|
|
||||||
account_no = 99082560
|
|
||||||
log_level = INFO
|
|
||||||
|
|
||||||
@@ -1,64 +1,25 @@
|
|||||||
import configparser
|
"""
|
||||||
from pathlib import Path
|
运行时配置 — 端口、路径、账号由自动探测设置,无需配置文件。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from pathlib import Path
|
||||||
|
|
||||||
miniQMTPath = r'D:\\Programs\\DTQMT\\userdata_mini' # miniQMT软件的安装路径
|
# ---- 自动探测的配置项(默认值仅占位,启动时自动修正) ----
|
||||||
# miniQMTPath = ''
|
miniQMTPath: str = ''
|
||||||
account_no:str = '99082560'
|
account_no: str = ''
|
||||||
console_log = True
|
log_level: str = 'INFO'
|
||||||
log_level = "INFO"
|
console_log: bool = True
|
||||||
|
use_simulated_qmt: bool = False
|
||||||
|
|
||||||
config : Any
|
|
||||||
|
|
||||||
def get_config_path() -> Path:
|
def app_dir() -> Path:
|
||||||
"""获取配置文件的正确路径(兼容开发环境和打包后的可执行文件)"""
|
"""应用根目录(兼容开发环境与打包后的 exe)"""
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
# 打包后的可执行文件环境
|
return Path(sys.executable).parent
|
||||||
# sys._MEIPASS是PyInstaller解压临时文件的目录
|
return Path(__file__).resolve().parent
|
||||||
# 配置文件应该放在可执行文件同目录下
|
|
||||||
base_path = Path(sys.executable).parent
|
|
||||||
else:
|
|
||||||
# 开发环境
|
|
||||||
base_path = Path(__file__).resolve().parent
|
|
||||||
|
|
||||||
return base_path / 'config.ini'
|
|
||||||
|
|
||||||
def get_config(section:str, key:str):
|
def log_file_path() -> Path:
|
||||||
pass
|
"""日志文件路径"""
|
||||||
|
return app_dir() / 'sfgrid.log'
|
||||||
def save_config(miniQmtPath:str, account_no:str):
|
|
||||||
"""创建默认配置文件"""
|
|
||||||
config = configparser.ConfigParser()
|
|
||||||
config['config'] = {
|
|
||||||
'miniQMTPath': miniQmtPath,
|
|
||||||
'account_no': account_no
|
|
||||||
}
|
|
||||||
config_path = get_config_path()
|
|
||||||
with open(config_path, 'w') as configfile:
|
|
||||||
config.write(configfile)
|
|
||||||
print(f'已创建默认配置文件: {config_path}')
|
|
||||||
|
|
||||||
def exist_config() -> bool:
|
|
||||||
"""检查配置文件是否存在"""
|
|
||||||
config_path = get_config_path()
|
|
||||||
return config_path.exists()
|
|
||||||
|
|
||||||
def initConfig() -> bool:
|
|
||||||
global miniQMTPath, account_no, log_level
|
|
||||||
|
|
||||||
# 获取配置文件路径
|
|
||||||
config_path = get_config_path()
|
|
||||||
|
|
||||||
config = configparser.ConfigParser()
|
|
||||||
config.read(config_path, encoding='utf-8')
|
|
||||||
miniQMTPath = config.get('config','miniQMTPath')
|
|
||||||
account_no = config.get('config','account_no')
|
|
||||||
log_level = config.get('config','log_level')
|
|
||||||
|
|
||||||
# 判断miniQMTPath是否为空,并且目录是否存在
|
|
||||||
if not miniQMTPath or not Path(miniQMTPath).exists():
|
|
||||||
print('请先配置miniQMTPath')
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
# Global configuration variables
|
|
||||||
# Define these BEFORE imports to avoid circular dependency issues with logger
|
|
||||||
console_log = True
|
|
||||||
miniQMTPath = None
|
|
||||||
miniQMTAccount = None
|
|
||||||
log_level = "1"
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from core.config.config_model import ConfigModel, CfgKeyLogLevel, CfgKeyMiniQmtPath, CfgKeyMiniQmtAccount, CfgKeyConsoleLog
|
|
||||||
from core.database import db
|
|
||||||
|
|
||||||
def initConfig() -> bool:
|
|
||||||
"""Initialize configuration from database"""
|
|
||||||
global miniQMTPath, miniQMTAccount, log_level, console_log
|
|
||||||
|
|
||||||
# Ensure connection and tables
|
|
||||||
db.connect(reuse_if_open=True)
|
|
||||||
if not db.table_exists(ConfigModel._meta.table_name):
|
|
||||||
db.create_tables([ConfigModel])
|
|
||||||
|
|
||||||
# Check and initialize keys
|
|
||||||
_init_key(CfgKeyLogLevel, "1")
|
|
||||||
_init_key(CfgKeyConsoleLog, "True")
|
|
||||||
_init_key(CfgKeyMiniQmtPath, None)
|
|
||||||
_init_key(CfgKeyMiniQmtAccount, None)
|
|
||||||
|
|
||||||
# Load values
|
|
||||||
try:
|
|
||||||
miniQMTPath = _get_value(CfgKeyMiniQmtPath)
|
|
||||||
miniQMTAccount = _get_value(CfgKeyMiniQmtAccount)
|
|
||||||
log_level = _get_value(CfgKeyLogLevel) or "1"
|
|
||||||
console_log = _get_value(CfgKeyConsoleLog) or "True"
|
|
||||||
console_log = console_log.lower() == "true"
|
|
||||||
|
|
||||||
# console_log is not in DB currently, keeping default True or could add to DB
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error loading config: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Validate path
|
|
||||||
if not miniQMTPath or not Path(miniQMTPath).exists():
|
|
||||||
print('请先配置miniQMTPath')
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _init_key(key: str, default_value: str | None):
|
|
||||||
"""Helper to initialize a key if it doesn't exist"""
|
|
||||||
try:
|
|
||||||
ConfigModel.get(ConfigModel.key == key)
|
|
||||||
except ConfigModel.DoesNotExist:
|
|
||||||
ConfigModel.create(key=key, value=default_value)
|
|
||||||
|
|
||||||
def _get_value(key: str) -> str | None:
|
|
||||||
"""Helper to get value safely"""
|
|
||||||
try:
|
|
||||||
return ConfigModel.get(ConfigModel.key == key).value
|
|
||||||
except ConfigModel.DoesNotExist:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def save_config(key: str, value: str):
|
|
||||||
"""Save configuration to database"""
|
|
||||||
_update_key(key, value)
|
|
||||||
print(f'配置已更新: {key}={value}')
|
|
||||||
|
|
||||||
def _update_key(key: str, value: str):
|
|
||||||
try:
|
|
||||||
record = ConfigModel.get(ConfigModel.key == key)
|
|
||||||
record.value = value
|
|
||||||
record.save()
|
|
||||||
except ConfigModel.DoesNotExist:
|
|
||||||
ConfigModel.create(key=key, value=value)
|
|
||||||
|
|
||||||
def exist_config() -> bool:
|
|
||||||
"""Check if essential config exists"""
|
|
||||||
path = _get_value(CfgKeyMiniQmtPath)
|
|
||||||
account = _get_value(CfgKeyMiniQmtAccount)
|
|
||||||
return bool(path and account)
|
|
||||||
|
|
||||||
def getLogLevel() -> str:
|
|
||||||
"""获取配置中的日志级别"""
|
|
||||||
return log_level
|
|
||||||
|
|
||||||
def getConsoleLog() -> bool:
|
|
||||||
"""获取配置中的控制台日志设置"""
|
|
||||||
return console_log
|
|
||||||
|
|
||||||
def getMiniQMTPath() -> str | None:
|
|
||||||
"""获取配置中的miniQMT路径"""
|
|
||||||
return miniQMTPath
|
|
||||||
|
|
||||||
def getMiniQMTAccount() -> str | None:
|
|
||||||
"""获取配置的miniQMT账号"""
|
|
||||||
return miniQMTAccount
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
from peewee import CharField
|
|
||||||
from core.database import BaseModel, db
|
|
||||||
|
|
||||||
CfgKeyLogLevel = "log_level"
|
|
||||||
CfgKeyConsoleLog = "console_log"
|
|
||||||
CfgKeyMiniQmtPath = "miniQMTPath"
|
|
||||||
CfgKeyMiniQmtAccount = "miniQMTAccount"
|
|
||||||
|
|
||||||
class ConfigModel(BaseModel):
|
|
||||||
key = CharField(unique=True)
|
|
||||||
value = CharField(null=True)
|
|
||||||
@@ -2,5 +2,4 @@ import xtquant.xtconstant as xtconstant
|
|||||||
|
|
||||||
OrderTypeBuy = f'{xtconstant.STOCK_BUY}' # 买
|
OrderTypeBuy = f'{xtconstant.STOCK_BUY}' # 买
|
||||||
OrderTypeSell = f'{xtconstant.STOCK_SELL}' # 卖
|
OrderTypeSell = f'{xtconstant.STOCK_SELL}' # 卖
|
||||||
OrderTypeInit = "0" # 建仓
|
|
||||||
OrderTypeNone = "None"
|
OrderTypeNone = "None"
|
||||||
+2
-1
@@ -1,9 +1,10 @@
|
|||||||
from peewee import SqliteDatabase, Model
|
from peewee import SqliteDatabase, Model
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
|
||||||
# 连接到SQLite数据库
|
# 连接到SQLite数据库
|
||||||
db: SqliteDatabase = SqliteDatabase('example.db')
|
db: SqliteDatabase = SqliteDatabase('example.db')
|
||||||
db.connect()
|
db.connect()
|
||||||
print("Database connected")
|
PrintLog(LogLevel.INFO, '- [成功]数据库连接')
|
||||||
|
|
||||||
# 定义基础模型类
|
# 定义基础模型类
|
||||||
class BaseModel(Model):
|
class BaseModel(Model):
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
class EventBus:
|
|
||||||
def __init__(self):
|
|
||||||
self.listeners = {} # 管理各种event的订阅情况
|
|
||||||
|
|
||||||
def subscribe(self, event_type, listener):
|
|
||||||
if event_type not in self.listeners:
|
|
||||||
self.listeners[event_type] = []
|
|
||||||
self.listeners[event_type].append(listener)
|
|
||||||
|
|
||||||
def publish(self, event_type, data):
|
|
||||||
if event_type in self.listeners:
|
|
||||||
for listener in self.listeners[event_type]:
|
|
||||||
listener(data)
|
|
||||||
|
|
||||||
# 订阅与发布事件示例
|
|
||||||
# event_bus.subscribe('my_event', handle_event)
|
|
||||||
# event_bus.publish('my_event', {'key': 'value'})
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from .eventbus import EventBus
|
|
||||||
|
|
||||||
# Pring Log
|
|
||||||
EventPrintLog = "print_log" # 打印日志
|
|
||||||
|
|
||||||
# 创建事件总线实例
|
|
||||||
loggerEBus = EventBus()
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from eventbus import EventBus
|
|
||||||
|
|
||||||
# 市场数据监听控制事件
|
|
||||||
EventMarketActiveSwitch = "market_active_switch" # 市场数据状态变更
|
|
||||||
MarketDataUpdate = "market_data_update" # 市价更新
|
|
||||||
MarketOrderCreated = "market_order_created" # 市价单创建
|
|
||||||
MarketOrderTraded = "market_order_traded" # 市价单成交
|
|
||||||
|
|
||||||
# 创建事件总线实例
|
|
||||||
marketDataEventBus = EventBus()
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
# 市场数据监听控制事件
|
||||||
|
EventMarketActiveSwitch = "market_active_switch" # 市场数据状态变更
|
||||||
|
MarketDataUpdate = "market_data_update" # 市价更新
|
||||||
|
MarketOrderCreated = "market_order_created" # 市价单创建
|
||||||
|
MarketOrderTraded = "market_order_traded" # 市价单成交
|
||||||
|
MarketOrderError = "market_order_error" # 市价单委托失败
|
||||||
|
# Pring Log
|
||||||
|
EventPrintLog = "print_log" # 打印日志
|
||||||
|
|
||||||
|
class EventBus:
|
||||||
|
def __init__(self):
|
||||||
|
self.listeners = {} # 管理各种event的订阅情况
|
||||||
|
self.last_events = {} # 存储每个事件的最后一次值,用于"重播"给新订阅者
|
||||||
|
|
||||||
|
def subscribe(self, event_type, listener, replay=True):
|
||||||
|
"""订阅事件
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_type: 事件类型
|
||||||
|
listener: 回调函数
|
||||||
|
replay: 是否自动重播最近一次事件状态(默认True)
|
||||||
|
"""
|
||||||
|
if event_type not in self.listeners:
|
||||||
|
self.listeners[event_type] = []
|
||||||
|
self.listeners[event_type].append(listener)
|
||||||
|
|
||||||
|
# 新订阅者自动收到最近一次事件状态(如果存在)
|
||||||
|
if replay and event_type in self.last_events:
|
||||||
|
listener(self.last_events[event_type])
|
||||||
|
|
||||||
|
def publish(self, event_type, data):
|
||||||
|
# 存储最后一次事件值
|
||||||
|
self.last_events[event_type] = data
|
||||||
|
if event_type in self.listeners:
|
||||||
|
for listener in self.listeners[event_type]:
|
||||||
|
listener(data)
|
||||||
|
|
||||||
|
|
||||||
|
# # 订阅事件
|
||||||
|
# event_bus.subscribe('my_event', handle_event)
|
||||||
|
|
||||||
|
# # 发布事件
|
||||||
|
# event_bus.publish('my_event', {'key': 'value'})
|
||||||
|
|
||||||
|
# 创建事件总线实例
|
||||||
|
event_bus = EventBus()
|
||||||
+30
-7
@@ -1,7 +1,9 @@
|
|||||||
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
import threading
|
||||||
|
|
||||||
from core.ebus.logger_ebus import EventPrintLog, loggerEBus
|
from core.eventbus import EventPrintLog, event_bus
|
||||||
from core.config import config as config
|
import config
|
||||||
|
|
||||||
|
|
||||||
class LogLevel(Enum):
|
class LogLevel(Enum):
|
||||||
@@ -14,13 +16,34 @@ class LogLevel(Enum):
|
|||||||
def __le__(self, other):
|
def __le__(self, other):
|
||||||
return self.value <= other.value
|
return self.value <= other.value
|
||||||
|
|
||||||
|
|
||||||
class LogData:
|
class LogData:
|
||||||
def __init__(self, level:LogLevel, message:str):
|
def __init__(self, level: LogLevel, message: str):
|
||||||
self.level = level
|
self.level = level
|
||||||
self.message = message
|
self.message = message
|
||||||
|
|
||||||
def PrintLog(level:LogLevel, message:str):
|
|
||||||
|
_log_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _log_file_path():
|
||||||
|
"""日志文件路径"""
|
||||||
|
return str(config.log_file_path())
|
||||||
|
|
||||||
|
|
||||||
|
def PrintLog(level: LogLevel, message: str):
|
||||||
data = LogData(level, message)
|
data = LogData(level, message)
|
||||||
loggerEBus.publish(EventPrintLog, data)
|
event_bus.publish(EventPrintLog, data)
|
||||||
if config.getConsoleLog():
|
|
||||||
print(f'{level.name} {message}')
|
line = f'{datetime.now().strftime("%Y-%m-%d %H:%M:%S")} [{level.name}] {message}'
|
||||||
|
|
||||||
|
if config.console_log:
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
# 写入日志文件
|
||||||
|
try:
|
||||||
|
with _log_lock:
|
||||||
|
with open(_log_file_path(), 'a', encoding='utf-8') as f:
|
||||||
|
f.write(line + '\n')
|
||||||
|
except Exception:
|
||||||
|
pass # 写文件失败不阻塞主流程
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
# coding:utf-8
|
|
||||||
# MainEntry 负责应用主窗口与菜单的统一构建:
|
|
||||||
# - 通过 build_menu_model 定义跨平台统一的菜单数据结构
|
|
||||||
# - 在 macOS 上使用 Tk 菜单栏;在非 macOS 上使用 pystray 系统托盘
|
|
||||||
# - 所有菜单项均绑定到同名处理函数,切换平台无需改动业务逻辑
|
|
||||||
import tkinter as tk
|
|
||||||
from core.logger import LogLevel, PrintLog
|
|
||||||
import threading
|
|
||||||
import sys
|
|
||||||
|
|
||||||
class MainEntry:
|
|
||||||
def __init__(self, master):
|
|
||||||
# 初始化 Tk 窗口属性与基础状态
|
|
||||||
self.master = master
|
|
||||||
self.master.title("Main Board")
|
|
||||||
self.master.geometry("800x600")
|
|
||||||
self.master.configure(bg="#f0f0f0")
|
|
||||||
self.master.resizable(False, False)
|
|
||||||
self.master.protocol("WM_DELETE_WINDOW", self.hide_window)
|
|
||||||
# QMT 开关状态用于动态更新菜单文案
|
|
||||||
self.qmt_enabled = False
|
|
||||||
self.icon = None
|
|
||||||
# 非 macOS 使用系统托盘(pystray);macOS 使用原生菜单栏
|
|
||||||
self.systray_supported = sys.platform != "darwin"
|
|
||||||
|
|
||||||
# 主内容容器
|
|
||||||
self.main_frame = tk.Frame(self.master, bg="#f0f0f0")
|
|
||||||
self.main_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20)
|
|
||||||
|
|
||||||
# 首次进入根据平台构建菜单
|
|
||||||
self.create_menu()
|
|
||||||
self.create_dashboard()
|
|
||||||
|
|
||||||
def build_menu_model(self):
|
|
||||||
# 菜单模型统一描述所有菜单:
|
|
||||||
# - 每个分组包含 label 与 items
|
|
||||||
# - item 支持:label 文案、action 处理函数名、enabled 启用状态、default 默认项、separator 分隔符
|
|
||||||
# - 文案可根据状态动态生成(如 QMT 开关)
|
|
||||||
qmt_label = "QMT (已开启)" if self.qmt_enabled else "QMT (已关闭)"
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"label": "-- 交易大师 --",
|
|
||||||
"items": [
|
|
||||||
{"label": "交易复盘", "action": "handler", "enabled": True},
|
|
||||||
{"label": "市场数据", "action": "handler", "enabled": True},
|
|
||||||
{"label": "快速下单", "action": "handler", "enabled": True},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "-- 策略交易 --",
|
|
||||||
"items": [
|
|
||||||
{"label": "交易看板", "action": "handler", "enabled": True},
|
|
||||||
{"label": "策略中心", "action": None, "enabled": False},
|
|
||||||
{"label": "策略定制", "action": None, "enabled": False},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "-- 实时数据 --",
|
|
||||||
"items": [
|
|
||||||
{"label": qmt_label, "action": "marketDataSwitch", "enabled": True},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "-- 系统 --",
|
|
||||||
"items": [
|
|
||||||
{"label": "控制台", "action": "show_window", "enabled": True, "default": True},
|
|
||||||
{"label": "设置", "action": "marketDataSwitch", "enabled": True},
|
|
||||||
{"separator": True},
|
|
||||||
{"label": "退出", "action": "quit_window", "enabled": True},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
def create_dashboard(self):
|
|
||||||
# 根据菜单模型构建主窗口按钮面板
|
|
||||||
for widget in self.main_frame.winfo_children():
|
|
||||||
widget.destroy()
|
|
||||||
|
|
||||||
model = self.build_menu_model()
|
|
||||||
|
|
||||||
for group in model:
|
|
||||||
# 为每个分组创建 LabelFrame
|
|
||||||
group_frame = tk.LabelFrame(self.main_frame, text=group["label"], bg="#f0f0f0", padx=10, pady=10)
|
|
||||||
group_frame.pack(fill=tk.X, pady=10, padx=10)
|
|
||||||
|
|
||||||
for it in group["items"]:
|
|
||||||
if it.get("separator"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
fn = getattr(self, it["action"]) if it.get("action") else None
|
|
||||||
state = tk.NORMAL if it.get("enabled", True) else tk.DISABLED
|
|
||||||
|
|
||||||
# 创建按钮
|
|
||||||
btn = tk.Button(group_frame, text=it["label"], command=fn, state=state)
|
|
||||||
btn.pack(side=tk.LEFT, padx=5)
|
|
||||||
|
|
||||||
def create_menu(self):
|
|
||||||
# 根据统一菜单模型与平台类型,渲染到系统托盘或 Tk 菜单栏
|
|
||||||
model = self.build_menu_model()
|
|
||||||
if self.systray_supported:
|
|
||||||
# 非 macOS:延迟导入 pystray 与 PIL,避免在 macOS 上引入不兼容依赖
|
|
||||||
from PIL import Image
|
|
||||||
import pystray
|
|
||||||
image = Image.open("logo.png")
|
|
||||||
items = []
|
|
||||||
for group in model:
|
|
||||||
# 分组标题作为禁用的头部项
|
|
||||||
items.append(pystray.MenuItem(group["label"], None, enabled=False))
|
|
||||||
for it in group["items"]:
|
|
||||||
if it.get("separator"):
|
|
||||||
items.append(pystray.Menu.SEPARATOR)
|
|
||||||
else:
|
|
||||||
fn = getattr(self, it["action"]) if it.get("action") else None
|
|
||||||
items.append(pystray.MenuItem(it["label"], fn, default=it.get("default", False), enabled=it.get("enabled", True)))
|
|
||||||
menu = tuple(items)
|
|
||||||
if self.icon:
|
|
||||||
# 已存在托盘图标:更新菜单
|
|
||||||
self.icon.menu = menu
|
|
||||||
self.icon.update_menu()
|
|
||||||
else:
|
|
||||||
# 首次创建托盘图标并在后台线程运行
|
|
||||||
self.icon = pystray.Icon("name", image, "标题", menu)
|
|
||||||
self.trayThread = threading.Thread(target=self.icon.run, daemon=True)
|
|
||||||
self.trayThread.start()
|
|
||||||
else:
|
|
||||||
# macOS:使用 Tk 菜单栏
|
|
||||||
menu_bar = tk.Menu(self.master)
|
|
||||||
for group in model:
|
|
||||||
m = tk.Menu(menu_bar, tearoff=0)
|
|
||||||
for it in group["items"]:
|
|
||||||
if it.get("separator"):
|
|
||||||
m.add_separator()
|
|
||||||
else:
|
|
||||||
fn = getattr(self, it["action"]) if it.get("action") else None
|
|
||||||
if it.get("enabled", True) and fn:
|
|
||||||
m.add_command(label=it["label"], command=fn)
|
|
||||||
else:
|
|
||||||
m.add_command(label=it["label"], state="disabled")
|
|
||||||
menu_bar.add_cascade(label=group["label"], menu=m)
|
|
||||||
self.master.config(menu=menu_bar)
|
|
||||||
|
|
||||||
def marketDataSwitch(self):
|
|
||||||
# 切换 QMT 开关,并触发菜单重建以更新文案
|
|
||||||
if self.qmt_enabled:
|
|
||||||
self.qmt_enabled = False
|
|
||||||
PrintLog(LogLevel.INFO, "QMT 市场数据已关闭")
|
|
||||||
else:
|
|
||||||
self.qmt_enabled = True
|
|
||||||
PrintLog(LogLevel.INFO, "QMT 市场数据已开启")
|
|
||||||
self.create_menu()
|
|
||||||
self.create_dashboard()
|
|
||||||
|
|
||||||
def handler(self):
|
|
||||||
# 通用占位处理:当前仅记录点击行为,后续可替换为具体业务逻辑
|
|
||||||
PrintLog(LogLevel.INFO, f"点击了")
|
|
||||||
|
|
||||||
def hide_window(self):
|
|
||||||
# 关闭窗口事件:隐藏但不退出应用
|
|
||||||
PrintLog(LogLevel.INFO, "隐藏主窗口")
|
|
||||||
self.master.withdraw() # 隐藏主窗口
|
|
||||||
|
|
||||||
def show_window(self):
|
|
||||||
# 显示主窗口;在非 macOS 平台同步让托盘图标可见
|
|
||||||
if self.icon:
|
|
||||||
self.icon.visible = True
|
|
||||||
PrintLog(LogLevel.INFO, "显示主窗口")
|
|
||||||
self.master.deiconify() # 显示主窗口
|
|
||||||
|
|
||||||
def quit_window(self, icon=None):
|
|
||||||
# 退出应用;在非 macOS 平台时关闭托盘图标
|
|
||||||
if icon:
|
|
||||||
icon.stop()
|
|
||||||
PrintLog(LogLevel.INFO, "退出应用")
|
|
||||||
self.master.quit()
|
|
||||||
self.master.destroy()
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
# 主事件循环入口
|
|
||||||
self.master.mainloop()
|
|
||||||
-262
@@ -1,262 +0,0 @@
|
|||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk
|
|
||||||
from core.logger import LogLevel, LogData, PrintLog
|
|
||||||
from core.sfgrid.sfgrid_ui import TradeTargetUI
|
|
||||||
|
|
||||||
from tkinter import ttk
|
|
||||||
from core.eventbus import EventPrintLog
|
|
||||||
from core.eventbus import event_bus as eBus
|
|
||||||
|
|
||||||
|
|
||||||
class MainWindow:
|
|
||||||
def __init__(self, configLogLevel:str):
|
|
||||||
self.root = tk.Tk()
|
|
||||||
self.root.title("神之一手 - 交易系统")
|
|
||||||
self.root.geometry("1400x700")
|
|
||||||
|
|
||||||
self.logLevel = LogLevel[configLogLevel]
|
|
||||||
PrintLog(LogLevel.DEBUG, f"系统启动成功 {self.logLevel.name}")
|
|
||||||
# 当前选中的策略Tab索引
|
|
||||||
self.current_strategy_index = 0
|
|
||||||
# 存储各个Frame的引用
|
|
||||||
self.strategy_frames = {}
|
|
||||||
# 日志面板可见性标志
|
|
||||||
self.log_visible = False
|
|
||||||
self.create_ui()
|
|
||||||
|
|
||||||
eBus.subscribe(EventPrintLog, self.on_log_event)
|
|
||||||
|
|
||||||
|
|
||||||
def create_ui(self):
|
|
||||||
"""创建UI界面"""
|
|
||||||
# 主容器
|
|
||||||
main_container = ttk.Frame(self.root)
|
|
||||||
main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
||||||
|
|
||||||
# 中间主体区域(左右布局)
|
|
||||||
content_area = ttk.Frame(main_container)
|
|
||||||
content_area.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# 左侧Tab按钮栏(垂直排列)
|
|
||||||
tab_bar_frame = ttk.Frame(content_area)
|
|
||||||
tab_bar_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
|
|
||||||
|
|
||||||
# 创建自定义样式
|
|
||||||
self.create_custom_styles()
|
|
||||||
|
|
||||||
# 创建Tab按钮(垂直排列,文字垂直显示)
|
|
||||||
self.tab_buttons = []
|
|
||||||
strategy_names = ["网格", "复盘"]
|
|
||||||
|
|
||||||
for idx, name in enumerate(strategy_names):
|
|
||||||
btn = ttk.Button(
|
|
||||||
tab_bar_frame,
|
|
||||||
text=name,
|
|
||||||
command=lambda i=idx: self.switch_strategy_tab(i),
|
|
||||||
width=4,
|
|
||||||
style='Bookmark.TButton' # 使用自定义书签样式
|
|
||||||
)
|
|
||||||
btn.pack(side=tk.TOP, pady=2, fill=tk.X)
|
|
||||||
self.tab_buttons.append(btn)
|
|
||||||
|
|
||||||
# 在Tab按钮下方添加退出按钮和日志按钮(底部对齐)
|
|
||||||
# 使用一个填充Frame将按钮推到底部
|
|
||||||
spacer = ttk.Frame(tab_bar_frame)
|
|
||||||
spacer.pack(side=tk.TOP, fill=tk.X, ipady=10)
|
|
||||||
|
|
||||||
# 清空日志按钮(底部第三个)
|
|
||||||
clear_log_btn = ttk.Button(
|
|
||||||
tab_bar_frame,
|
|
||||||
text="🗑", # 垃圾桶图标
|
|
||||||
command=self.clear_logs,
|
|
||||||
width=3
|
|
||||||
)
|
|
||||||
clear_log_btn.pack(side=tk.TOP, pady=2, fill=tk.X)
|
|
||||||
|
|
||||||
# 日志显示按钮(退出按钮上方)
|
|
||||||
self.log_toggle_btn = ttk.Button(
|
|
||||||
tab_bar_frame,
|
|
||||||
text="📋", # 日志图标
|
|
||||||
command=self.toggle_log_panel,
|
|
||||||
width=3
|
|
||||||
)
|
|
||||||
self.log_toggle_btn.pack(side=tk.TOP, pady=2, fill=tk.X)
|
|
||||||
|
|
||||||
# 退出按钮(最底部)
|
|
||||||
exit_btn = ttk.Button(
|
|
||||||
tab_bar_frame,
|
|
||||||
text="⏻", # 电源图标
|
|
||||||
command=self.on_exit,
|
|
||||||
width=3
|
|
||||||
)
|
|
||||||
exit_btn.pack(side=tk.TOP, pady=2, fill=tk.X)
|
|
||||||
|
|
||||||
# 添加垂直分隔线
|
|
||||||
separator = ttk.Separator(content_area, orient='vertical')
|
|
||||||
separator.pack(side=tk.LEFT, fill=tk.Y, padx=1)
|
|
||||||
|
|
||||||
# 右侧内容区域容器(用于放置不同策略的Frame)
|
|
||||||
self.content_container = ttk.Frame(content_area)
|
|
||||||
self.content_container.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# 创建各个策略的Frame
|
|
||||||
self.create_strategy_frames(strategy_names)
|
|
||||||
|
|
||||||
# 创建全局日志面板(默认隐藏)
|
|
||||||
self.create_global_log_panel(main_container)
|
|
||||||
|
|
||||||
# 默认显示第一个策略
|
|
||||||
self.switch_strategy_tab(0)
|
|
||||||
|
|
||||||
def create_custom_styles(self):
|
|
||||||
"""创建自定义样式"""
|
|
||||||
style = ttk.Style()
|
|
||||||
|
|
||||||
# 创建书签样式
|
|
||||||
style.configure(
|
|
||||||
'Bookmark.TButton',
|
|
||||||
relief='flat',
|
|
||||||
borderwidth=1,
|
|
||||||
padding=(5, 10),
|
|
||||||
foreground='black',
|
|
||||||
background='#FFE599', # 浅黄色背景,类似便签纸
|
|
||||||
font=('Arial', 10, 'bold')
|
|
||||||
)
|
|
||||||
|
|
||||||
# 设置焦点样式(选中状态)
|
|
||||||
style.map(
|
|
||||||
'Bookmark.TButton',
|
|
||||||
background=[('active', '#F1C232'), ('pressed', '#F1C232')],
|
|
||||||
relief=[('pressed', 'sunken')]
|
|
||||||
)
|
|
||||||
|
|
||||||
# 创建选中状态的书签样式
|
|
||||||
style.configure(
|
|
||||||
'SelectedBookmark.TButton',
|
|
||||||
relief='flat',
|
|
||||||
borderwidth=1,
|
|
||||||
padding=(5, 10),
|
|
||||||
background='#3D85C6', # 蓝色背景表示选中状态
|
|
||||||
font=('Arial', 10, 'bold')
|
|
||||||
)
|
|
||||||
|
|
||||||
def create_global_log_panel(self, parent):
|
|
||||||
"""创建全局日志面板"""
|
|
||||||
# 日志区域(默认隐藏)
|
|
||||||
self.log_frame = ttk.LabelFrame(parent, text="操作日志", padding=10)
|
|
||||||
# 默认不显示,通过工具栏按钮控制
|
|
||||||
|
|
||||||
# 创建日志表格
|
|
||||||
columns = ("timestamp", "level", "message")
|
|
||||||
|
|
||||||
self.log_table = ttk.Treeview(self.log_frame, columns=columns, show='headings', height=8)
|
|
||||||
|
|
||||||
log_column_configs = {
|
|
||||||
"timestamp": ("时间", 100),
|
|
||||||
"level": ("级别", 50),
|
|
||||||
"message": ("消息", 1150) # 调整宽度适应全局布局
|
|
||||||
}
|
|
||||||
|
|
||||||
for col in columns:
|
|
||||||
title, width = log_column_configs[col]
|
|
||||||
self.log_table.heading(col, text=title)
|
|
||||||
self.log_table.column(col, width=width, anchor=tk.W)
|
|
||||||
|
|
||||||
# 添加初始日志
|
|
||||||
from datetime import datetime
|
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
self.log_table.insert('', tk.END, values=(timestamp, "INFO", "系统启动成功"))
|
|
||||||
|
|
||||||
# 滚动条
|
|
||||||
scrollbar = ttk.Scrollbar(self.log_frame, orient=tk.VERTICAL, command=self.log_table.yview)
|
|
||||||
self.log_table.configure(yscrollcommand=scrollbar.set)
|
|
||||||
|
|
||||||
self.log_table.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
||||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
||||||
|
|
||||||
def on_log_event(self, event:LogData):
|
|
||||||
if self.logLevel.value <= event.level.value:
|
|
||||||
self.add_log(event.level, event.message)
|
|
||||||
|
|
||||||
|
|
||||||
def add_log(self, level:LogLevel, message):
|
|
||||||
"""添加日志记录 - 全局方法"""
|
|
||||||
from datetime import datetime
|
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
self.log_table.insert('', 0, values=(timestamp, level.name, message))
|
|
||||||
|
|
||||||
def clear_logs(self):
|
|
||||||
"""清空日志记录"""
|
|
||||||
# 删除所有日志项
|
|
||||||
for item in self.log_table.get_children():
|
|
||||||
self.log_table.delete(item)
|
|
||||||
|
|
||||||
def create_strategy_frames(self, strategy_names):
|
|
||||||
"""创建各个策略的Frame"""
|
|
||||||
for idx, name in enumerate(strategy_names):
|
|
||||||
if idx == 0:
|
|
||||||
# 第一个Tab使用TradeTargetUI,传入main_window引用
|
|
||||||
frame = TradeTargetUI(self.content_container)
|
|
||||||
self.strategy_frames[idx] = frame
|
|
||||||
else:
|
|
||||||
# 其他策略使用占位Frame
|
|
||||||
frame = ttk.Frame(self.content_container)
|
|
||||||
self.strategy_frames[idx] = frame
|
|
||||||
|
|
||||||
# 添加占位内容
|
|
||||||
placeholder = ttk.Label(
|
|
||||||
frame,
|
|
||||||
text=f"{name} - 策略界面将在此实现",
|
|
||||||
font=('Arial', 14),
|
|
||||||
foreground='gray'
|
|
||||||
)
|
|
||||||
placeholder.pack(expand=True)
|
|
||||||
|
|
||||||
def switch_strategy_tab(self, index):
|
|
||||||
"""切换策略Tab"""
|
|
||||||
# 隐藏当前Frame
|
|
||||||
if self.current_strategy_index in self.strategy_frames:
|
|
||||||
self.strategy_frames[self.current_strategy_index].pack_forget()
|
|
||||||
|
|
||||||
# 更新当前索引
|
|
||||||
self.current_strategy_index = index
|
|
||||||
|
|
||||||
# 显示选中的Frame
|
|
||||||
if index in self.strategy_frames:
|
|
||||||
self.strategy_frames[index].pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# 更新Tab按钮样式(可选,用于视觉反馈)
|
|
||||||
self.update_tab_button_styles()
|
|
||||||
|
|
||||||
def update_tab_button_styles(self):
|
|
||||||
"""更新Tab按钮的样式以显示选中状态"""
|
|
||||||
# 重置所有按钮为普通书签样式
|
|
||||||
for i, btn in enumerate(self.tab_buttons):
|
|
||||||
if i == self.current_strategy_index:
|
|
||||||
btn.configure(style='SelectedBookmark.TButton') # 选中状态
|
|
||||||
else:
|
|
||||||
btn.configure(style='Bookmark.TButton') # 普通状态
|
|
||||||
|
|
||||||
def toggle_log_panel(self):
|
|
||||||
"""切换日志面板的显示/隐藏"""
|
|
||||||
if self.log_visible:
|
|
||||||
# 隐藏日志面板
|
|
||||||
self.log_frame.pack_forget()
|
|
||||||
self.log_visible = False
|
|
||||||
self.log_toggle_btn.config(text="📋") # 日志图标
|
|
||||||
else:
|
|
||||||
# 显示日志面板
|
|
||||||
self.log_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=(5, 0))
|
|
||||||
self.log_visible = True
|
|
||||||
self.log_toggle_btn.config(text="🔽") # 使用不同图标表示隐藏
|
|
||||||
|
|
||||||
def on_exit(self):
|
|
||||||
"""退出程序"""
|
|
||||||
from tkinter import messagebox
|
|
||||||
result = messagebox.askyesno("确认退出", "确定要退出系统吗?")
|
|
||||||
if result:
|
|
||||||
self.root.destroy()
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
"""运行程序"""
|
|
||||||
self.root.mainloop()
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
from qmt import QmtV
|
|
||||||
from eventbus import marketDataEventBus
|
|
||||||
|
|
||||||
qmtv:QmtV = None
|
|
||||||
|
|
||||||
def init_qmtv():
|
|
||||||
global qmtv
|
|
||||||
qmtv = QmtV()
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import datetime
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import config
|
|
||||||
from xtquant.xttype import StockAccount, XtOrder, XtOrderResponse, XtPosition, XtTrade
|
|
||||||
from xtquant.xttrader import XtQuantTrader
|
|
||||||
from xtquant.xttype import StockAccount
|
|
||||||
from core.logger import LogLevel, PrintLog
|
|
||||||
from xtquant.xttrader import XtQuantTrader, XtQuantTraderCallback
|
|
||||||
from xtquant.xttype import StockAccount
|
|
||||||
from xtquant import xtconstant, xtdata
|
|
||||||
from eventbus import marketDataEventBus, EventMarketActiveSwitch, MarketDataUpdate, MarketOrderCreated, MarketOrderTraded
|
|
||||||
|
|
||||||
class QmtV(XtQuantTraderCallback):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.xttrader: XtQuantTrader
|
|
||||||
self.inited: bool = False
|
|
||||||
self.details = {}
|
|
||||||
self.lastMarketDataUpdateTimestamp = time.time()
|
|
||||||
self.isMarketActive = False
|
|
||||||
self.refresh_thread = threading.Thread(target=self.marketStatusNotifier, daemon=True)
|
|
||||||
self.refresh_thread.start()
|
|
||||||
|
|
||||||
def getTrader(self) -> XtQuantTrader:
|
|
||||||
return self.xttrader
|
|
||||||
|
|
||||||
def init_qmtv(self):
|
|
||||||
sessionId= int(time.time())
|
|
||||||
self.xttrader = XtQuantTrader(config.miniQMTPath, sessionId)
|
|
||||||
xtdata.enable_hello = False
|
|
||||||
|
|
||||||
def connect(self) -> bool:
|
|
||||||
self.xttrader.register_callback(self)
|
|
||||||
self.xttrader.start()
|
|
||||||
self.xttrader.connect()
|
|
||||||
|
|
||||||
PrintLog(LogLevel.INFO, f'- [{'成功' if self.xttrader.connected else '失败'}]市场交易连接: {config.miniQMTPath}')
|
|
||||||
if self.xttrader.connected == False:
|
|
||||||
self.inited = False
|
|
||||||
return self.inited
|
|
||||||
else:
|
|
||||||
self.inited = True
|
|
||||||
|
|
||||||
self.account = StockAccount(config.miniQMTAccount, 'STOCK') # pyright: ignore[reportAssignmentType, reportAttributeAccessIssue]
|
|
||||||
PrintLog(LogLevel.INFO, f'- [成功]交易账号对象初始化完成, 账号: {config.miniQMTAccount}') # pyright: ignore[reportOptionalMemberAccess]
|
|
||||||
subscribe_result = self.xttrader.subscribe(self.account)
|
|
||||||
PrintLog(LogLevel.INFO, f'- [{'成功' if subscribe_result == 0 else '失败'}:{subscribe_result}]交易状态订阅')
|
|
||||||
if subscribe_result != 0:
|
|
||||||
self.inited = False
|
|
||||||
return self.inited
|
|
||||||
self.startMarketDataSubscription()
|
|
||||||
return self.inited
|
|
||||||
|
|
||||||
|
|
||||||
def getStockPosition(self, stock_code: str):
|
|
||||||
positions = self.xttrader.query_stock_positions(self.account)
|
|
||||||
if positions:
|
|
||||||
for temp in positions:
|
|
||||||
pos:XtPosition = temp
|
|
||||||
if pos.stock_code == stock_code:
|
|
||||||
return pos
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def queryPendingOrder(self, stock_code:str, tag: str) -> list[XtOrder]:
|
|
||||||
if stock_code == None or tag == None:
|
|
||||||
return []
|
|
||||||
orders = self.xttrader.query_stock_orders(self.account)
|
|
||||||
result = [order for order in orders if order.order_status == xtconstant.ORDER_REPORTED and order.stock_code == stock_code and order.strategy_name == tag]
|
|
||||||
return result
|
|
||||||
|
|
||||||
def orderAsync(self, stock_code, orderVolume, orderType, orderPrice, priceType, orderRemark, strategy_name):
|
|
||||||
return self.xttrader.order_stock_async(
|
|
||||||
self.account,
|
|
||||||
str(stock_code),
|
|
||||||
orderType,
|
|
||||||
orderVolume,
|
|
||||||
priceType,
|
|
||||||
orderPrice,
|
|
||||||
strategy_name, # strategy_name
|
|
||||||
orderRemark # remark # type: ignore
|
|
||||||
)
|
|
||||||
|
|
||||||
def cacheStockDetail(self, stock_code:str):
|
|
||||||
if stock_code in self.details:
|
|
||||||
return self.details[stock_code]
|
|
||||||
else:
|
|
||||||
self.details[stock_code] = xtdata.get_instrument_detail(stock_code, False)
|
|
||||||
return self.details[stock_code]
|
|
||||||
|
|
||||||
def getInstrumentName(self, stock_code:str):
|
|
||||||
return self.cacheStockDetail(stock_code)['InstrumentName']
|
|
||||||
|
|
||||||
def dailyUpStop(self, stock_code:str):
|
|
||||||
cacheStock = self.cacheStockDetail(stock_code)
|
|
||||||
PrintLog(LogLevel.INFO, f'- [成功]获取股票详情: {stock_code} {cacheStock["InstrumentName"]} {cacheStock["UpStopPrice"]}')
|
|
||||||
return cacheStock['UpStopPrice']
|
|
||||||
|
|
||||||
def dailyDownStop(self, stock_code:str):
|
|
||||||
return self.cacheStockDetail(stock_code)['DownStopPrice']
|
|
||||||
|
|
||||||
# ========================================#
|
|
||||||
def startMarketDataSubscription(self):
|
|
||||||
try:
|
|
||||||
self.subscriptionId = xtdata.subscribe_whole_quote(['SH', 'SZ'], self.onDataUpdate)
|
|
||||||
|
|
||||||
PrintLog(LogLevel.INFO, f'- [市场数据订阅成功-{self.subscriptionId}]')
|
|
||||||
except Exception as e:
|
|
||||||
PrintLog(LogLevel.ERROR, f'- [市场数据订阅失败-{e}]')
|
|
||||||
|
|
||||||
def stopMarketDataSubscription(self):
|
|
||||||
PrintLog(LogLevel.INFO, '- 停止市场数据订阅')
|
|
||||||
|
|
||||||
if self.subscriptionId is not None and self.subscriptionId > 0:
|
|
||||||
xtdata.unsubscribe_quote(self.subscriptionId)
|
|
||||||
|
|
||||||
# ====== 市场回调方法 -- 以下方法由XtQuantData调用 ======
|
|
||||||
def onDataUpdate(self, data):
|
|
||||||
# 收集所有市场数据用于市场监控
|
|
||||||
marketDataEventBus.publish(marketDataEventBus.MarketDataUpdate, data)
|
|
||||||
now = time.time()
|
|
||||||
if now - self.lastMarketDataUpdateTimestamp < 5:
|
|
||||||
self.isMarketActive = True
|
|
||||||
self.lastMarketDataUpdateTimestamp = now
|
|
||||||
|
|
||||||
def marketStatusNotifier(self):
|
|
||||||
# 市场状态通知器
|
|
||||||
tmpMarketStatus = False
|
|
||||||
while True:
|
|
||||||
tmpTime = time.time()
|
|
||||||
time.sleep(10)
|
|
||||||
if tmpMarketStatus != self.isMarketActive and tmpTime - self.lastMarketDataUpdateTimestamp < 5:
|
|
||||||
tmpMarketStatus = self.isMarketActive
|
|
||||||
PrintLog(LogLevel.INFO, f'- [市场状态变更] {self.isMarketActive}')
|
|
||||||
marketDataEventBus.publish(EventMarketActiveSwitch, self.isMarketActive)
|
|
||||||
if tmpMarketStatus and self.isMarketActive and tmpTime - self.lastMarketDataUpdateTimestamp > 10: # 上次更新市场状态已经超过10秒
|
|
||||||
self.isMarketActive = False
|
|
||||||
PrintLog(LogLevel.INFO, f'- [市场状态变更] {self.isMarketActive}')
|
|
||||||
|
|
||||||
PrintLog(LogLevel.DEBUG, f'- [市场状态] {self.isMarketActive}') # 市场已 inactive
|
|
||||||
|
|
||||||
|
|
||||||
# ====== 市场回调方法 -- 以下方法由XtQuantTrader调用 ======
|
|
||||||
def on_connected(self):
|
|
||||||
"""
|
|
||||||
连接成功推送
|
|
||||||
"""
|
|
||||||
print(datetime.datetime.now(), '连接成功回调')
|
|
||||||
|
|
||||||
def on_disconnected(self):
|
|
||||||
"""
|
|
||||||
连接断开
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
print(datetime.datetime.now(), '连接断开回调')
|
|
||||||
|
|
||||||
def on_stock_order(self, order:XtOrder):
|
|
||||||
"""
|
|
||||||
委托回报推送
|
|
||||||
:param order: XtOrder对象
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
# print(f"委托回调 on_stock_order 投资备注 {order.order_id} {order.strategy_name} {order.order_remark}")
|
|
||||||
|
|
||||||
|
|
||||||
def on_stock_trade(self, trade:XtTrade):
|
|
||||||
"""
|
|
||||||
成交变动推送
|
|
||||||
:param trade: XtTrade对象
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
marketDataEventBus.publish(MarketOrderTraded, trade)
|
|
||||||
# stockCode = trade.stock_code
|
|
||||||
# ctrl:SFGridStrategy = self.stock_trade_ctrl[stockCode]
|
|
||||||
# # 如果存在对应的StockTradeController,则调用其onDataUpdate方法
|
|
||||||
# if ctrl is not None and trade.strategy_name == ctrl.getName():
|
|
||||||
# ctrl.onOrderTrade(trade)
|
|
||||||
# else:
|
|
||||||
# print(f"委托回调 投资备注 {trade.strategy_name} 不匹配 {ctrl.getName()}")
|
|
||||||
|
|
||||||
def on_order_stock_async_response(self, response:XtOrderResponse):
|
|
||||||
# print(f"委托回调 on_order_stock_async_response 投资备注 {response.order_id} {response.seq} {response.error_msg}{response.strategy_name} {response.order_remark}")
|
|
||||||
marketDataEventBus.publish(MarketOrderCreated, response)
|
|
||||||
|
|
||||||
# stockCode = response.order_remark
|
|
||||||
# ctrl:SFGridStrategy = self.stock_trade_ctrl[stockCode]
|
|
||||||
# # 如果存在对应的StockTradeController,则调用其onDataUpdate方法
|
|
||||||
# if ctrl is not None and response.strategy_name == ctrl.getName():
|
|
||||||
# ctrl.onAsyncOrderResponse(response)
|
|
||||||
# else:
|
|
||||||
# print(f"委托回调 投资备注 {response.strategy_name} 不匹配 {ctrl.getName()}")
|
|
||||||
|
|
||||||
def on_order_error(self, order_error):
|
|
||||||
"""
|
|
||||||
委托失败推送
|
|
||||||
:param order_error:XtOrderError 对象
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
print(f"\n委托报错回调 {order_error.order_remark} {order_error.error_msg}")
|
|
||||||
|
|
||||||
|
|
||||||
def on_account_status(self, status):
|
|
||||||
"""
|
|
||||||
:param response: XtAccountStatus 对象
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
print(datetime.datetime.now(), status)
|
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from peewee import CharField, DateField
|
|
||||||
|
|
||||||
from core.database import BaseModel, db
|
|
||||||
|
|
||||||
class StockInfo(BaseModel):
|
|
||||||
stock_code = CharField(unique=True, primary_key=True)
|
|
||||||
stock_name = CharField()
|
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
QMT 模块统一入口
|
||||||
|
根据配置或环境自动选择真实 QMT 或模拟器
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import config as _config
|
||||||
|
|
||||||
|
|
||||||
|
def _get_qmt():
|
||||||
|
"""获取 QMT 模块(配置优先于平台检测)"""
|
||||||
|
if _config.use_simulated_qmt:
|
||||||
|
print('[qmt] 配置指定模拟模式 → qmt_dummy')
|
||||||
|
from core.qmt_dummy import qmtv
|
||||||
|
return qmtv
|
||||||
|
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
try:
|
||||||
|
print('[qmt] Windows 平台,尝试加载 qmt_real...')
|
||||||
|
from core.qmt_real import qmtv as real_qmtv
|
||||||
|
print('[qmt] qmt_real 加载成功')
|
||||||
|
return real_qmtv
|
||||||
|
except ImportError as e:
|
||||||
|
print(f'[qmt] qmt_real 加载失败: {e},回退 qmt_dummy')
|
||||||
|
|
||||||
|
# 非 Windows 或导入失败,使用模拟器
|
||||||
|
print('[qmt] 使用模拟模式 qmt_dummy')
|
||||||
|
from core.qmt_dummy import qmtv
|
||||||
|
return qmtv
|
||||||
|
|
||||||
|
|
||||||
|
# 导出单例
|
||||||
|
qmtv = _get_qmt()
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"""
|
||||||
|
Dummy QMT 模拟器 - 用于在非 Windows 环境下模拟 QMT 交易功能
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import config
|
||||||
|
import core.eventbus as eBus
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
|
||||||
|
class DummyPosition:
|
||||||
|
"""模拟持仓"""
|
||||||
|
def __init__(self, stock_code, stock_name, volume, yesterday_vol=0):
|
||||||
|
self.stock_code = stock_code
|
||||||
|
self.stock_name = stock_name
|
||||||
|
self.volume = volume
|
||||||
|
self.can_use_volume = volume
|
||||||
|
self.yesterday_volume = yesterday_vol
|
||||||
|
|
||||||
|
class DummyOrder:
|
||||||
|
"""模拟订单"""
|
||||||
|
def __init__(self, stock_code, order_id, status, price, volume):
|
||||||
|
self.stock_code = stock_code
|
||||||
|
self.order_id = order_id
|
||||||
|
self.order_status = status
|
||||||
|
self.order_price = price
|
||||||
|
self.volume = volume
|
||||||
|
|
||||||
|
class DummyTrade:
|
||||||
|
"""模拟成交"""
|
||||||
|
def __init__(self, stock_code, trade_id, price, volume, strategy_name):
|
||||||
|
self.stock_code = stock_code
|
||||||
|
self.trade_id = trade_id
|
||||||
|
self.trade_price = price
|
||||||
|
self.trade_volume = volume
|
||||||
|
self.strategy_name = strategy_name
|
||||||
|
|
||||||
|
class DummyOrderResponse:
|
||||||
|
"""模拟下单响应"""
|
||||||
|
def __init__(self, order_id, stock_code, seq, error_msg, strategy_name):
|
||||||
|
self.order_id = order_id
|
||||||
|
self.stock_code = stock_code
|
||||||
|
self.seq = seq
|
||||||
|
self.error_msg = error_msg
|
||||||
|
self.strategy_name = strategy_name
|
||||||
|
|
||||||
|
class DummyQmtV:
|
||||||
|
"""
|
||||||
|
Dummy QMT 模拟器
|
||||||
|
模拟 QmtV 类的接口,用于在没有 miniQMT 的环境下运行和测试
|
||||||
|
"""
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.inited = False
|
||||||
|
self.details = {}
|
||||||
|
self.lastMarketDataUpdateTimestamp = time.time()
|
||||||
|
self.isMarketActive = True
|
||||||
|
self.connected = False
|
||||||
|
self.account = None
|
||||||
|
self._positions = {}
|
||||||
|
self._pending_orders = []
|
||||||
|
self._market_data_thread = None
|
||||||
|
self._counter = 0
|
||||||
|
|
||||||
|
def getTrader(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def init_qmtv(self):
|
||||||
|
"""初始化交易器"""
|
||||||
|
PrintLog(LogLevel.INFO, f'- [模拟] QMT 交易器初始化')
|
||||||
|
self.connected = True
|
||||||
|
self.inited = True
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""连接 QMT (模拟总是成功)"""
|
||||||
|
PrintLog(LogLevel.INFO, f'- [成功] 市场交易连接 (模拟模式)')
|
||||||
|
|
||||||
|
# 创建模拟账号
|
||||||
|
try:
|
||||||
|
from xtquant.xttype import StockAccount
|
||||||
|
self.account = StockAccount(config.account_no, 'STOCK')
|
||||||
|
except ImportError:
|
||||||
|
self.account = type('StockAccount', (), {'account_id': config.account_no})()
|
||||||
|
PrintLog(LogLevel.INFO, f'- [成功] 交易账号: {config.account_no}')
|
||||||
|
|
||||||
|
self._init_dummy_positions()
|
||||||
|
self.startMarketDataSubscription()
|
||||||
|
|
||||||
|
return self.inited
|
||||||
|
|
||||||
|
def _init_dummy_positions(self):
|
||||||
|
"""初始化模拟持仓数据"""
|
||||||
|
dummy_stocks = [
|
||||||
|
('600519', '贵州茅台', 100, 2800.0),
|
||||||
|
('000858', '五粮液', 200, 180.0),
|
||||||
|
('600036', '招商银行', 500, 42.0),
|
||||||
|
('000001', '平安银行', 300, 13.5),
|
||||||
|
]
|
||||||
|
for code, name, volume, price in dummy_stocks:
|
||||||
|
self._positions[code] = {
|
||||||
|
'stock_code': code,
|
||||||
|
'stock_name': name,
|
||||||
|
'volume': volume,
|
||||||
|
'can_use_volume': volume,
|
||||||
|
'open_cost': price,
|
||||||
|
'market_value': volume * price
|
||||||
|
}
|
||||||
|
PrintLog(LogLevel.INFO, f'- [模拟] 已加载 {len(self._positions)} 个持仓')
|
||||||
|
|
||||||
|
def getAllPositions(self) -> dict:
|
||||||
|
"""获取全部持仓,返回 {stock_code: position_object}"""
|
||||||
|
result = {}
|
||||||
|
for code, pos_data in self._positions.items():
|
||||||
|
result[code] = type('DummyPos', (), pos_data)()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def getStockPosition(self, stock_code: str):
|
||||||
|
"""获取持仓 (模拟)"""
|
||||||
|
if stock_code in self._positions:
|
||||||
|
pos = self._positions[stock_code]
|
||||||
|
return type('DummyPos', (), pos)()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def queryTodayOrders(self) -> list:
|
||||||
|
"""查询当日所有委托 (模拟)"""
|
||||||
|
return list(self._pending_orders)
|
||||||
|
|
||||||
|
def queryTodayTrades(self) -> list:
|
||||||
|
"""查询当日所有成交 (模拟)"""
|
||||||
|
return [] # 模拟模式无实际成交记录
|
||||||
|
|
||||||
|
def queryPendingOrder(self, stock_code: str, tag: str) -> list:
|
||||||
|
"""查询挂单"""
|
||||||
|
return [o for o in self._pending_orders
|
||||||
|
if o.stock_code == stock_code and
|
||||||
|
(tag is None or getattr(o, 'strategy_name', None) == tag)]
|
||||||
|
|
||||||
|
def orderAsync(self, stock_code, orderVolume, orderType, orderPrice, priceType, orderRemark, strategy_name):
|
||||||
|
"""异步下单 (模拟)"""
|
||||||
|
self._counter += 1
|
||||||
|
order_id = f"DUMMY{self._counter:06d}"
|
||||||
|
seq = self._counter
|
||||||
|
|
||||||
|
order = DummyOrder(
|
||||||
|
stock_code=stock_code,
|
||||||
|
order_id=order_id,
|
||||||
|
status='reported',
|
||||||
|
price=orderPrice,
|
||||||
|
volume=orderVolume
|
||||||
|
)
|
||||||
|
order.strategy_name = strategy_name
|
||||||
|
order.order_remark = orderRemark
|
||||||
|
self._pending_orders.append(order)
|
||||||
|
|
||||||
|
response = DummyOrderResponse(
|
||||||
|
order_id=order_id,
|
||||||
|
stock_code=stock_code,
|
||||||
|
seq=seq,
|
||||||
|
error_msg='成功',
|
||||||
|
strategy_name=strategy_name
|
||||||
|
)
|
||||||
|
response.order_remark = orderRemark
|
||||||
|
|
||||||
|
eBus.event_bus.publish(eBus.MarketOrderCreated, response)
|
||||||
|
PrintLog(LogLevel.INFO, f'- [模拟下单] {stock_code} 数量:{orderVolume} 价格:{orderPrice} 订单号:{order_id}')
|
||||||
|
|
||||||
|
# 模拟成交 (80% 概率)
|
||||||
|
if random.random() > 0.2:
|
||||||
|
threading.Timer(random.uniform(0.5, 3.0), self._simulate_trade,
|
||||||
|
args=(stock_code, order_id, orderPrice, orderVolume, strategy_name)).start()
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _simulate_trade(self, stock_code, order_id, price, volume, strategy_name):
|
||||||
|
"""模拟成交"""
|
||||||
|
trade = DummyTrade(
|
||||||
|
stock_code=stock_code,
|
||||||
|
trade_id=f"TRADE{self._counter:06d}",
|
||||||
|
price=price,
|
||||||
|
volume=volume,
|
||||||
|
strategy_name=strategy_name
|
||||||
|
)
|
||||||
|
trade.trade_time = int(time.strftime('%H%M%S'))
|
||||||
|
trade.order_remark = stock_code
|
||||||
|
|
||||||
|
if stock_code in self._positions:
|
||||||
|
self._positions[stock_code]['volume'] += volume
|
||||||
|
self._positions[stock_code]['can_use_volume'] += volume
|
||||||
|
|
||||||
|
eBus.event_bus.publish(eBus.MarketOrderTraded, trade)
|
||||||
|
PrintLog(LogLevel.INFO, f'- [模拟成交] {stock_code} 数量:{volume} 价格:{price}')
|
||||||
|
|
||||||
|
def cacheStockDetail(self, stock_code: str):
|
||||||
|
"""获取股票详情 (模拟)"""
|
||||||
|
if stock_code not in self.details:
|
||||||
|
self.details[stock_code] = {
|
||||||
|
'InstrumentName': self._get_dummy_name(stock_code),
|
||||||
|
'UpStopPrice': 0,
|
||||||
|
'DownStopPrice': 0
|
||||||
|
}
|
||||||
|
return self.details[stock_code]
|
||||||
|
|
||||||
|
def _get_dummy_name(self, stock_code: str) -> str:
|
||||||
|
"""获取模拟股票名称"""
|
||||||
|
names = {
|
||||||
|
'600519': '贵州茅台', '000858': '五粮液', '600036': '招商银行',
|
||||||
|
'000001': '平安银行', '000002': '万科A', '600000': '浦发银行'
|
||||||
|
}
|
||||||
|
return names.get(stock_code, f'股票{stock_code}')
|
||||||
|
|
||||||
|
def getInstrumentName(self, stock_code: str) -> str:
|
||||||
|
"""获取股票名称"""
|
||||||
|
return self.cacheStockDetail(stock_code)['InstrumentName']
|
||||||
|
|
||||||
|
def dailyUpStop(self, stock_code: str):
|
||||||
|
"""获取涨停价 (模拟)"""
|
||||||
|
cacheStock = self.cacheStockDetail(stock_code)
|
||||||
|
PrintLog(LogLevel.INFO, f'- [模拟] 获取股票详情: {stock_code} {cacheStock["InstrumentName"]} 涨停价: 0')
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def dailyDownStop(self, stock_code: str):
|
||||||
|
"""获取跌停价 (模拟)"""
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def getLastPrice(self, stock_code: str) -> float:
|
||||||
|
"""主动获取最新市价(模拟)"""
|
||||||
|
if stock_code in self._positions:
|
||||||
|
return float(self._positions[stock_code].get('open_cost', 10.0))
|
||||||
|
# 给一个合理模拟价
|
||||||
|
return 10.0 + hash(stock_code) % 100
|
||||||
|
|
||||||
|
def startMarketDataSubscription(self):
|
||||||
|
"""启动市场数据订阅 (模拟)"""
|
||||||
|
try:
|
||||||
|
self._market_data_thread = threading.Thread(target=self._generate_market_data, daemon=True)
|
||||||
|
self._market_data_thread.start()
|
||||||
|
PrintLog(LogLevel.INFO, f'- [市场数据订阅成功-模拟]')
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'- [市场数据订阅失败-{e}]')
|
||||||
|
|
||||||
|
def stopMarketDataSubscription(self):
|
||||||
|
"""停止市场数据订阅"""
|
||||||
|
PrintLog(LogLevel.INFO, '- 停止市场数据订阅 (模拟)')
|
||||||
|
|
||||||
|
def _is_trading_time(self) -> bool:
|
||||||
|
import zoneinfo
|
||||||
|
beijing_tz = zoneinfo.ZoneInfo('Asia/Shanghai')
|
||||||
|
now = datetime.datetime.now(beijing_tz)
|
||||||
|
if now.weekday() >= 5:
|
||||||
|
return False
|
||||||
|
t = now.time()
|
||||||
|
return (
|
||||||
|
datetime.time(9, 30) <= t <= datetime.time(11, 30) or
|
||||||
|
datetime.time(13, 0) <= t <= datetime.time(15, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_market_data(self):
|
||||||
|
"""生成模拟市场数据"""
|
||||||
|
stocks = ['600519', '000858', '600036', '000001', '000002', '600000']
|
||||||
|
base_prices = [2800.0, 180.0, 42.0, 13.5, 10.0, 10.0]
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
for i, stock in enumerate(stocks):
|
||||||
|
data = {
|
||||||
|
'stock_code': stock,
|
||||||
|
'last_price': base_prices[i] + random.uniform(-1, 1),
|
||||||
|
'open_price': base_prices[i],
|
||||||
|
'high_price': base_prices[i] + random.uniform(0, 2),
|
||||||
|
'low_price': base_prices[i] - random.uniform(0, 2),
|
||||||
|
'volume': random.randint(1000, 10000),
|
||||||
|
'timestamp': time.time()
|
||||||
|
}
|
||||||
|
eBus.event_bus.publish(eBus.MarketDataUpdate, data)
|
||||||
|
base_prices[i] = data['last_price']
|
||||||
|
|
||||||
|
self.lastMarketDataUpdateTimestamp = time.time()
|
||||||
|
if self._is_trading_time():
|
||||||
|
self.isMarketActive = True
|
||||||
|
eBus.event_bus.publish(eBus.EventMarketActiveSwitch, True)
|
||||||
|
else:
|
||||||
|
self.isMarketActive = False
|
||||||
|
eBus.event_bus.publish(eBus.EventMarketActiveSwitch, False)
|
||||||
|
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'- [市场数据模拟异常-{e}]')
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
def on_connected(self):
|
||||||
|
print(datetime.datetime.now(), '模拟连接成功')
|
||||||
|
|
||||||
|
def on_disconnected(self):
|
||||||
|
print(datetime.datetime.now(), '模拟连接断开')
|
||||||
|
|
||||||
|
def on_stock_order(self, order):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_stock_trade(self, trade):
|
||||||
|
eBus.event_bus.publish(eBus.MarketOrderTraded, trade)
|
||||||
|
|
||||||
|
def on_order_stock_async_response(self, response):
|
||||||
|
eBus.event_bus.publish(eBus.MarketOrderCreated, response)
|
||||||
|
|
||||||
|
def on_order_error(self, order_error):
|
||||||
|
print(f"\n模拟委托报错回调: order_id={order_error.order_id}, error_id={order_error.error_id}, error_msg={order_error.error_msg}, remark={order_error.order_remark}")
|
||||||
|
eBus.event_bus.publish(eBus.MarketOrderError, order_error)
|
||||||
|
|
||||||
|
def on_account_status(self, status):
|
||||||
|
print(datetime.datetime.now(), status)
|
||||||
|
|
||||||
|
|
||||||
|
qmtv = DummyQmtV()
|
||||||
@@ -0,0 +1,648 @@
|
|||||||
|
"""
|
||||||
|
QMT 真实交易实现 - 封装 xtquant SDK
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import config
|
||||||
|
import core.eventbus as eBus
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
|
||||||
|
|
||||||
|
class RealQmtV:
|
||||||
|
"""
|
||||||
|
真实 QMT 交易器
|
||||||
|
封装 xtquant 的 XtQuantTrader,提供与模拟器一致的接口
|
||||||
|
"""
|
||||||
|
|
||||||
|
# miniQMT 进程名关键字(GUI 壳: XtMiniQmt.exe,交易引擎: miniquote.exe)
|
||||||
|
_QMT_PROCESS_KEYWORDS = ['Qmt', 'qmt', 'QMT', 'miniquote', 'MiniQuote']
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _discover_qmt_port() -> int:
|
||||||
|
"""
|
||||||
|
自动探测 miniQMT 监听端口。
|
||||||
|
方法1: SDK 内部扫描 (读取配置)
|
||||||
|
方法2: netstat 找 LISTENING 端口 → 反查所属进程名 → 匹配 QMT 关键字
|
||||||
|
返回端口号,未找到返回 0。
|
||||||
|
"""
|
||||||
|
# ---- 方法1: SDK 内部扫描 ----
|
||||||
|
try:
|
||||||
|
from xtquant import xtconn
|
||||||
|
addrs = xtconn.scan_available_server_addr()
|
||||||
|
for addr in addrs:
|
||||||
|
try:
|
||||||
|
port = int(addr.split(':')[1])
|
||||||
|
if port:
|
||||||
|
PrintLog(LogLevel.INFO, f'[端口探测] SDK 扫描发现端口: {port}')
|
||||||
|
return port
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.DEBUG, f'[端口探测] SDK 扫描异常: {e}')
|
||||||
|
|
||||||
|
# ---- 方法2: netstat → 反向查进程名 ----
|
||||||
|
try:
|
||||||
|
# 2a. netstat 找出所有 LISTENING 端口的 PID
|
||||||
|
pid_ports = {} # pid -> [port, ...]
|
||||||
|
netstat = subprocess.run(
|
||||||
|
['netstat', '-ano'],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
for line in netstat.stdout.splitlines():
|
||||||
|
if 'LISTENING' not in line and 'LISTEN' not in line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 5:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
local_addr = parts[1]
|
||||||
|
port = int(local_addr.rsplit(':', 1)[-1])
|
||||||
|
pid = int(parts[-1])
|
||||||
|
if port > 0:
|
||||||
|
pid_ports.setdefault(pid, []).append(port)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not pid_ports:
|
||||||
|
PrintLog(LogLevel.DEBUG, '[端口探测] netstat 未找到任何 LISTENING 端口')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 2b. 对每个有监听端口的 PID,查进程名是否匹配 QMT
|
||||||
|
for pid, ports in pid_ports.items():
|
||||||
|
name = RealQmtV._get_process_name(pid)
|
||||||
|
if name and any(kw in name for kw in RealQmtV._QMT_PROCESS_KEYWORDS):
|
||||||
|
port = ports[0]
|
||||||
|
PrintLog(LogLevel.INFO, f'[端口探测] 发现 QMT 进程: {name} (PID={pid}), 端口: {port}')
|
||||||
|
|
||||||
|
# 同时探测 userdata_mini 路径
|
||||||
|
exe_path = RealQmtV._get_process_exe_path(pid)
|
||||||
|
if exe_path:
|
||||||
|
PrintLog(LogLevel.INFO, f'[路径探测] 进程路径: {exe_path}')
|
||||||
|
found_path = RealQmtV._find_userdata_mini(exe_path)
|
||||||
|
if found_path:
|
||||||
|
PrintLog(LogLevel.INFO, f'[路径探测] 发现 userdata_mini: {found_path}')
|
||||||
|
if found_path != config.miniQMTPath:
|
||||||
|
PrintLog(LogLevel.INFO, f'[路径探测] 自动修正 miniQMTPath: {config.miniQMTPath} -> {found_path}')
|
||||||
|
config.miniQMTPath = found_path
|
||||||
|
|
||||||
|
# 同时从窗口标题提取资金账号
|
||||||
|
account = RealQmtV._discover_account()
|
||||||
|
if account:
|
||||||
|
if account != config.account_no:
|
||||||
|
PrintLog(LogLevel.INFO, f'[账号探测] 自动修正 account_no: {config.account_no[-4:]}**** -> {account[-4:]}****')
|
||||||
|
config.account_no = account
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.INFO, f'[账号探测] 确认账号: {account[-4:]}****')
|
||||||
|
|
||||||
|
return port
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.INFO, f'[端口探测] 进程扫描异常: {e}')
|
||||||
|
|
||||||
|
PrintLog(LogLevel.WARNING, '[端口探测] 未能自动发现 miniQMT 端口')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_process_name(pid: int) -> str:
|
||||||
|
"""通过 PID 获取进程名(单个查询,不用扫全量 tasklist)"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['tasklist', '/fi', f'PID eq {pid}', '/fo', 'csv', '/nh'],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('INFO:'):
|
||||||
|
continue
|
||||||
|
parts = [p.strip('"').strip() for p in line.split('","')]
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return parts[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_process_exe_path(pid: int) -> str:
|
||||||
|
"""通过 PID 获取进程的可执行文件完整路径"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['powershell', '-NoProfile', '-Command',
|
||||||
|
f'(Get-Process -Id {pid}).Path'],
|
||||||
|
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5
|
||||||
|
)
|
||||||
|
path = result.stdout.strip()
|
||||||
|
if path and os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_userdata_mini(exe_path: str) -> str:
|
||||||
|
"""从 QMT 可执行文件路径向上查找 userdata_mini 目录"""
|
||||||
|
exe_dir = os.path.dirname(exe_path)
|
||||||
|
# 从 exe 所在目录开始,向上最多 3 层
|
||||||
|
for _ in range(4):
|
||||||
|
candidate = os.path.join(exe_dir, 'userdata_mini')
|
||||||
|
if os.path.isdir(candidate):
|
||||||
|
return candidate
|
||||||
|
parent = os.path.dirname(exe_dir)
|
||||||
|
if parent == exe_dir:
|
||||||
|
break
|
||||||
|
exe_dir = parent
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _discover_account() -> str:
|
||||||
|
"""
|
||||||
|
从 XtMiniQmt.exe 的窗口标题中提取资金账号。
|
||||||
|
标题格式: "8882874667 - 国金证券QMT交易端 2.0.8.300"
|
||||||
|
返回账号字符串,失败返回空字符串。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 找到 XtMiniQmt.exe 的 PID
|
||||||
|
tasklist = subprocess.run(
|
||||||
|
['tasklist', '/fo', 'csv', '/nh'],
|
||||||
|
capture_output=True, text=True, timeout=10
|
||||||
|
)
|
||||||
|
gui_pid = 0
|
||||||
|
for line in tasklist.stdout.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = [p.strip('"').strip() for p in line.split('","')]
|
||||||
|
if len(parts) >= 2 and 'XtMiniQmt' in parts[0]:
|
||||||
|
gui_pid = int(parts[1])
|
||||||
|
break
|
||||||
|
|
||||||
|
if not gui_pid:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# 获取窗口标题(PowerShell 输出可能含中文,用 utf-8)
|
||||||
|
result = subprocess.run(
|
||||||
|
['powershell', '-NoProfile', '-Command',
|
||||||
|
f'(Get-Process -Id {gui_pid}).MainWindowTitle'],
|
||||||
|
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5
|
||||||
|
)
|
||||||
|
title = result.stdout.strip()
|
||||||
|
if title and ' - ' in title:
|
||||||
|
account = title.split(' - ')[0].strip()
|
||||||
|
if account.isdigit():
|
||||||
|
return account
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_plain_code(stock_code: str) -> str:
|
||||||
|
"""将 xtquant 格式 '600519.SH' 转换为数据库格式 '600519'"""
|
||||||
|
return stock_code.split('.')[0] if '.' in stock_code else stock_code
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_full_code(stock_code: str) -> str:
|
||||||
|
"""将数据库格式 '600519' 转换为 xtquant 格式 '600519.SH'"""
|
||||||
|
if '.' in stock_code:
|
||||||
|
return stock_code # already has suffix
|
||||||
|
code = stock_code
|
||||||
|
if code.startswith(('6', '5', '9')):
|
||||||
|
return f'{code}.SH'
|
||||||
|
elif code.startswith(('0', '3', '2')):
|
||||||
|
return f'{code}.SZ'
|
||||||
|
# fallback: try both, prefer SH
|
||||||
|
return f'{code}.SH'
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.inited = False
|
||||||
|
self.connected = False
|
||||||
|
self.account = None
|
||||||
|
self.xt_trader = None
|
||||||
|
self.mini_qmt_path = ""
|
||||||
|
self._positions = {}
|
||||||
|
self._pending_orders = []
|
||||||
|
self._market_data_thread = None
|
||||||
|
self.isMarketActive = False
|
||||||
|
self.lastMarketDataUpdateTimestamp = time.time()
|
||||||
|
self.details = {}
|
||||||
|
|
||||||
|
def getTrader(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def init_qmtv(self):
|
||||||
|
"""初始化 QMT 交易器"""
|
||||||
|
try:
|
||||||
|
from xtquant.xttrader import XtQuantTrader
|
||||||
|
from xtquant.xttype import StockAccount
|
||||||
|
|
||||||
|
self.mini_qmt_path = config.miniQMTPath
|
||||||
|
self.account = StockAccount(config.account_no, 'STOCK')
|
||||||
|
PrintLog(LogLevel.INFO, f'[QMT] 初始化: path={self.mini_qmt_path}, account={config.account_no[-4:]}****')
|
||||||
|
|
||||||
|
# 创建 XtQuantTrader 实例
|
||||||
|
session_id = int(time.time()) % 10000
|
||||||
|
PrintLog(LogLevel.INFO, f'[QMT] 创建 XtQuantTrader, session={session_id}')
|
||||||
|
self.xt_trader = XtQuantTrader(self.mini_qmt_path, session_id)
|
||||||
|
|
||||||
|
# 注册回调 — xtquant 只接受一个回调对象,会在上面调用 on_xxx 方法
|
||||||
|
self.xt_trader.register_callback(self)
|
||||||
|
|
||||||
|
self.inited = True
|
||||||
|
PrintLog(LogLevel.INFO, f'- [真实] QMT 交易器初始化成功')
|
||||||
|
except Exception as e:
|
||||||
|
self.inited = False
|
||||||
|
PrintLog(LogLevel.ERROR, f'- [失败] QMT 初始化: {e}')
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""连接 MiniQMT,失败自动探测端口并重试"""
|
||||||
|
if not self.inited:
|
||||||
|
PrintLog(LogLevel.ERROR, '[QMT] 连接失败: 未初始化')
|
||||||
|
return False
|
||||||
|
|
||||||
|
_connect_errors = {
|
||||||
|
0: '成功',
|
||||||
|
-1: '一般错误(miniQMT 可能未启动)',
|
||||||
|
-2: 'miniQMT 未运行(请先启动极简QMT)',
|
||||||
|
-3: '连接超时',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _do_connect() -> int:
|
||||||
|
self.xt_trader.start()
|
||||||
|
PrintLog(LogLevel.INFO, '[QMT] xt_trader.start() 完成')
|
||||||
|
PrintLog(LogLevel.INFO, '[QMT] 正在连接 miniQMT...')
|
||||||
|
return self.xt_trader.connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 尝试默认连接
|
||||||
|
PrintLog(LogLevel.INFO, '[QMT] 尝试默认方式连接...')
|
||||||
|
connect_result = _do_connect()
|
||||||
|
|
||||||
|
# 失败则自动探测端口并重试
|
||||||
|
if connect_result != 0:
|
||||||
|
PrintLog(LogLevel.INFO, '[QMT] 默认连接失败,启动端口自动探测...')
|
||||||
|
discovered_port = self._discover_qmt_port()
|
||||||
|
if discovered_port > 0:
|
||||||
|
PrintLog(LogLevel.INFO, f'[QMT] 探测到端口 {discovered_port},尝试连接...')
|
||||||
|
try:
|
||||||
|
from xtquant import xtdata
|
||||||
|
xtdata.connect(ip='127.0.0.1', port=discovered_port)
|
||||||
|
PrintLog(LogLevel.INFO, f'[QMT] xtdata 连接成功 (端口: {discovered_port})')
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[QMT] xtdata 连接失败 (端口: {discovered_port}): {e}')
|
||||||
|
return False
|
||||||
|
connect_result = _do_connect()
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.WARNING, '[QMT] 端口自动探测未找到 miniQMT 进程')
|
||||||
|
|
||||||
|
result_desc = _connect_errors.get(connect_result, f'未知({connect_result})')
|
||||||
|
PrintLog(LogLevel.INFO, f'[QMT] connect() 返回: {connect_result} ({result_desc})')
|
||||||
|
|
||||||
|
if connect_result == 0:
|
||||||
|
PrintLog(LogLevel.INFO, f'[QMT] 订阅账户...')
|
||||||
|
self.xt_trader.subscribe(self.account)
|
||||||
|
PrintLog(LogLevel.INFO, '[QMT] 订阅完成')
|
||||||
|
self.connected = True
|
||||||
|
self.startMarketDataSubscription()
|
||||||
|
PrintLog(LogLevel.INFO, f'[QMT] 连接成功 (账号: {config.account_no[-4:]}****)')
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[QMT] 连接失败: {result_desc}')
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[QMT] 连接异常: {e}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
def getAllPositions(self) -> dict:
|
||||||
|
"""获取全部持仓,返回 {plain_code: position_object}"""
|
||||||
|
if not self.connected:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
positions = self.xt_trader.query_stock_positions(self.account)
|
||||||
|
result = {}
|
||||||
|
for pos in positions:
|
||||||
|
code = self._to_plain_code(getattr(pos, 'stock_code', ''))
|
||||||
|
result[code] = pos
|
||||||
|
# 缓存以供 getStockPosition 使用
|
||||||
|
self._position_cache = result
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'- [获取全部持仓失败]: {e}')
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def getStockPosition(self, stock_code: str):
|
||||||
|
"""获取单只股票持仓(优先使用缓存)"""
|
||||||
|
if not self.connected:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# 优先查缓存
|
||||||
|
if hasattr(self, '_position_cache') and stock_code in self._position_cache:
|
||||||
|
return self._position_cache[stock_code]
|
||||||
|
# 回退查询
|
||||||
|
positions = self.xt_trader.query_stock_positions(self.account)
|
||||||
|
for pos in positions:
|
||||||
|
pos_code = self._to_plain_code(getattr(pos, 'stock_code', ''))
|
||||||
|
if pos_code == stock_code:
|
||||||
|
return pos
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'- [持仓查询失败] {stock_code}: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def queryPendingOrder(self, stock_code: str, tag: str) -> list:
|
||||||
|
"""查询挂单(过滤已撤/废单)"""
|
||||||
|
if not self.connected:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
orders = self.xt_trader.query_stock_orders(self.account)
|
||||||
|
# 过滤已撤(54)和废单(57),避免策略误判"已有挂单"跳过下单
|
||||||
|
_CANCELED = {54, 57}
|
||||||
|
return [o for o in orders
|
||||||
|
if self._to_plain_code(getattr(o, 'stock_code', '')) == stock_code and
|
||||||
|
(tag is None or getattr(o, 'strategy_name', None) == tag) and
|
||||||
|
getattr(o, 'order_status', 0) not in _CANCELED]
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'- [查询挂单失败] {e}')
|
||||||
|
return []
|
||||||
|
|
||||||
|
def queryTodayOrders(self) -> list:
|
||||||
|
"""查询当日所有委托"""
|
||||||
|
if not self.connected:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.xt_trader.query_stock_orders(self.account))
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'- [查询委托失败] {e}')
|
||||||
|
return []
|
||||||
|
|
||||||
|
def queryTodayTrades(self) -> list:
|
||||||
|
"""查询当日所有成交"""
|
||||||
|
if not self.connected:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return list(self.xt_trader.query_stock_trades(self.account))
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'- [查询成交失败] {e}')
|
||||||
|
return []
|
||||||
|
|
||||||
|
def orderAsync(self, stock_code, orderVolume, orderType, orderPrice, priceType, orderRemark, strategy_name):
|
||||||
|
"""异步下单"""
|
||||||
|
if not self.connected:
|
||||||
|
PrintLog(LogLevel.ERROR, '- [下单失败] 未连接')
|
||||||
|
return -1
|
||||||
|
|
||||||
|
try:
|
||||||
|
full_code = self._to_full_code(stock_code)
|
||||||
|
seq = self.xt_trader.order_stock_async(
|
||||||
|
account=self.account,
|
||||||
|
stock_code=full_code,
|
||||||
|
order_volume=orderVolume,
|
||||||
|
order_type=orderType,
|
||||||
|
price=orderPrice,
|
||||||
|
price_type=priceType,
|
||||||
|
order_remark=orderRemark,
|
||||||
|
strategy_name=strategy_name
|
||||||
|
)
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'- [下单] {stock_code} 数量:{orderVolume} 价格:{orderPrice} 类型:{orderType} seq:{seq}')
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'- [下单失败] {stock_code}: {e}')
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def cacheStockDetail(self, stock_code: str):
|
||||||
|
"""获取股票详情"""
|
||||||
|
if stock_code not in self.details:
|
||||||
|
try:
|
||||||
|
from xtquant import xtdata
|
||||||
|
# xtquant 需要带后缀的完整代码
|
||||||
|
full_code = self._to_full_code(stock_code)
|
||||||
|
detail = xtdata.get_instrument_detail(full_code)
|
||||||
|
if detail:
|
||||||
|
# xtquant 返回 dict,使用 .get() 读取
|
||||||
|
self.details[stock_code] = {
|
||||||
|
'InstrumentName': detail.get('InstrumentName', stock_code) if isinstance(detail, dict) else getattr(detail, 'InstrumentName', stock_code),
|
||||||
|
'UpStopPrice': detail.get('UpStopPrice', 0) if isinstance(detail, dict) else getattr(detail, 'UpStopPrice', 0),
|
||||||
|
'DownStopPrice': detail.get('DownStopPrice', 0) if isinstance(detail, dict) else getattr(detail, 'DownStopPrice', 0)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
self.details[stock_code] = {
|
||||||
|
'InstrumentName': stock_code,
|
||||||
|
'UpStopPrice': 0,
|
||||||
|
'DownStopPrice': 0
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
self.details[stock_code] = {
|
||||||
|
'InstrumentName': stock_code,
|
||||||
|
'UpStopPrice': 0,
|
||||||
|
'DownStopPrice': 0
|
||||||
|
}
|
||||||
|
return self.details[stock_code]
|
||||||
|
|
||||||
|
def getInstrumentName(self, stock_code: str) -> str:
|
||||||
|
"""获取股票名称"""
|
||||||
|
return self.cacheStockDetail(stock_code)['InstrumentName']
|
||||||
|
|
||||||
|
def getInstrumentName_batch(self, stock_codes: list) -> dict:
|
||||||
|
"""批量获取股票名称,返回 {stock_code: name} dict"""
|
||||||
|
result = {}
|
||||||
|
missing = []
|
||||||
|
for code in stock_codes:
|
||||||
|
if code in self.details:
|
||||||
|
result[code] = self.details[code].get('InstrumentName', '')
|
||||||
|
else:
|
||||||
|
missing.append(code)
|
||||||
|
if not missing:
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
from xtquant import xtdata
|
||||||
|
for code in missing:
|
||||||
|
full_code = self._to_full_code(code)
|
||||||
|
detail = xtdata.get_instrument_detail(full_code)
|
||||||
|
if detail:
|
||||||
|
name = detail.get('instrumentName', detail.get('InstrumentName', ''))
|
||||||
|
self.details[code] = detail
|
||||||
|
result[code] = name
|
||||||
|
else:
|
||||||
|
result[code] = ''
|
||||||
|
except Exception:
|
||||||
|
for code in missing:
|
||||||
|
result[code] = ''
|
||||||
|
return result
|
||||||
|
|
||||||
|
def dailyUpStop(self, stock_code: str):
|
||||||
|
"""获取涨停价"""
|
||||||
|
detail = self.cacheStockDetail(stock_code)
|
||||||
|
up_stop = detail.get('UpStopPrice', 0)
|
||||||
|
PrintLog(LogLevel.DEBUG, f'- [详情] {stock_code} {detail["InstrumentName"]} 涨停价: {up_stop}')
|
||||||
|
return up_stop or 0.0
|
||||||
|
|
||||||
|
def dailyDownStop(self, stock_code: str):
|
||||||
|
"""获取跌停价"""
|
||||||
|
detail = self.cacheStockDetail(stock_code)
|
||||||
|
down_stop = detail.get('DownStopPrice', 0)
|
||||||
|
return down_stop or 0.0
|
||||||
|
|
||||||
|
def getLastPrice(self, stock_code: str) -> float:
|
||||||
|
"""主动获取最新市价(拉取模式,作为推送的兜底)"""
|
||||||
|
try:
|
||||||
|
from xtquant import xtdata
|
||||||
|
import json
|
||||||
|
full_code = self._to_full_code(stock_code)
|
||||||
|
|
||||||
|
# 方式1: 尝试 get_full_tick(参数是 list[str],返回 dict {code: {...}})
|
||||||
|
raw = xtdata.get_full_tick([full_code])
|
||||||
|
if raw:
|
||||||
|
tick = json.loads(raw) if isinstance(raw, str) else raw
|
||||||
|
if isinstance(tick, dict):
|
||||||
|
# 格式: {'600519.SH': {'lastPrice': 8.97, ...}}
|
||||||
|
for code, info in tick.items():
|
||||||
|
if isinstance(info, dict) and info.get('lastPrice', 0) > 0:
|
||||||
|
PrintLog(LogLevel.DEBUG, f'[getLastPrice] {stock_code} → tick: {info["lastPrice"]:.3f}')
|
||||||
|
return float(info['lastPrice'])
|
||||||
|
|
||||||
|
# 方式2: get_market_data 取最新1分钟K线收盘价
|
||||||
|
data = xtdata.get_market_data(
|
||||||
|
field_list=['close'],
|
||||||
|
stock_list=[full_code],
|
||||||
|
period='1m',
|
||||||
|
count=1
|
||||||
|
)
|
||||||
|
if data:
|
||||||
|
vals = None
|
||||||
|
if full_code in data:
|
||||||
|
row = data[full_code]
|
||||||
|
if hasattr(row, '__iter__') and not isinstance(row, str):
|
||||||
|
row = list(row)
|
||||||
|
if row:
|
||||||
|
vals = row
|
||||||
|
if not vals and 'close' in data:
|
||||||
|
field_data = data['close']
|
||||||
|
if full_code in field_data:
|
||||||
|
vals = list(field_data[full_code])
|
||||||
|
if vals and len(vals) > 0 and float(vals[0]) > 0:
|
||||||
|
PrintLog(LogLevel.DEBUG, f'[getLastPrice] {stock_code} → kline: {float(vals[0]):.3f}')
|
||||||
|
return float(vals[0])
|
||||||
|
|
||||||
|
# 方式3: 下载历史数据后再试
|
||||||
|
xtdata.download_history_data(full_code, '1m', '')
|
||||||
|
data = xtdata.get_market_data(
|
||||||
|
field_list=['close'],
|
||||||
|
stock_list=[full_code],
|
||||||
|
period='1m',
|
||||||
|
count=1
|
||||||
|
)
|
||||||
|
if data:
|
||||||
|
vals = None
|
||||||
|
if full_code in data:
|
||||||
|
row = data[full_code]
|
||||||
|
if hasattr(row, '__iter__') and not isinstance(row, str):
|
||||||
|
row = list(row)
|
||||||
|
if row:
|
||||||
|
vals = row
|
||||||
|
if not vals and 'close' in data:
|
||||||
|
field_data = data['close']
|
||||||
|
if full_code in field_data:
|
||||||
|
vals = list(field_data[full_code])
|
||||||
|
if vals and len(vals) > 0 and float(vals[0]) > 0:
|
||||||
|
PrintLog(LogLevel.DEBUG, f'[getLastPrice] {stock_code} → download+kline: {float(vals[0]):.3f}')
|
||||||
|
return float(vals[0])
|
||||||
|
|
||||||
|
PrintLog(LogLevel.DEBUG, f'[getLastPrice] {stock_code} → 失败: 所有方式均无数据, raw={raw}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.DEBUG, f'[getLastPrice] {stock_code} → 异常: {e}')
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def startMarketDataSubscription(self):
|
||||||
|
"""启动市场数据订阅"""
|
||||||
|
try:
|
||||||
|
from xtquant import xtdata
|
||||||
|
|
||||||
|
# 订阅沪深全市场实时行情
|
||||||
|
seq = xtdata.subscribe_whole_quote(['SH', 'SZ'], self._on_market_data)
|
||||||
|
PrintLog(LogLevel.INFO, f'- [市场数据订阅成功-真实] seq={seq}')
|
||||||
|
|
||||||
|
# 启动行情活跃监控线程(默认不活跃,收到行情后激活)
|
||||||
|
self._market_data_thread = threading.Thread(
|
||||||
|
target=self._market_data_watchdog, daemon=True
|
||||||
|
)
|
||||||
|
self._market_data_thread.start()
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'- [市场数据订阅失败-{e}]')
|
||||||
|
|
||||||
|
def _on_market_data(self, datas: dict):
|
||||||
|
"""xtquant 行情回调 — 收到行情即标记市场活跃(但需满足 09:15 后才激活)"""
|
||||||
|
self.lastMarketDataUpdateTimestamp = time.time()
|
||||||
|
if not self.isMarketActive:
|
||||||
|
# 检查当前时间是否已过 09:15(集合竞价结束后才激活市场状态)
|
||||||
|
import zoneinfo
|
||||||
|
beijing_tz = zoneinfo.ZoneInfo("Asia/Shanghai")
|
||||||
|
now = datetime.datetime.now(beijing_tz)
|
||||||
|
t = now.time()
|
||||||
|
activation_time = datetime.time(9, 15)
|
||||||
|
if t >= activation_time:
|
||||||
|
self.isMarketActive = True
|
||||||
|
eBus.event_bus.publish(eBus.EventMarketActiveSwitch, True)
|
||||||
|
PrintLog(LogLevel.INFO, f'- [行情] 市场激活 (时间 {t.strftime("%H:%M:%S")} >= 09:15)')
|
||||||
|
eBus.event_bus.publish(eBus.MarketDataUpdate, datas)
|
||||||
|
|
||||||
|
def _is_trading_time(self) -> bool:
|
||||||
|
"""判断当前是否在交易时间内(工作日 09:30-11:30 / 13:00-15:00,北京时间 UTC+8)"""
|
||||||
|
import zoneinfo
|
||||||
|
beijing_tz = zoneinfo.ZoneInfo("Asia/Shanghai")
|
||||||
|
now = datetime.datetime.now(beijing_tz)
|
||||||
|
if now.weekday() >= 5: # 周六、周日
|
||||||
|
return False
|
||||||
|
t = now.time()
|
||||||
|
morning_start = datetime.time(9, 30)
|
||||||
|
morning_end = datetime.time(11, 30)
|
||||||
|
afternoon_start = datetime.time(13, 0)
|
||||||
|
afternoon_end = datetime.time(15, 0)
|
||||||
|
return (morning_start <= t <= morning_end) or (afternoon_start <= t <= afternoon_end)
|
||||||
|
|
||||||
|
def _market_data_watchdog(self):
|
||||||
|
"""行情活跃监控 — 超过 120 秒无行情 则标记市场不活跃(无论是否交易时间)"""
|
||||||
|
while True:
|
||||||
|
time.sleep(15)
|
||||||
|
if self.isMarketActive:
|
||||||
|
elapsed = time.time() - self.lastMarketDataUpdateTimestamp
|
||||||
|
# 只有超过 120 秒无行情才标记不活跃,不再区分交易时间
|
||||||
|
if elapsed > 120:
|
||||||
|
self.isMarketActive = False
|
||||||
|
eBus.event_bus.publish(eBus.EventMarketActiveSwitch, False)
|
||||||
|
PrintLog(LogLevel.INFO, f'- [行情] 超过 {elapsed:.0f} 秒无数据,市场标记为不活跃')
|
||||||
|
|
||||||
|
def stopMarketDataSubscription(self):
|
||||||
|
"""停止市场数据订阅"""
|
||||||
|
self.isMarketActive = False
|
||||||
|
PrintLog(LogLevel.INFO, '- [市场数据订阅已停止]')
|
||||||
|
|
||||||
|
# ---- xtquant 回调处理 (xtquant 通过回调对象调用 on_xxx 方法) ----
|
||||||
|
|
||||||
|
def on_connected(self):
|
||||||
|
PrintLog(LogLevel.INFO, f'[QMT] on_connected: 真实 QMT 连接成功 {datetime.datetime.now()}')
|
||||||
|
|
||||||
|
def on_disconnected(self):
|
||||||
|
PrintLog(LogLevel.WARNING, f'[QMT] on_disconnected: 真实 QMT 连接断开 {datetime.datetime.now()}')
|
||||||
|
|
||||||
|
def on_stock_order(self, order):
|
||||||
|
self._pending_orders.append(order)
|
||||||
|
|
||||||
|
def on_stock_trade(self, trade):
|
||||||
|
eBus.event_bus.publish(eBus.MarketOrderTraded, trade)
|
||||||
|
|
||||||
|
def on_order_stock_async_response(self, response):
|
||||||
|
eBus.event_bus.publish(eBus.MarketOrderCreated, response)
|
||||||
|
|
||||||
|
def on_order_error(self, order_error):
|
||||||
|
PrintLog(LogLevel.ERROR,
|
||||||
|
f'[QMT] 委托报错: order_id={order_error.order_id}, error_id={order_error.error_id}, '
|
||||||
|
f'error_msg={order_error.error_msg}, remark={order_error.order_remark}')
|
||||||
|
eBus.event_bus.publish(eBus.MarketOrderError, order_error)
|
||||||
|
|
||||||
|
def on_account_status(self, status):
|
||||||
|
PrintLog(LogLevel.INFO, f'[QMT] on_account_status: {datetime.datetime.now()} {status}')
|
||||||
|
|
||||||
|
|
||||||
|
qmtv = RealQmtV()
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 软件介绍
|
|
||||||
软件名称:神之一手交易系统
|
|
||||||
软件介绍:面向个人的交易管理系统,提供交易记录、复盘工具、持仓管理、资产监控、策略交易等功能。
|
|
||||||
|
|
||||||
# 模块介绍
|
|
||||||
1. /core/daily_review: 每日复盘模块目录
|
|
||||||
2. /core/market_data: 市场数据模块目录
|
|
||||||
3. /core/quick_trade: 快速交易模块目录
|
|
||||||
4. /core/strategy/builder: 策略构建模块目录
|
|
||||||
5. /core/strategy/trade: 策略交易模块目录
|
|
||||||
6. /core: 应用核心程序目录
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# grid_seeker v6.4 评分模块
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
grid_seeker v6.4 CLI 入口
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m core.scoring.cli sync all # 按依赖顺序执行全部同步
|
||||||
|
python -m core.scoring.cli sync kline # 仅同步个股+指数K线
|
||||||
|
python -m core.scoring.cli sync stocks # 仅同步股票基础信息
|
||||||
|
python -m core.scoring.cli sync industry # 仅同步行业映射
|
||||||
|
python -m core.scoring.cli sync market # 仅计算市场状态
|
||||||
|
python -m core.scoring.cli sync sector # 仅计算行业指数
|
||||||
|
python -m core.scoring.cli score # 完整评分管道 (最新交易日)
|
||||||
|
python -m core.scoring.cli score --date 20260615 # 指定日期
|
||||||
|
python -m core.scoring.cli score --dry-run # 试运行 (不写库)
|
||||||
|
python -m core.scoring.cli check 000001 # 检查单只股票数据充分性
|
||||||
|
python -m core.scoring.cli list-candidates # 列出全部候选股
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
_usage()
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
|
||||||
|
if cmd == 'sync':
|
||||||
|
_cmd_sync()
|
||||||
|
elif cmd == 'score':
|
||||||
|
_cmd_score()
|
||||||
|
elif cmd == 'check':
|
||||||
|
_cmd_check()
|
||||||
|
elif cmd == 'list-candidates':
|
||||||
|
_cmd_list_candidates()
|
||||||
|
else:
|
||||||
|
print(f'未知命令: {cmd}')
|
||||||
|
_usage()
|
||||||
|
|
||||||
|
|
||||||
|
def _usage():
|
||||||
|
print(__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# sync 命令
|
||||||
|
# ============================================================
|
||||||
|
def _cmd_sync():
|
||||||
|
target = sys.argv[2] if len(sys.argv) > 2 else 'all'
|
||||||
|
from core.scoring.sync import (
|
||||||
|
KlineStockSync, KlineIndexSync,
|
||||||
|
StocksSync, IndustrySync,
|
||||||
|
MarketRegimeSync, SectorFeaturesSync,
|
||||||
|
)
|
||||||
|
|
||||||
|
syncs = {
|
||||||
|
'kline': [KlineStockSync, KlineIndexSync],
|
||||||
|
'stocks': [StocksSync],
|
||||||
|
'industry': [IndustrySync],
|
||||||
|
'market': [MarketRegimeSync],
|
||||||
|
'sector': [SectorFeaturesSync],
|
||||||
|
}
|
||||||
|
|
||||||
|
if target == 'all':
|
||||||
|
# 按依赖顺序执行
|
||||||
|
order = [
|
||||||
|
('K线(个股)', KlineStockSync(count=300)),
|
||||||
|
('K线(指数)', KlineIndexSync(count=300)),
|
||||||
|
('股票信息', StocksSync()),
|
||||||
|
('行业映射', IndustrySync()),
|
||||||
|
('市场状态', MarketRegimeSync()),
|
||||||
|
('行业指数', SectorFeaturesSync()),
|
||||||
|
]
|
||||||
|
for label, sync in order:
|
||||||
|
print(f'\n===== {label} =====')
|
||||||
|
sync.run()
|
||||||
|
print('\n===== 全部同步完成 =====')
|
||||||
|
elif target in syncs:
|
||||||
|
for cls in syncs[target]:
|
||||||
|
cls().run()
|
||||||
|
else:
|
||||||
|
print(f'未知同步目标: {target}')
|
||||||
|
print(f'可用: all, {", ".join(syncs.keys())}')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# score 命令
|
||||||
|
# ============================================================
|
||||||
|
def _cmd_score():
|
||||||
|
dry_run = '--dry-run' in sys.argv
|
||||||
|
date_str = None
|
||||||
|
for i, arg in enumerate(sys.argv):
|
||||||
|
if arg == '--date' and i + 1 < len(sys.argv):
|
||||||
|
date_str = sys.argv[i + 1]
|
||||||
|
break
|
||||||
|
|
||||||
|
if date_str:
|
||||||
|
trade_date = datetime.strptime(date_str, '%Y%m%d').date()
|
||||||
|
else:
|
||||||
|
trade_date = date.today()
|
||||||
|
|
||||||
|
print(f'评分日期: {trade_date}')
|
||||||
|
|
||||||
|
from core.scoring.inference.scorer import GridSeekerPipeline
|
||||||
|
|
||||||
|
engine = GridSeekerPipeline()
|
||||||
|
rankings = engine.run(trade_date)
|
||||||
|
|
||||||
|
if rankings.empty:
|
||||||
|
print('无评分结果')
|
||||||
|
return
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
engine.persist(rankings, trade_date)
|
||||||
|
print(f'结果已写入 ScoringResult ({len(rankings)} 条)')
|
||||||
|
|
||||||
|
# 打印 Top-20
|
||||||
|
print('\n===== Top-20 =====')
|
||||||
|
print(f'{"Rank":<6} {"Code":<10} {"Profit":>10} {"Rounds":>10} {"Prob":>10}')
|
||||||
|
print('-' * 50)
|
||||||
|
for code, row in rankings.head(20).iterrows():
|
||||||
|
print(f'{int(row["score_rank"]):<6} {code:<10} '
|
||||||
|
f'{row["stacking_probability"]:>10.4f} '
|
||||||
|
f'{row.get("rank_predicted_rounds", 0):>10.2f} '
|
||||||
|
f'{row.get("stacking_probability", 0):>10.4f}')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# check 命令
|
||||||
|
# ============================================================
|
||||||
|
def _cmd_check():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print('Usage: python -m core.scoring.cli check <stock_code>')
|
||||||
|
return
|
||||||
|
|
||||||
|
stock_code = sys.argv[2]
|
||||||
|
from core.scoring.features.validator import _check_kline_sufficiency
|
||||||
|
|
||||||
|
ok, reason, close = _check_kline_sufficiency(stock_code, date.today())
|
||||||
|
if ok:
|
||||||
|
print(f'{stock_code}: ✅ 通过 (close={close:.2f})')
|
||||||
|
else:
|
||||||
|
print(f'{stock_code}: ❌ {reason}')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# list-candidates 命令
|
||||||
|
# ============================================================
|
||||||
|
def _cmd_list_candidates():
|
||||||
|
from core.scoring.features.validator import load_candidates
|
||||||
|
|
||||||
|
ctx = load_candidates(date.today())
|
||||||
|
print(f'候选股总数: {len(ctx.candidates)}')
|
||||||
|
print(f'排除: {len(ctx.excluded)}')
|
||||||
|
print(f'\n候选股 (前100):')
|
||||||
|
for i, code in enumerate(ctx.candidates[:100]):
|
||||||
|
print(f' {i + 1}. {code}')
|
||||||
|
if ctx.excluded:
|
||||||
|
print(f'\n排除原因 (前20):')
|
||||||
|
for i, (code, reason) in enumerate(list(ctx.excluded.items())[:20]):
|
||||||
|
print(f' {code}: {reason}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
grid_seeker v6.6 评分配置常量
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
import config as app_config
|
||||||
|
|
||||||
|
# ---- 网格交易参数 ----
|
||||||
|
GRID_LOW = 1 # 网格下限
|
||||||
|
GRID_HIGH = 11 # 网格上限
|
||||||
|
GRID_STEP = 1 # 网格间距(整数格)
|
||||||
|
|
||||||
|
# ---- 候选股过滤 ----
|
||||||
|
FILTER_MIN_CLOSE = 5 # 最低收盘价(覆盖网格策略持仓股)
|
||||||
|
FILTER_MAX_CLOSE = 13 # 最高收盘价
|
||||||
|
REQUIRE_DAYS = 120 # 最少交易日数
|
||||||
|
|
||||||
|
# ---- 窗口参数 ----
|
||||||
|
WINDOW_180D = 180 # 长窗口(情绪特征)
|
||||||
|
WINDOW_60D = 60 # 中窗口
|
||||||
|
WINDOW_20D = 20 # 短窗口(独立性特征)
|
||||||
|
ATR_PERIOD = 14 # ATR 周期
|
||||||
|
|
||||||
|
# ---- 指数 ----
|
||||||
|
HS300_CODE = '000300' # 沪深300基准指数
|
||||||
|
TRACKED_INDICES = [
|
||||||
|
'000001', # 上证指数
|
||||||
|
'000300', # 沪深300
|
||||||
|
'000852', # 中证1000
|
||||||
|
'000905', # 中证500
|
||||||
|
'399001', # 深证成指
|
||||||
|
'399006', # 创业板指
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---- 市场状态 ----
|
||||||
|
PANIC_ADVANCE_RATIO = 0.20 # 恐慌日涨跌比阈值
|
||||||
|
GREED_ADVANCE_RATIO = 0.55 # 贪婪日涨跌比阈值
|
||||||
|
PANIC_TURNOVER_RATIO = 1.5 # 恐慌日量比阈值
|
||||||
|
|
||||||
|
# ---- 模型文件 ----
|
||||||
|
def model_dir() -> Path:
|
||||||
|
"""模型文件目录"""
|
||||||
|
return app_config.app_dir() / 'models'
|
||||||
|
|
||||||
|
def get_model_path(name: str) -> Path:
|
||||||
|
"""获取指定模型文件路径"""
|
||||||
|
return model_dir() / f'{name}.pkl'
|
||||||
|
|
||||||
|
# 3 级模型文件名 (v6.6)
|
||||||
|
RANK_MODEL = 'rank'
|
||||||
|
TOP_MODEL = 'top'
|
||||||
|
STACKING_MODEL = 'stacking'
|
||||||
|
|
||||||
|
# Stacking 选股阈值 (v6.7r3 最优阈值 0.331)
|
||||||
|
STACKING_THRESHOLD = 0.33
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 特征工程子包
|
||||||
|
from core.scoring.features.validator import load_candidates, DataContext
|
||||||
|
from core.scoring.features.pipeline import FeaturePipeline
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""
|
||||||
|
情绪弹性特征 (4维) — calculate_relaxed_emotion_features(df, market_regime)
|
||||||
|
180 日窗口,筛选 advance_ratio < 0.20 恐慌日 + advance_ratio > 0.55 贪婪日。
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from core.scoring.config import PANIC_ADVANCE_RATIO, GREED_ADVANCE_RATIO, WINDOW_180D
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_relaxed_emotion_features(ctx) -> pd.DataFrame:
|
||||||
|
"""计算情绪弹性特征 (4维)"""
|
||||||
|
kline = ctx.kline.copy()
|
||||||
|
mkt = ctx.market_regime.copy() if ctx.market_regime is not None else pd.DataFrame()
|
||||||
|
|
||||||
|
if kline.empty or mkt.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# 筛选恐慌日和贪婪日
|
||||||
|
panic_dates = set()
|
||||||
|
greed_dates = set()
|
||||||
|
|
||||||
|
for _, row in mkt.iterrows():
|
||||||
|
td = row['trade_date']
|
||||||
|
ar = row.get('advance_ratio', 0.5)
|
||||||
|
if ar < PANIC_ADVANCE_RATIO:
|
||||||
|
panic_dates.add(td.date() if hasattr(td, 'date') else td)
|
||||||
|
if ar > GREED_ADVANCE_RATIO:
|
||||||
|
greed_dates.add(td.date() if hasattr(td, 'date') else td)
|
||||||
|
|
||||||
|
kline = kline.sort_values(['stock_code', 'trade_date'])
|
||||||
|
kline['td'] = (kline['trade_date'].dt.date
|
||||||
|
if hasattr(kline['trade_date'], 'dt')
|
||||||
|
else pd.to_datetime(kline['trade_date']).dt.date)
|
||||||
|
|
||||||
|
candidates = ctx.candidates
|
||||||
|
|
||||||
|
# 计算全市场平均振幅 (用于恐慌/贪婪 ratio)
|
||||||
|
kline['amp'] = (kline['high'] - kline['low']) / np.where(
|
||||||
|
kline['open'] > 0, kline['open'], 1
|
||||||
|
)
|
||||||
|
market_amp = kline.groupby('td')['amp'].mean().to_dict()
|
||||||
|
|
||||||
|
features = {}
|
||||||
|
grouped = kline.groupby('stock_code')
|
||||||
|
|
||||||
|
for code, group in grouped:
|
||||||
|
if code not in candidates:
|
||||||
|
continue
|
||||||
|
if len(group) < 20:
|
||||||
|
continue
|
||||||
|
|
||||||
|
g = group.sort_values('trade_date').tail(WINDOW_180D)
|
||||||
|
closes = g['close'].values
|
||||||
|
opens = g['open'].values
|
||||||
|
highs = g['high'].values
|
||||||
|
lows = g['low'].values
|
||||||
|
dates = g['td'].values
|
||||||
|
amps = (highs - lows) / np.where(opens > 0, opens, 1)
|
||||||
|
|
||||||
|
feat = {}
|
||||||
|
|
||||||
|
# 恐慌日分析
|
||||||
|
panic_indices = [i for i, d in enumerate(dates) if d in panic_dates]
|
||||||
|
if panic_indices:
|
||||||
|
panic_amp_ratios = []
|
||||||
|
panic_drop_ratios = []
|
||||||
|
panic_rebounds = []
|
||||||
|
|
||||||
|
for pi in panic_indices:
|
||||||
|
td = dates[pi]
|
||||||
|
mkt_amp_val = market_amp.get(td, amps[pi])
|
||||||
|
if mkt_amp_val > 0:
|
||||||
|
panic_amp_ratios.append(amps[pi] / mkt_amp_val)
|
||||||
|
|
||||||
|
# 恐慌日跌幅
|
||||||
|
if pi > 0 and closes[pi - 1] > 0:
|
||||||
|
drop = (closes[pi] - closes[pi - 1]) / closes[pi - 1]
|
||||||
|
# 全市场跌幅: 用 advance_ratio 估算
|
||||||
|
ar = _get_advance_ratio(mkt, td)
|
||||||
|
market_drop = -0.02 if ar < PANIC_ADVANCE_RATIO else -0.005
|
||||||
|
if market_drop != 0:
|
||||||
|
panic_drop_ratios.append(drop / market_drop)
|
||||||
|
|
||||||
|
# 恐慌次日反弹
|
||||||
|
if pi + 1 < len(g) and closes[pi] > 0:
|
||||||
|
panic_rebounds.append(highs[pi + 1] / closes[pi] - 1)
|
||||||
|
|
||||||
|
# 64. relaxed_panic_amplitude_ratio
|
||||||
|
feat['relaxed_panic_amplitude_ratio'] = (
|
||||||
|
np.median(panic_amp_ratios) if panic_amp_ratios else 1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# 65. relaxed_panic_rebound_strength
|
||||||
|
feat['relaxed_panic_rebound_strength'] = (
|
||||||
|
np.median(panic_rebounds) if panic_rebounds else 0.0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
feat['relaxed_panic_amplitude_ratio'] = 1.0
|
||||||
|
feat['relaxed_panic_rebound_strength'] = 0.0
|
||||||
|
|
||||||
|
# 贪婪日分析
|
||||||
|
greed_indices = [i for i, d in enumerate(dates) if d in greed_dates]
|
||||||
|
if greed_indices:
|
||||||
|
greed_gains = []
|
||||||
|
greed_amp_ratios = []
|
||||||
|
|
||||||
|
for gi in greed_indices:
|
||||||
|
td = dates[gi]
|
||||||
|
if gi > 0 and closes[gi - 1] > 0:
|
||||||
|
gain = (closes[gi] - closes[gi - 1]) / closes[gi - 1]
|
||||||
|
# 全市场收益
|
||||||
|
ar = _get_advance_ratio(mkt, td)
|
||||||
|
market_gain = 0.01 if ar > GREED_ADVANCE_RATIO else 0.003
|
||||||
|
if market_gain > 0:
|
||||||
|
greed_gains.append(gain / market_gain)
|
||||||
|
|
||||||
|
mkt_amp_val = market_amp.get(td, amps[gi])
|
||||||
|
if mkt_amp_val > 0:
|
||||||
|
greed_amp_ratios.append(amps[gi] / mkt_amp_val)
|
||||||
|
|
||||||
|
# 66. relaxed_greed_relative_gain
|
||||||
|
feat['relaxed_greed_relative_gain'] = (
|
||||||
|
np.median(greed_gains) if greed_gains else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# 67. relaxed_greed_amplitude_ratio
|
||||||
|
feat['relaxed_greed_amplitude_ratio'] = (
|
||||||
|
np.median(greed_amp_ratios) if greed_amp_ratios else 1.0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
feat['relaxed_greed_relative_gain'] = 0.0
|
||||||
|
feat['relaxed_greed_amplitude_ratio'] = 1.0
|
||||||
|
|
||||||
|
features[code] = feat
|
||||||
|
|
||||||
|
return pd.DataFrame.from_dict(features, orient='index')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_advance_ratio(mkt_df, trade_date):
|
||||||
|
"""获取指定日期的 advance_ratio"""
|
||||||
|
if mkt_df is None or mkt_df.empty:
|
||||||
|
return 0.5
|
||||||
|
td = trade_date
|
||||||
|
match = mkt_df[mkt_df['trade_date'] == td]
|
||||||
|
if not match.empty:
|
||||||
|
return match.iloc[0].get('advance_ratio', 0.5)
|
||||||
|
return 0.5
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""
|
||||||
|
大盘独立性特征 (3维) — calculate_independence_features(df)
|
||||||
|
使用 HS300(000300) 作为 benchmark,最近 20 个交易日 OLS 回归。
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from core.scoring.config import WINDOW_20D
|
||||||
|
from core.scoring.features.v3_2_features import _ols_slope
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_independence_features(ctx) -> pd.DataFrame:
|
||||||
|
"""计算大盘独立性特征 (3维)"""
|
||||||
|
kline = ctx.kline.copy()
|
||||||
|
hs300 = ctx.hs300_kline.copy() if ctx.hs300_kline is not None else pd.DataFrame()
|
||||||
|
|
||||||
|
if kline.empty or hs300.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# 准备 HS300 收益率序列
|
||||||
|
hs300 = hs300.sort_values('trade_date')
|
||||||
|
hs300['market_return'] = hs300['close'].pct_change()
|
||||||
|
hs300['market_amplitude'] = (hs300['high'] - hs300['low']) / hs300['open']
|
||||||
|
hs300 = hs300.dropna(subset=['market_return', 'market_amplitude'])
|
||||||
|
|
||||||
|
# 对齐日期
|
||||||
|
hs300_dates = set(hs300['trade_date'].dt.date
|
||||||
|
if hasattr(hs300['trade_date'], 'dt') else hs300['trade_date'])
|
||||||
|
|
||||||
|
kline = kline.sort_values(['stock_code', 'trade_date'])
|
||||||
|
kline['trade_date_dt'] = (kline['trade_date'].dt.date
|
||||||
|
if hasattr(kline['trade_date'], 'dt')
|
||||||
|
else pd.to_datetime(kline['trade_date']).dt.date)
|
||||||
|
|
||||||
|
candidates = ctx.candidates
|
||||||
|
|
||||||
|
# 计算 HS300 最近20日平均振幅
|
||||||
|
hs300_tail = hs300.tail(WINDOW_20D)
|
||||||
|
hs300_avg_amp = hs300_tail['market_amplitude'].mean() if len(hs300_tail) > 0 else 0
|
||||||
|
|
||||||
|
features = {}
|
||||||
|
grouped = kline.groupby('stock_code')
|
||||||
|
|
||||||
|
for code, group in grouped:
|
||||||
|
if code not in candidates:
|
||||||
|
continue
|
||||||
|
if len(group) < 20:
|
||||||
|
continue
|
||||||
|
|
||||||
|
g = group.sort_values('trade_date').tail(120)
|
||||||
|
closes = g['close'].values
|
||||||
|
|
||||||
|
# 计算个股日收益率
|
||||||
|
stock_rets = np.diff(closes) / np.where(closes[:-1] > 0, closes[:-1], 1)
|
||||||
|
|
||||||
|
# 对齐 HS300 收益率 (取对应日期)
|
||||||
|
# 简化: 取最近 N 个交易日的数据点
|
||||||
|
n = min(WINDOW_20D, len(stock_rets))
|
||||||
|
|
||||||
|
# 获取 HS300 最近 n 天的 market_return
|
||||||
|
market_rets = hs300['market_return'].tail(n + 1).values
|
||||||
|
if len(market_rets) < n:
|
||||||
|
market_rets = hs300['market_return'].values[-n - 1:]
|
||||||
|
|
||||||
|
# 对齐长度
|
||||||
|
min_len = min(n, len(market_rets) - 1, len(stock_rets))
|
||||||
|
if min_len < 5: # 至少需要5个数据点做回归
|
||||||
|
continue
|
||||||
|
|
||||||
|
stock_ret_window = stock_rets[-min_len:]
|
||||||
|
market_ret_window = market_rets[-min_len:]
|
||||||
|
|
||||||
|
feat = {}
|
||||||
|
|
||||||
|
# OLS 回归: stock_ret ~ market_return
|
||||||
|
try:
|
||||||
|
slope, r_value = _ols_slope(market_ret_window, stock_ret_window)
|
||||||
|
residuals = stock_ret_window - slope * market_ret_window
|
||||||
|
|
||||||
|
# 58. market_residual_volatility_20d: std(residuals) × √252
|
||||||
|
feat['market_residual_volatility_20d'] = np.std(residuals, ddof=1) * np.sqrt(252)
|
||||||
|
|
||||||
|
# 59. market_independence_ratio_20d: 1 - R²
|
||||||
|
feat['market_independence_ratio_20d'] = 1 - r_value ** 2
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
feat['market_residual_volatility_20d'] = 0
|
||||||
|
feat['market_independence_ratio_20d'] = 1
|
||||||
|
|
||||||
|
# 60. market_amplitude_deviation_20d: 个股平均振幅 - HS300 平均振幅
|
||||||
|
g_amps = (g['high'].values[-20:] - g['low'].values[-20:]) / np.where(
|
||||||
|
g['open'].values[-20:] > 0, g['open'].values[-20:], 1
|
||||||
|
)
|
||||||
|
feat['market_amplitude_deviation_20d'] = np.mean(g_amps) - hs300_avg_amp
|
||||||
|
|
||||||
|
features[code] = feat
|
||||||
|
|
||||||
|
return pd.DataFrame.from_dict(features, orient='index')
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
负向指标 (5维) — calculate_negative_features(df)
|
||||||
|
注意: #53 trend_consistency_20d 与 #39 同名不同义,更新字典时会覆盖 #39
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_negative_features(ctx) -> pd.DataFrame:
|
||||||
|
"""计算负向指标 (5维)"""
|
||||||
|
kline = ctx.kline.copy()
|
||||||
|
if kline.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
kline = kline.sort_values(['stock_code', 'trade_date'])
|
||||||
|
candidates = ctx.candidates
|
||||||
|
|
||||||
|
features = {}
|
||||||
|
grouped = kline.groupby('stock_code')
|
||||||
|
|
||||||
|
for code, group in grouped:
|
||||||
|
if code not in candidates:
|
||||||
|
continue
|
||||||
|
g = group.tail(120)
|
||||||
|
if len(g) < 20:
|
||||||
|
continue
|
||||||
|
|
||||||
|
closes = g['close'].values
|
||||||
|
opens = g['open'].values
|
||||||
|
highs = g['high'].values
|
||||||
|
lows = g['low'].values
|
||||||
|
volumes = g['volume'].values
|
||||||
|
|
||||||
|
feat = {}
|
||||||
|
|
||||||
|
# 53. trend_consistency_20d (负向版本): |mean(return>0) - 0.5| × 100
|
||||||
|
# 衡量偏离均衡的程度,越接近50%越弱
|
||||||
|
rets_20 = np.diff(closes[-21:]) / np.where(closes[-21:-1] > 0, closes[-21:-1], 1)
|
||||||
|
feat['trend_consistency_20d'] = abs(np.mean(rets_20 > 0) - 0.5) * 100
|
||||||
|
|
||||||
|
# 54. max_consecutive_direction_20d: 最大连续同向天数
|
||||||
|
rets_sign = np.sign(np.diff(closes[-21:]))
|
||||||
|
max_consec = 0
|
||||||
|
curr_consec = 0
|
||||||
|
curr_sign = 0
|
||||||
|
for s in rets_sign:
|
||||||
|
if s != 0 and s == curr_sign:
|
||||||
|
curr_consec += 1
|
||||||
|
elif s != 0:
|
||||||
|
curr_sign = s
|
||||||
|
curr_consec = 1
|
||||||
|
else:
|
||||||
|
curr_consec = 0
|
||||||
|
max_consec = max(max_consec, curr_consec)
|
||||||
|
feat['max_consecutive_direction_20d'] = max_consec
|
||||||
|
|
||||||
|
# 55. gap_risk_20d: mean(|open_t - close_{t-1}|/close_{t-1} > 0.02) × 100
|
||||||
|
gap_count = 0
|
||||||
|
n = 0
|
||||||
|
for i in range(max(0, len(g) - 20), len(g)):
|
||||||
|
if i > 0 and closes[i - 1] > 0:
|
||||||
|
gap_pct = abs(opens[i] - closes[i - 1]) / closes[i - 1]
|
||||||
|
if gap_pct > 0.02:
|
||||||
|
gap_count += 1
|
||||||
|
n += 1
|
||||||
|
feat['gap_risk_20d'] = gap_count / n * 100 if n > 0 else 0
|
||||||
|
|
||||||
|
# 56. liquidity_drying_up_20d: min(vol_20d)/mean(vol_60d)
|
||||||
|
vol_20 = volumes[-20:]
|
||||||
|
vol_60 = volumes[-60:] if len(volumes) >= 60 else volumes
|
||||||
|
feat['liquidity_drying_up_20d'] = (
|
||||||
|
np.min(vol_20) / np.mean(vol_60) if np.mean(vol_60) > 0 else 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# 57. price_stagnation_20d: (max(high_20d)-min(low_20d))/close × 100
|
||||||
|
h20 = np.max(highs[-20:])
|
||||||
|
l20 = np.min(lows[-20:])
|
||||||
|
feat['price_stagnation_20d'] = (
|
||||||
|
(h20 - l20) / closes[-1] * 100 if closes[-1] > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
features[code] = feat
|
||||||
|
|
||||||
|
return pd.DataFrame.from_dict(features, orient='index')
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
特征工程编排器 — 串联全部特征组,输出完整特征 DataFrame
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import date
|
||||||
|
from core.scoring.features.validator import load_candidates, DataContext
|
||||||
|
from core.scoring.features.v3_2_features import calculate_features_v3_2
|
||||||
|
from core.scoring.features.v3_3_features import calculate_features_v3_3
|
||||||
|
from core.scoring.features.v3_4_features import calculate_features_v3_4
|
||||||
|
from core.scoring.features.negative_features import calculate_negative_features
|
||||||
|
from core.scoring.features.independence_features import calculate_independence_features
|
||||||
|
from core.scoring.features.sector_features import calculate_sector_independence_features
|
||||||
|
from core.scoring.features.emotion_features import calculate_relaxed_emotion_features
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
|
||||||
|
|
||||||
|
class FeaturePipeline:
|
||||||
|
"""
|
||||||
|
特征工程管道 — 串联 8 组特征计算,输出完整特征矩阵。
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
pipeline = FeaturePipeline(trade_date=date.today())
|
||||||
|
feature_df = pipeline.run() # DataFrame indexed by stock_code
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, trade_date: date):
|
||||||
|
self.trade_date = trade_date
|
||||||
|
self.ctx: DataContext = None
|
||||||
|
|
||||||
|
def run(self) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
执行完整特征工程管道。
|
||||||
|
返回: DataFrame indexed by stock_code, columns = 全部特征
|
||||||
|
"""
|
||||||
|
# Stage 0: 加载候选股和数据
|
||||||
|
PrintLog(LogLevel.INFO, '[pipeline] Stage 0: 加载候选股...')
|
||||||
|
self.ctx = load_candidates(self.trade_date)
|
||||||
|
if not self.ctx.candidates:
|
||||||
|
PrintLog(LogLevel.WARNING, '[pipeline] 无候选股通过过滤')
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO, f'[pipeline] 候选股: {len(self.ctx.candidates)}, '
|
||||||
|
f'排除: {len(self.ctx.excluded)}')
|
||||||
|
|
||||||
|
# Stage 1: v3.2 基础特征 (20维)
|
||||||
|
PrintLog(LogLevel.INFO, '[pipeline] Stage 1: v3.2 基础特征 (20维)...')
|
||||||
|
df = calculate_features_v3_2(self.ctx)
|
||||||
|
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
|
||||||
|
|
||||||
|
# Stage 2: v3.3 扩展特征 (16维)
|
||||||
|
PrintLog(LogLevel.INFO, '[pipeline] Stage 2: v3.3 扩展特征 (16维)...')
|
||||||
|
df_v33 = calculate_features_v3_3(self.ctx)
|
||||||
|
df = df.join(df_v33, how='inner', rsuffix='_v33')
|
||||||
|
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
|
||||||
|
|
||||||
|
# Stage 3: v3.4 扩展特征 (16维)
|
||||||
|
PrintLog(LogLevel.INFO, '[pipeline] Stage 3: v3.4 扩展特征 (16维)...')
|
||||||
|
df_v34 = calculate_features_v3_4(self.ctx)
|
||||||
|
df = df.join(df_v34, how='inner', rsuffix='_v34')
|
||||||
|
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
|
||||||
|
|
||||||
|
# Stage 4: 负向指标 (5维) — 注意 #53 覆盖 #39
|
||||||
|
PrintLog(LogLevel.INFO, '[pipeline] Stage 4: 负向指标 (5维)...')
|
||||||
|
df_neg = calculate_negative_features(self.ctx)
|
||||||
|
# 使用 update 模式: 负向指标的 trend_consistency_20d 覆盖 v3.4 版本
|
||||||
|
common_cols = set(df.columns) & set(df_neg.columns)
|
||||||
|
for col in common_cols:
|
||||||
|
df[col] = df_neg[col] # 覆盖
|
||||||
|
new_cols = set(df_neg.columns) - common_cols
|
||||||
|
for col in new_cols:
|
||||||
|
df[col] = df_neg[col]
|
||||||
|
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
|
||||||
|
|
||||||
|
# Stage 5: 大盘独立性 (3维)
|
||||||
|
PrintLog(LogLevel.INFO, '[pipeline] Stage 5: 大盘独立性 (3维)...')
|
||||||
|
df_ind = calculate_independence_features(self.ctx)
|
||||||
|
df = df.join(df_ind, how='left')
|
||||||
|
df[df_ind.columns] = df[df_ind.columns].fillna(0)
|
||||||
|
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
|
||||||
|
|
||||||
|
# Stage 6: 行业独立性 (3维)
|
||||||
|
PrintLog(LogLevel.INFO, '[pipeline] Stage 6: 行业独立性 (3维)...')
|
||||||
|
df_sec = calculate_sector_independence_features(self.ctx)
|
||||||
|
df = df.join(df_sec, how='left')
|
||||||
|
df[df_sec.columns] = df[df_sec.columns].fillna(0)
|
||||||
|
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
|
||||||
|
|
||||||
|
# Stage 7: 情绪弹性 (4维)
|
||||||
|
PrintLog(LogLevel.INFO, '[pipeline] Stage 7: 情绪弹性 (4维)...')
|
||||||
|
df_emo = calculate_relaxed_emotion_features(self.ctx)
|
||||||
|
df = df.join(df_emo, how='left')
|
||||||
|
df[df_emo.columns] = df[df_emo.columns].fillna(1.0)
|
||||||
|
PrintLog(LogLevel.INFO, f'[pipeline] → {len(df)} stocks, {len(df.columns)} features')
|
||||||
|
|
||||||
|
# 添加 latest_close 列 (Meta Ranker 输入)
|
||||||
|
df['latest_close'] = 0.0
|
||||||
|
for code in df.index:
|
||||||
|
g = self.ctx.kline[self.ctx.kline['stock_code'] == code]
|
||||||
|
if not g.empty:
|
||||||
|
g_sorted = g.sort_values('trade_date')
|
||||||
|
df.at[code, 'latest_close'] = float(g_sorted['close'].iloc[-1])
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[pipeline] 完成: {len(df)} 只股票, {len(df.columns)} 维特征')
|
||||||
|
|
||||||
|
return df
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
行业独立性特征 (3维) — calculate_sector_independence_features(df)
|
||||||
|
匹配 sector_features_daily,最近 20 个交易日 OLS 回归。
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from core.scoring.config import WINDOW_20D
|
||||||
|
from core.scoring.features.v3_2_features import _ols_slope
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_sector_independence_features(ctx) -> pd.DataFrame:
|
||||||
|
"""计算行业独立性特征 (3维)"""
|
||||||
|
kline = ctx.kline.copy()
|
||||||
|
sector_df = ctx.sector_features.copy() if ctx.sector_features is not None else pd.DataFrame()
|
||||||
|
industry_map = ctx.industry_map
|
||||||
|
|
||||||
|
if kline.empty or sector_df.empty or not industry_map:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
sector_df = sector_df.sort_values(['sector_name', 'trade_date'])
|
||||||
|
kline = kline.sort_values(['stock_code', 'trade_date'])
|
||||||
|
|
||||||
|
candidates = ctx.candidates
|
||||||
|
|
||||||
|
features = {}
|
||||||
|
grouped = kline.groupby('stock_code')
|
||||||
|
|
||||||
|
for code, group in grouped:
|
||||||
|
if code not in candidates:
|
||||||
|
continue
|
||||||
|
if len(group) < 20:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取行业名称
|
||||||
|
sector_name = industry_map.get(str(code), '')
|
||||||
|
if not sector_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取该行业的指数数据
|
||||||
|
sector_data = sector_df[sector_df['sector_name'] == sector_name]
|
||||||
|
if sector_data.empty or len(sector_data) < 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sector_data = sector_data.sort_values('trade_date').tail(120)
|
||||||
|
sector_rets = sector_data['sector_ret'].values / 100 # 转为小数
|
||||||
|
sector_amps = sector_data['sector_amplitude'].values / 100
|
||||||
|
|
||||||
|
g = group.sort_values('trade_date').tail(120)
|
||||||
|
closes = g['close'].values
|
||||||
|
|
||||||
|
# 个股日收益率
|
||||||
|
stock_rets = np.diff(closes) / np.where(closes[:-1] > 0, closes[:-1], 1)
|
||||||
|
|
||||||
|
# 对齐长度
|
||||||
|
n = min(WINDOW_20D, len(stock_rets), len(sector_rets) - 1)
|
||||||
|
if n < 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
stock_ret_window = stock_rets[-n:]
|
||||||
|
sector_ret_window = sector_rets[-n:]
|
||||||
|
|
||||||
|
feat = {}
|
||||||
|
|
||||||
|
# OLS: stock_ret ~ sector_ret
|
||||||
|
try:
|
||||||
|
slope, r_value = _ols_slope(sector_ret_window, stock_ret_window)
|
||||||
|
residuals = stock_ret_window - slope * sector_ret_window
|
||||||
|
|
||||||
|
# 61. sector_residual_volatility_20d
|
||||||
|
feat['sector_residual_volatility_20d'] = np.std(residuals, ddof=1) * np.sqrt(252)
|
||||||
|
|
||||||
|
# 62. sector_independence_ratio_20d: 1 - R²
|
||||||
|
feat['sector_independence_ratio_20d'] = 1 - r_value ** 2
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
feat['sector_residual_volatility_20d'] = 0
|
||||||
|
feat['sector_independence_ratio_20d'] = 1
|
||||||
|
|
||||||
|
# 63. sector_amplitude_deviation_20d: 个股振幅 - 行业平均振幅
|
||||||
|
g_amps = (g['high'].values[-20:] - g['low'].values[-20:]) / np.where(
|
||||||
|
g['open'].values[-20:] > 0, g['open'].values[-20:], 1
|
||||||
|
)
|
||||||
|
sector_avg_amp = np.mean(sector_amps[-20:]) if len(sector_amps) >= 20 else np.mean(sector_amps)
|
||||||
|
feat['sector_amplitude_deviation_20d'] = np.mean(g_amps) - sector_avg_amp
|
||||||
|
|
||||||
|
features[code] = feat
|
||||||
|
|
||||||
|
return pd.DataFrame.from_dict(features, orient='index')
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
基础特征 v3.2 (20维) — calculate_features(df, "v3.2")
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from core.scoring.config import GRID_LOW, GRID_HIGH
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_features_v3_2(ctx) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
计算 v3.2 基础特征 (20维)。
|
||||||
|
返回 DataFrame indexed by stock_code。
|
||||||
|
"""
|
||||||
|
kline = ctx.kline.copy()
|
||||||
|
if kline.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
kline = kline.sort_values(['stock_code', 'trade_date'])
|
||||||
|
candidates = ctx.candidates
|
||||||
|
|
||||||
|
features = {}
|
||||||
|
grouped = kline.groupby('stock_code')
|
||||||
|
|
||||||
|
for code, group in grouped:
|
||||||
|
if code not in candidates:
|
||||||
|
continue
|
||||||
|
g = group.tail(120) # 取最近120日用于大部分窗口
|
||||||
|
if len(g) < 20:
|
||||||
|
continue
|
||||||
|
|
||||||
|
closes = g['close'].values
|
||||||
|
opens = g['open'].values
|
||||||
|
highs = g['high'].values
|
||||||
|
lows = g['low'].values
|
||||||
|
volumes = g['volume'].values
|
||||||
|
|
||||||
|
feat = {}
|
||||||
|
|
||||||
|
# 1. rolling_grid_ratio_20d: mean(close∈[1,11]) × 100
|
||||||
|
recent_20_close = closes[-20:]
|
||||||
|
feat['rolling_grid_ratio_20d'] = (
|
||||||
|
np.mean((recent_20_close >= GRID_LOW) & (recent_20_close <= GRID_HIGH)) * 100
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. cross_freq_20d: 日高低区间穿越≥1条整数网格线的天数比例
|
||||||
|
cross_count = 0
|
||||||
|
for i in range(max(0, len(g) - 20), len(g)):
|
||||||
|
h, l = highs[i], lows[i]
|
||||||
|
if h > l:
|
||||||
|
grid_low = int(np.ceil(l))
|
||||||
|
grid_high = int(np.floor(h))
|
||||||
|
if grid_high >= grid_low:
|
||||||
|
cross_count += 1
|
||||||
|
feat['cross_freq_20d'] = cross_count / min(20, len(g)) * 100
|
||||||
|
|
||||||
|
# 3. avg_daily_amp: mean((high-low)/open × 100)
|
||||||
|
amps = (highs - lows) / np.where(opens > 0, opens, 1) * 100
|
||||||
|
feat['avg_daily_amp'] = np.mean(amps[-20:])
|
||||||
|
|
||||||
|
# 4. high_amp_days: mean(振幅>2%) × 100
|
||||||
|
feat['high_amp_days'] = np.mean(amps[-20:] > 2) * 100
|
||||||
|
|
||||||
|
# 5. atr_pct: mean(ATR_14)/close × 100
|
||||||
|
atr = _compute_atr(highs, lows, closes, 14)
|
||||||
|
feat['atr_pct'] = np.mean(atr[-14:]) / closes[-1] * 100 if closes[-1] > 0 else 0
|
||||||
|
|
||||||
|
# 6. volatility_20d: std(log_return) × √252 × 100
|
||||||
|
log_rets = np.diff(np.log(np.maximum(closes, 1e-10)))
|
||||||
|
vol_20 = np.std(log_rets[-20:], ddof=1) if len(log_rets) >= 20 else 0
|
||||||
|
feat['volatility_20d'] = vol_20 * np.sqrt(252) * 100
|
||||||
|
|
||||||
|
# 7. price_cv: std(close)/mean(close) × 100
|
||||||
|
feat['price_cv'] = np.std(closes) / np.mean(closes) * 100 if np.mean(closes) > 0 else 0
|
||||||
|
|
||||||
|
# 8. bb_width: (MA20+2σ - (MA20-2σ))/MA20 × 100
|
||||||
|
ma20 = np.mean(closes[-20:])
|
||||||
|
std20 = np.std(closes[-20:], ddof=1)
|
||||||
|
feat['bb_width'] = (4 * std20) / ma20 * 100 if ma20 > 0 else 0
|
||||||
|
|
||||||
|
# 9. volume_ratio: mean(vol_20d)/mean(vol_60d)
|
||||||
|
vol_20m = np.mean(volumes[-20:])
|
||||||
|
vol_60m = np.mean(volumes[-60:]) if len(volumes) >= 60 else vol_20m
|
||||||
|
feat['volume_ratio'] = vol_20m / vol_60m if vol_60m > 0 else 1
|
||||||
|
|
||||||
|
# 10. obv_slope: OBV序列最近20日线性回归斜率
|
||||||
|
obv = _compute_obv(closes, volumes)
|
||||||
|
feat['obv_slope'] = _ols_slope(np.arange(20), obv[-20:])[0] if len(obv) >= 20 else 0
|
||||||
|
|
||||||
|
# 11. amplitude_cv: std(振幅)/mean(振幅)
|
||||||
|
feat['amplitude_cv'] = (np.std(amps[-20:]) / np.mean(amps[-20:])
|
||||||
|
if np.mean(amps[-20:]) > 0 else 0)
|
||||||
|
|
||||||
|
# 12. price_entropy: Shannon熵 (10 bins)
|
||||||
|
feat['price_entropy'] = _shannon_entropy(closes[-60:], bins=10)
|
||||||
|
|
||||||
|
# 13. intraday_trend_strength: mean(|close-open|/open) × 100
|
||||||
|
intraday = np.abs(closes[-20:] - opens[-20:]) / np.where(opens[-20:] > 0, opens[-20:], 1) * 100
|
||||||
|
feat['intraday_trend_strength'] = np.mean(intraday)
|
||||||
|
|
||||||
|
# 14-17. 交叉特征 (依赖前序特征)
|
||||||
|
feat['amp_x_grid'] = feat['avg_daily_amp'] * feat['rolling_grid_ratio_20d'] / 100
|
||||||
|
feat['amp_x_grid_vol'] = (feat['avg_daily_amp'] * feat['rolling_grid_ratio_20d'] / 100 *
|
||||||
|
feat['volume_ratio'] / 10000)
|
||||||
|
feat['amp_cv_x_entropy'] = feat['amplitude_cv'] * feat['price_entropy']
|
||||||
|
feat['cross_freq_x_bb'] = feat['cross_freq_20d'] * feat['bb_width'] / 100
|
||||||
|
|
||||||
|
# 18. ln_float_mv: ln(close × total_share + 1)
|
||||||
|
total_share = _get_total_share(ctx, code)
|
||||||
|
float_mv = closes[-1] * total_share if total_share else closes[-1] * 1e8
|
||||||
|
feat['ln_float_mv'] = np.log(float_mv + 1)
|
||||||
|
|
||||||
|
# 19. mv_vol_interact: ln_float_mv × volatility_20d/100
|
||||||
|
feat['mv_vol_interact'] = feat['ln_float_mv'] * feat['volatility_20d'] / 100
|
||||||
|
|
||||||
|
# 20. small_cap_premium: 1/(浮动市值 + 1)
|
||||||
|
feat['small_cap_premium'] = 1.0 / (float_mv + 1)
|
||||||
|
|
||||||
|
features[code] = feat
|
||||||
|
|
||||||
|
return pd.DataFrame.from_dict(features, orient='index')
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 辅助函数 ----
|
||||||
|
|
||||||
|
def _compute_atr(highs, lows, closes, period=14):
|
||||||
|
"""计算 ATR"""
|
||||||
|
n = len(closes)
|
||||||
|
tr = np.zeros(n)
|
||||||
|
for i in range(1, n):
|
||||||
|
h_l = highs[i] - lows[i]
|
||||||
|
h_c = abs(highs[i] - closes[i - 1])
|
||||||
|
l_c = abs(lows[i] - closes[i - 1])
|
||||||
|
tr[i] = max(h_l, h_c, l_c)
|
||||||
|
atr = np.zeros(n)
|
||||||
|
atr[:period] = np.mean(tr[:period])
|
||||||
|
for i in range(period, n):
|
||||||
|
atr[i] = (atr[i - 1] * (period - 1) + tr[i]) / period
|
||||||
|
return atr
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_obv(closes, volumes):
|
||||||
|
"""计算 OBV"""
|
||||||
|
obv = np.zeros(len(closes))
|
||||||
|
obv[0] = volumes[0]
|
||||||
|
for i in range(1, len(closes)):
|
||||||
|
if closes[i] > closes[i - 1]:
|
||||||
|
obv[i] = obv[i - 1] + volumes[i]
|
||||||
|
elif closes[i] < closes[i - 1]:
|
||||||
|
obv[i] = obv[i - 1] - volumes[i]
|
||||||
|
else:
|
||||||
|
obv[i] = obv[i - 1]
|
||||||
|
return obv
|
||||||
|
|
||||||
|
|
||||||
|
def _shannon_entropy(values, bins=10):
|
||||||
|
"""Shannon 熵"""
|
||||||
|
if len(values) < bins:
|
||||||
|
return 0.0
|
||||||
|
hist, _ = np.histogram(values, bins=bins)
|
||||||
|
hist = hist / hist.sum()
|
||||||
|
hist = hist[hist > 0]
|
||||||
|
return -np.sum(hist * np.log2(hist))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_total_share(ctx, code):
|
||||||
|
"""从 StockInfo 获取总股本"""
|
||||||
|
# 尝试各种前缀
|
||||||
|
for prefix in ['', 'SH', 'SZ', 'BJ']:
|
||||||
|
key = f'{code}.{prefix}' if prefix else code
|
||||||
|
info = ctx.stock_info.get(key, {})
|
||||||
|
ts = info.get('total_share', None)
|
||||||
|
if ts and ts > 0:
|
||||||
|
return ts
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ols_slope(x, y):
|
||||||
|
"""
|
||||||
|
纯 numpy OLS 线性回归。
|
||||||
|
等价于 scipy.stats.linregress(x, y),返回 (slope, r_value)。
|
||||||
|
slope=0 且 r_value=0 表示计算失败(数据不足或方差为零)。
|
||||||
|
"""
|
||||||
|
if len(x) < 2 or len(y) < 2 or len(x) != len(y):
|
||||||
|
return 0.0, 0.0
|
||||||
|
x = np.asarray(x, dtype=float)
|
||||||
|
y = np.asarray(y, dtype=float)
|
||||||
|
x_mean = x.mean()
|
||||||
|
y_mean = y.mean()
|
||||||
|
num = np.sum((x - x_mean) * (y - y_mean))
|
||||||
|
den = np.sum((x - x_mean) ** 2)
|
||||||
|
if den < 1e-12:
|
||||||
|
return 0.0, 0.0
|
||||||
|
slope = num / den
|
||||||
|
ss_xy = num
|
||||||
|
ss_xx = np.sum((x - x_mean) ** 2)
|
||||||
|
ss_yy = np.sum((y - y_mean) ** 2)
|
||||||
|
if ss_xx < 1e-12 or ss_yy < 1e-12:
|
||||||
|
r_value = 0.0
|
||||||
|
else:
|
||||||
|
r_value = ss_xy / (np.sqrt(ss_xx) * np.sqrt(ss_yy))
|
||||||
|
return slope, r_value
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
扩展特征 v3.3 (16维)
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from core.scoring.features.v3_2_features import _ols_slope
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_features_v3_3(ctx) -> pd.DataFrame:
|
||||||
|
"""计算 v3.3 扩展特征 (16维)"""
|
||||||
|
kline = ctx.kline.copy()
|
||||||
|
if kline.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
kline = kline.sort_values(['stock_code', 'trade_date'])
|
||||||
|
candidates = ctx.candidates
|
||||||
|
|
||||||
|
features = {}
|
||||||
|
grouped = kline.groupby('stock_code')
|
||||||
|
|
||||||
|
for code, group in grouped:
|
||||||
|
if code not in candidates:
|
||||||
|
continue
|
||||||
|
g = group.tail(120)
|
||||||
|
if len(g) < 20:
|
||||||
|
continue
|
||||||
|
|
||||||
|
closes = g['close'].values
|
||||||
|
highs = g['high'].values
|
||||||
|
lows = g['low'].values
|
||||||
|
volumes = g['volume'].values
|
||||||
|
|
||||||
|
feat = {}
|
||||||
|
|
||||||
|
# 21. grid_touch_count_20d: 高低区间触碰网格线总次数
|
||||||
|
feat['grid_touch_count_20d'] = _grid_touch_count(highs[-20:], lows[-20:])
|
||||||
|
|
||||||
|
# 22. grid_touch_count_60d
|
||||||
|
h60 = highs[-60:] if len(highs) >= 60 else highs
|
||||||
|
l60 = lows[-60:] if len(lows) >= 60 else lows
|
||||||
|
feat['grid_touch_count_60d'] = _grid_touch_count(h60, l60)
|
||||||
|
|
||||||
|
# 23. grid_cross_density_20d
|
||||||
|
cross_count = 0
|
||||||
|
w = min(20, len(g))
|
||||||
|
for i in range(len(g) - w, len(g)):
|
||||||
|
h, l = highs[i], lows[i]
|
||||||
|
if h > l and int(np.floor(h)) >= int(np.ceil(l)):
|
||||||
|
cross_count += 1
|
||||||
|
feat['grid_cross_density_20d'] = cross_count / w
|
||||||
|
|
||||||
|
# 24. near_grid_line_ratio_20d
|
||||||
|
recent_closes = closes[-20:]
|
||||||
|
dist_to_int = np.abs(recent_closes - np.round(recent_closes))
|
||||||
|
feat['near_grid_line_ratio_20d'] = np.mean(dist_to_int <= 0.15) * 100
|
||||||
|
|
||||||
|
# 25. trend_slope_20d
|
||||||
|
feat['trend_slope_20d'] = _ols_slope(np.arange(20), closes[-20:])[0]
|
||||||
|
|
||||||
|
# 26. trend_slope_60d
|
||||||
|
c60 = closes[-60:] if len(closes) >= 60 else closes
|
||||||
|
feat['trend_slope_60d'] = _ols_slope(np.arange(len(c60)), c60)[0]
|
||||||
|
|
||||||
|
# 27. trend_abs_slope_20d
|
||||||
|
feat['trend_abs_slope_20d'] = abs(feat['trend_slope_20d'])
|
||||||
|
|
||||||
|
# 28. range_position_60d
|
||||||
|
h60_mx = np.max(highs[-60:]) if len(highs) >= 60 else np.max(highs)
|
||||||
|
l60_mn = np.min(lows[-60:]) if len(lows) >= 60 else np.min(lows)
|
||||||
|
feat['range_position_60d'] = (closes[-1] - l60_mn) / (h60_mx - l60_mn) * 100 \
|
||||||
|
if h60_mx > l60_mn else 50
|
||||||
|
|
||||||
|
# 29. drawdown_60d
|
||||||
|
c60_arr = closes[-60:] if len(closes) >= 60 else closes
|
||||||
|
cummax = np.maximum.accumulate(c60_arr)
|
||||||
|
dd = (1 - c60_arr / cummax) * 100
|
||||||
|
feat['drawdown_60d'] = np.max(dd)
|
||||||
|
|
||||||
|
# 30. rebound_from_low_60d
|
||||||
|
feat['rebound_from_low_60d'] = (closes[-1] - l60_mn) / l60_mn * 100 if l60_mn > 0 else 0
|
||||||
|
|
||||||
|
# 31. dist_to_grid_upper
|
||||||
|
from core.scoring.config import GRID_HIGH
|
||||||
|
feat['dist_to_grid_upper'] = max(0, (GRID_HIGH - closes[-1]) / 10 * 100)
|
||||||
|
|
||||||
|
# 32. dist_to_grid_lower
|
||||||
|
from core.scoring.config import GRID_LOW
|
||||||
|
feat['dist_to_grid_lower'] = max(0, (closes[-1] - GRID_LOW) / 10 * 100)
|
||||||
|
|
||||||
|
# 33. grid_room_balance
|
||||||
|
upper = feat['dist_to_grid_upper']
|
||||||
|
lower = feat['dist_to_grid_lower']
|
||||||
|
feat['grid_room_balance'] = min(upper, lower) / (upper + lower) * 100 \
|
||||||
|
if (upper + lower) > 0 else 50
|
||||||
|
|
||||||
|
# 34. amount_mean_20d
|
||||||
|
amounts = closes[-20:] * volumes[-20:]
|
||||||
|
feat['amount_mean_20d'] = np.mean(amounts)
|
||||||
|
|
||||||
|
# 35. amount_cv_20d
|
||||||
|
feat['amount_cv_20d'] = np.std(amounts) / np.mean(amounts) * 100 \
|
||||||
|
if np.mean(amounts) > 0 else 0
|
||||||
|
|
||||||
|
# 36. turnover_proxy_20d
|
||||||
|
total_share = _get_total_share(ctx, code) or 1e8
|
||||||
|
feat['turnover_proxy_20d'] = np.mean(volumes[-20:]) / (total_share * 1e8) * 100
|
||||||
|
|
||||||
|
features[code] = feat
|
||||||
|
|
||||||
|
return pd.DataFrame.from_dict(features, orient='index')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_total_share(ctx, code):
|
||||||
|
for prefix in ['', 'SH', 'SZ', 'BJ']:
|
||||||
|
key = f'{code}.{prefix}' if prefix else code
|
||||||
|
info = ctx.stock_info.get(key, {})
|
||||||
|
ts = info.get('total_share', None)
|
||||||
|
if ts and ts > 0:
|
||||||
|
return ts
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _grid_touch_count(highs, lows):
|
||||||
|
count = 0
|
||||||
|
for h, l in zip(highs, lows):
|
||||||
|
if h > l:
|
||||||
|
grid_low = int(np.ceil(l))
|
||||||
|
grid_high = int(np.floor(h))
|
||||||
|
count += max(0, grid_high - grid_low + 1)
|
||||||
|
return count
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
扩展特征 v3.4 + v6.7新增 (20维: 16维 v3.4 + 4维 v6.7新增)
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from core.scoring.features.v3_2_features import _ols_slope
|
||||||
|
from core.scoring.features.v3_3_features import _grid_touch_count
|
||||||
|
from core.scoring.config import GRID_LOW, GRID_HIGH
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_features_v3_4(ctx) -> pd.DataFrame:
|
||||||
|
"""计算 v3.4 扩展特征 (16维)"""
|
||||||
|
kline = ctx.kline.copy()
|
||||||
|
if kline.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
kline = kline.sort_values(['stock_code', 'trade_date'])
|
||||||
|
candidates = ctx.candidates
|
||||||
|
|
||||||
|
features = {}
|
||||||
|
grouped = kline.groupby('stock_code')
|
||||||
|
|
||||||
|
for code, group in grouped:
|
||||||
|
if code not in candidates:
|
||||||
|
continue
|
||||||
|
g = group.tail(120)
|
||||||
|
if len(g) < 20:
|
||||||
|
continue
|
||||||
|
|
||||||
|
closes = g['close'].values
|
||||||
|
opens = g['open'].values
|
||||||
|
highs = g['high'].values
|
||||||
|
lows = g['low'].values
|
||||||
|
volumes = g['volume'].values
|
||||||
|
|
||||||
|
feat = {}
|
||||||
|
|
||||||
|
# 37. down_days_20d
|
||||||
|
rets_20 = np.diff(closes[-21:]) / closes[-21:-1]
|
||||||
|
feat['down_days_20d'] = np.mean(rets_20 < 0) * 100
|
||||||
|
|
||||||
|
# 38. up_days_20d
|
||||||
|
feat['up_days_20d'] = np.mean(rets_20 > 0) * 100
|
||||||
|
|
||||||
|
# 39. trend_consistency_20d
|
||||||
|
feat['trend_consistency_20d'] = max(feat['up_days_20d'], feat['down_days_20d'])
|
||||||
|
|
||||||
|
# 40. ma20_deviation_pct
|
||||||
|
ma20 = np.mean(closes[-20:])
|
||||||
|
feat['ma20_deviation_pct'] = (closes[-1] - ma20) / ma20 * 100 if ma20 > 0 else 0
|
||||||
|
|
||||||
|
# 41. ma60_deviation_pct
|
||||||
|
ma60 = np.mean(closes[-60:]) if len(closes) >= 60 else np.mean(closes)
|
||||||
|
feat['ma60_deviation_pct'] = (closes[-1] - ma60) / ma60 * 100 if ma60 > 0 else 0
|
||||||
|
|
||||||
|
# 42. ma20_ma60_gap_pct
|
||||||
|
feat['ma20_ma60_gap_pct'] = (ma20 - ma60) / ma60 * 100 if ma60 > 0 else 0
|
||||||
|
|
||||||
|
# 43. usable_grid_count_upper
|
||||||
|
feat['usable_grid_count_upper'] = sum(
|
||||||
|
1 for g in range(GRID_LOW, GRID_HIGH + 1) if g > closes[-1])
|
||||||
|
|
||||||
|
# 44. usable_grid_count_lower
|
||||||
|
feat['usable_grid_count_lower'] = sum(
|
||||||
|
1 for g in range(GRID_LOW, GRID_HIGH + 1) if g < closes[-1])
|
||||||
|
|
||||||
|
# 45. near_upper_boundary_risk
|
||||||
|
feat['near_upper_boundary_risk'] = max(0, min(100, (closes[-1] - 9) / 2 * 100))
|
||||||
|
|
||||||
|
# 46. near_lower_boundary_risk
|
||||||
|
feat['near_lower_boundary_risk'] = max(0, min(100, (3 - closes[-1]) / 2 * 100))
|
||||||
|
|
||||||
|
# 47. volume_cv_20d
|
||||||
|
vol_20 = volumes[-20:]
|
||||||
|
feat['volume_cv_20d'] = np.std(vol_20) / np.mean(vol_20) * 100 \
|
||||||
|
if np.mean(vol_20) > 0 else 0
|
||||||
|
|
||||||
|
# 48. amount_trend_20d
|
||||||
|
amounts = closes[-20:] * volumes[-20:]
|
||||||
|
feat['amount_trend_20d'] = _ols_slope(np.arange(len(amounts)), amounts)[0]
|
||||||
|
|
||||||
|
# 49. low_volume_days_20d
|
||||||
|
mean_vol = np.mean(vol_20)
|
||||||
|
feat['low_volume_days_20d'] = np.mean(vol_20 < 0.5 * mean_vol) * 100
|
||||||
|
|
||||||
|
# 50. close_reversal_count_20d
|
||||||
|
rets_sign = np.sign(np.diff(closes[-21:]))
|
||||||
|
reversals = sum(
|
||||||
|
1 for i in range(1, len(rets_sign))
|
||||||
|
if rets_sign[i] != 0 and rets_sign[i - 1] != 0 and rets_sign[i] != rets_sign[i - 1])
|
||||||
|
feat['close_reversal_count_20d'] = reversals
|
||||||
|
|
||||||
|
# 51. range_compression_20d
|
||||||
|
amps = (highs - lows) / np.where(closes > 0, closes, 1) * 100
|
||||||
|
amp_20_mean = np.mean(amps[-20:])
|
||||||
|
amp_60_mean = np.mean(amps[-60:]) if len(amps) >= 60 else amp_20_mean
|
||||||
|
feat['range_compression_20d'] = amp_20_mean / amp_60_mean * 100 if amp_60_mean > 0 else 100
|
||||||
|
|
||||||
|
# 52. wick_ratio_20d
|
||||||
|
upper_wick = highs[-20:] - np.maximum(opens[-20:], closes[-20:])
|
||||||
|
lower_wick = np.minimum(opens[-20:], closes[-20:]) - lows[-20:]
|
||||||
|
body = np.abs(closes[-20:] - opens[-20:])
|
||||||
|
total_len = highs[-20:] - lows[-20:]
|
||||||
|
wick_len = upper_wick + lower_wick
|
||||||
|
feat['wick_ratio_20d'] = np.mean(
|
||||||
|
wick_len / np.where(total_len > 0, total_len, 1)) * 100
|
||||||
|
|
||||||
|
# ── v6.7 新增 4 维特征 ──────────────────────────────
|
||||||
|
# 53. vol_decay_5d: 近5日波动率 / 近20日波动率 (波动率用对数收益std)
|
||||||
|
log_ret = np.diff(np.log(np.maximum(closes, 1e-10)))
|
||||||
|
vol_5d = np.std(log_ret[-5:], ddof=1) if len(log_ret) >= 5 else 0
|
||||||
|
vol_20d = np.std(log_ret[-20:], ddof=1) if len(log_ret) >= 20 else vol_5d
|
||||||
|
feat['vol_decay_5d'] = float(vol_5d / vol_20d) if vol_20d > 0 else 0.0
|
||||||
|
|
||||||
|
# 54. grid_touch_relative_10d: 10日振幅比 / 60日振幅比
|
||||||
|
range_10d = float(np.max(highs[-10:]) - np.min(lows[-10:]))
|
||||||
|
range_60d = float(np.max(highs[-60:]) - np.min(lows[-60:])) if len(highs) >= 60 else range_10d
|
||||||
|
close_now = float(closes[-1])
|
||||||
|
close_60d_mean = float(np.mean(closes[-60:])) if len(closes) >= 60 else close_now
|
||||||
|
if close_now > 0 and close_60d_mean > 0 and range_60d > 0:
|
||||||
|
feat['grid_touch_relative_10d'] = (range_10d / close_now) / (range_60d / close_60d_mean)
|
||||||
|
else:
|
||||||
|
feat['grid_touch_relative_10d'] = 0.0
|
||||||
|
|
||||||
|
# 55. vol_decay_x_grid_balance: vol_decay × 网格均衡度
|
||||||
|
feat['vol_decay_x_grid_balance'] = feat['vol_decay_5d'] * feat.get('grid_room_balance', 0.0)
|
||||||
|
|
||||||
|
# 56. vol_decay_x_dist_lower: vol_decay × 下轨距离
|
||||||
|
feat['vol_decay_x_dist_lower'] = feat['vol_decay_5d'] * feat.get('dist_to_grid_lower', 0.0)
|
||||||
|
|
||||||
|
features[code] = feat
|
||||||
|
|
||||||
|
return pd.DataFrame.from_dict(features, orient='index')
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""
|
||||||
|
候选股过滤与数据加载
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from core.scoring.models import (
|
||||||
|
KlineStock, StockInfo, IndustryMapping,
|
||||||
|
KlineIndex, MarketRegimeDaily, SectorFeaturesDaily,
|
||||||
|
)
|
||||||
|
from core.scoring.config import (
|
||||||
|
FILTER_MIN_CLOSE, FILTER_MAX_CLOSE, REQUIRE_DAYS,
|
||||||
|
WINDOW_180D, HS300_CODE,
|
||||||
|
)
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
|
||||||
|
|
||||||
|
class DataContext:
|
||||||
|
"""特征计算所需的全部数据上下文"""
|
||||||
|
|
||||||
|
def __init__(self, trade_date: date):
|
||||||
|
self.trade_date = trade_date
|
||||||
|
self.require_days = REQUIRE_DAYS
|
||||||
|
# 原始数据
|
||||||
|
self.kline: pd.DataFrame = None # 候选股 K线 (180d)
|
||||||
|
self.stock_info: dict = {} # code → StockInfo dict
|
||||||
|
self.industry_map: dict = {} # code → industry_name
|
||||||
|
self.hs300_kline: pd.DataFrame = None # HS300 K线 (180d)
|
||||||
|
self.market_regime: pd.DataFrame = None # 市场状态 (180d)
|
||||||
|
self.sector_features: pd.DataFrame = None # 行业指数 (180d)
|
||||||
|
# 候选股列表
|
||||||
|
self.candidates: list[str] = []
|
||||||
|
self.excluded: dict[str, str] = {} # code → reason
|
||||||
|
|
||||||
|
|
||||||
|
def _get_stock_codes_for_date(trade_date: date) -> list:
|
||||||
|
"""获取评分日所有符合条件的股票代码(非ST/退市)"""
|
||||||
|
rows = (StockInfo
|
||||||
|
.select(StockInfo.code, StockInfo.listing_status)
|
||||||
|
.where(StockInfo.listing_status.not_in(['delisted', 'ST']))
|
||||||
|
.dicts())
|
||||||
|
return [row['code'] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _check_kline_sufficiency(stock_code: str, trade_date: date) -> tuple[bool, str, float]:
|
||||||
|
"""检查单只股票的K线数据是否满足评分条件"""
|
||||||
|
# 查询最近 REQUIRE_DAYS + 30 (留缓冲) 个交易日
|
||||||
|
start = trade_date - timedelta(days=(REQUIRE_DAYS + 60) * 2)
|
||||||
|
rows = (KlineStock
|
||||||
|
.select(KlineStock.trade_date, KlineStock.close)
|
||||||
|
.where(
|
||||||
|
(KlineStock.stock_code == stock_code) &
|
||||||
|
(KlineStock.trade_date >= start) &
|
||||||
|
(KlineStock.trade_date <= trade_date)
|
||||||
|
)
|
||||||
|
.order_by(KlineStock.trade_date.desc())
|
||||||
|
.dicts())
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return False, '无K线数据', 0
|
||||||
|
|
||||||
|
# 过滤有效收盘价 (close > 0)
|
||||||
|
valid_rows = [r for r in rows if r['close'] and r['close'] > 0]
|
||||||
|
if len(valid_rows) < REQUIRE_DAYS:
|
||||||
|
return False, f'交易天数不足({len(valid_rows)}<{REQUIRE_DAYS})', 0
|
||||||
|
|
||||||
|
latest_close = valid_rows[0]['close']
|
||||||
|
|
||||||
|
# 价格区间检查
|
||||||
|
if latest_close < FILTER_MIN_CLOSE:
|
||||||
|
return False, f'价格过低({latest_close:.2f}<{FILTER_MIN_CLOSE})', latest_close
|
||||||
|
if latest_close > FILTER_MAX_CLOSE:
|
||||||
|
return False, f'价格过高({latest_close:.2f}>{FILTER_MAX_CLOSE})', latest_close
|
||||||
|
|
||||||
|
return True, '', latest_close
|
||||||
|
|
||||||
|
|
||||||
|
def load_candidates(trade_date: date) -> DataContext:
|
||||||
|
"""
|
||||||
|
加载评分日候选股及全部所需数据。
|
||||||
|
返回 DataContext,包含候选股列表和预加载的原始数据。
|
||||||
|
"""
|
||||||
|
ctx = DataContext(trade_date)
|
||||||
|
start_180 = trade_date - timedelta(days=365) # 取约1年数据覆盖180个交易日
|
||||||
|
|
||||||
|
# 1. 获取非ST/退市的全部股票
|
||||||
|
all_codes = [r.split('.')[0] for r in _get_stock_codes_for_date(trade_date)]
|
||||||
|
PrintLog(LogLevel.INFO, f'[validator] 全市场有效股票: {len(all_codes)} 只')
|
||||||
|
|
||||||
|
# 2. 批量加载 KlineStock (180d 窗口)
|
||||||
|
raw_codes = all_codes # 使用纯数字代码查询
|
||||||
|
kline_rows = (KlineStock
|
||||||
|
.select()
|
||||||
|
.where(
|
||||||
|
(KlineStock.stock_code.in_(raw_codes)) &
|
||||||
|
(KlineStock.trade_date >= start_180) &
|
||||||
|
(KlineStock.trade_date <= trade_date)
|
||||||
|
)
|
||||||
|
.order_by(KlineStock.stock_code, KlineStock.trade_date)
|
||||||
|
.dicts())
|
||||||
|
|
||||||
|
kline_df = pd.DataFrame(kline_rows)
|
||||||
|
if kline_df.empty:
|
||||||
|
PrintLog(LogLevel.WARNING, '[validator] KlineStock 无数据')
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
kline_df['trade_date'] = pd.to_datetime(kline_df['trade_date'])
|
||||||
|
PrintLog(LogLevel.INFO, f'[validator] K线原始数据: {len(kline_df)} 行')
|
||||||
|
|
||||||
|
# 3. 逐股过滤
|
||||||
|
candidates = []
|
||||||
|
excluded = {}
|
||||||
|
grouped = kline_df.groupby('stock_code')
|
||||||
|
for code, group in grouped:
|
||||||
|
g_sorted = group.sort_values('trade_date', ascending=False)
|
||||||
|
valid_rows = g_sorted[g_sorted['close'].notna() & (g_sorted['close'] > 0)]
|
||||||
|
if len(valid_rows) < REQUIRE_DAYS:
|
||||||
|
excluded[code] = f'交易天数不足({len(valid_rows)}<{REQUIRE_DAYS})'
|
||||||
|
continue
|
||||||
|
latest_close = valid_rows.iloc[0]['close']
|
||||||
|
if latest_close < FILTER_MIN_CLOSE:
|
||||||
|
excluded[code] = f'价格过低({latest_close:.2f}<{FILTER_MIN_CLOSE})'
|
||||||
|
continue
|
||||||
|
if latest_close > FILTER_MAX_CLOSE:
|
||||||
|
excluded[code] = f'价格过高({latest_close:.2f}>{FILTER_MAX_CLOSE})'
|
||||||
|
continue
|
||||||
|
candidates.append(code)
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[validator] 候选: {len(candidates)} 通过, {len(excluded)} 排除')
|
||||||
|
|
||||||
|
# 3.5 补充:强制加入网格持仓股(不受价格过滤限制)
|
||||||
|
from core.sfgrid.model import SFGridTradeTarget
|
||||||
|
pos_rows = list(SFGridTradeTarget
|
||||||
|
.select(SFGridTradeTarget.stock_code)
|
||||||
|
.where(SFGridTradeTarget.enabled == True)
|
||||||
|
.dicts())
|
||||||
|
pos_codes = [r['stock_code'].split('.')[0] for r in pos_rows]
|
||||||
|
forced = [c for c in pos_codes if c not in candidates and c not in excluded]
|
||||||
|
if forced:
|
||||||
|
PrintLog(LogLevel.INFO, f'[validator] 强制加入网格持仓股: {forced}')
|
||||||
|
candidates.extend(forced)
|
||||||
|
|
||||||
|
# 4. 裁剪K线到只含候选股 (保留最近180日)
|
||||||
|
kline_df = kline_df[kline_df['stock_code'].isin(candidates)].copy()
|
||||||
|
cut_date = trade_date - timedelta(days=365)
|
||||||
|
kline_df = kline_df[kline_df['trade_date'] >= pd.Timestamp(cut_date)]
|
||||||
|
|
||||||
|
ctx.kline = kline_df
|
||||||
|
ctx.candidates = candidates
|
||||||
|
ctx.excluded = excluded
|
||||||
|
|
||||||
|
# 5. 加载 StockInfo
|
||||||
|
stock_rows = (StockInfo
|
||||||
|
.select()
|
||||||
|
.where(StockInfo.code.in_([f'{c}.SH' for c in candidates] +
|
||||||
|
[f'{c}.SZ' for c in candidates] +
|
||||||
|
[f'{c}.BJ' for c in candidates]))
|
||||||
|
.dicts())
|
||||||
|
ctx.stock_info = {r['code']: r for r in stock_rows}
|
||||||
|
|
||||||
|
# 6. 加载行业映射: code → industry_name
|
||||||
|
ind_rows = (IndustryMapping
|
||||||
|
.select()
|
||||||
|
.where(IndustryMapping.code.in_(candidates))
|
||||||
|
.dicts())
|
||||||
|
ctx.industry_map = {r['code']: r['industry_name'] for r in ind_rows}
|
||||||
|
PrintLog(LogLevel.INFO, f'[validator] 行业映射: {len(ctx.industry_map)} 只')
|
||||||
|
|
||||||
|
# 7. 加载 HS300 K线
|
||||||
|
hs300_rows = (KlineIndex
|
||||||
|
.select()
|
||||||
|
.where(
|
||||||
|
(KlineIndex.index_code == HS300_CODE) &
|
||||||
|
(KlineIndex.trade_date >= start_180) &
|
||||||
|
(KlineIndex.trade_date <= trade_date)
|
||||||
|
)
|
||||||
|
.order_by(KlineIndex.trade_date)
|
||||||
|
.dicts())
|
||||||
|
ctx.hs300_kline = pd.DataFrame(hs300_rows)
|
||||||
|
if not ctx.hs300_kline.empty:
|
||||||
|
ctx.hs300_kline['trade_date'] = pd.to_datetime(ctx.hs300_kline['trade_date'])
|
||||||
|
|
||||||
|
# 8. 加载市场状态 (180d)
|
||||||
|
mkt_rows = (MarketRegimeDaily
|
||||||
|
.select()
|
||||||
|
.where(
|
||||||
|
(MarketRegimeDaily.trade_date >= start_180) &
|
||||||
|
(MarketRegimeDaily.trade_date <= trade_date)
|
||||||
|
)
|
||||||
|
.order_by(MarketRegimeDaily.trade_date)
|
||||||
|
.dicts())
|
||||||
|
ctx.market_regime = pd.DataFrame(mkt_rows)
|
||||||
|
if not ctx.market_regime.empty:
|
||||||
|
ctx.market_regime['trade_date'] = pd.to_datetime(ctx.market_regime['trade_date'])
|
||||||
|
|
||||||
|
# 9. 加载行业指数 (180d)
|
||||||
|
sec_rows = (SectorFeaturesDaily
|
||||||
|
.select()
|
||||||
|
.where(
|
||||||
|
(SectorFeaturesDaily.trade_date >= start_180) &
|
||||||
|
(SectorFeaturesDaily.trade_date <= trade_date)
|
||||||
|
)
|
||||||
|
.order_by(SectorFeaturesDaily.trade_date)
|
||||||
|
.dicts())
|
||||||
|
ctx.sector_features = pd.DataFrame(sec_rows)
|
||||||
|
if not ctx.sector_features.empty:
|
||||||
|
ctx.sector_features['trade_date'] = pd.to_datetime(ctx.sector_features['trade_date'])
|
||||||
|
|
||||||
|
return ctx
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 模型推理子包
|
||||||
|
from core.scoring.inference.scorer import GridSeekerPipeline
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""
|
||||||
|
grid_seeker v6.7r3 三级模型推理管道
|
||||||
|
Rank → Top → Stacking → stacking_probability (最终排序)
|
||||||
|
"""
|
||||||
|
import pickle
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import date
|
||||||
|
from core.scoring.config import (
|
||||||
|
get_model_path, RANK_MODEL, TOP_MODEL, STACKING_MODEL,
|
||||||
|
STACKING_THRESHOLD,
|
||||||
|
)
|
||||||
|
from core.scoring.features.pipeline import FeaturePipeline
|
||||||
|
from core.scoring.models import ScoringResult
|
||||||
|
from core.database import db
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Rank 模型输入特征 (56维 v6.7/v3.4, 同时支持 feat_names 和 selected_features)
|
||||||
|
# ============================================================
|
||||||
|
def _get_rank_features() -> list:
|
||||||
|
import pickle
|
||||||
|
from core.scoring.config import get_model_path
|
||||||
|
path = get_model_path(RANK_MODEL)
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
obj = pickle.load(f)
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
# v6.7r3 使用 feat_names, v6.6 使用 selected_features
|
||||||
|
sf = obj.get('feat_names', []) or obj.get('selected_features', [])
|
||||||
|
if sf:
|
||||||
|
return sf
|
||||||
|
raise RuntimeError("无法从 rank.pkl 读取 feat_names 或 selected_features")
|
||||||
|
|
||||||
|
RANK_FEATURE_COLS = _get_rank_features()
|
||||||
|
|
||||||
|
# Top/Stacking 模型只用 52 维基础特征(不含 v6.7 新增的4维)
|
||||||
|
# v6.7 新增: vol_decay_5d, grid_touch_relative_10d, vol_decay_x_grid_balance, vol_decay_x_dist_lower
|
||||||
|
_V67_NEW_FEATS = {
|
||||||
|
'vol_decay_5d', 'grid_touch_relative_10d',
|
||||||
|
'vol_decay_x_grid_balance', 'vol_decay_x_dist_lower'
|
||||||
|
}
|
||||||
|
BASE_52_COLS = [f for f in RANK_FEATURE_COLS if f not in _V67_NEW_FEATS]
|
||||||
|
|
||||||
|
|
||||||
|
class GridSeekerPipeline:
|
||||||
|
"""
|
||||||
|
grid_seeker v6.7r3 三级模型评分管道。
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
engine = GridSeekerPipeline()
|
||||||
|
rankings = engine.run(trade_date=date.today())
|
||||||
|
# 返回 DataFrame: stock_code, stacking_probability, rank 等
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model_dir: Path = None):
|
||||||
|
self._rank_model = None
|
||||||
|
self._top_model = None
|
||||||
|
self._stacking_model = None
|
||||||
|
|
||||||
|
# ---- 模型加载 ----
|
||||||
|
|
||||||
|
def _load_model(self, name: str):
|
||||||
|
"""加载单个 .pkl 模型"""
|
||||||
|
path = get_model_path(name)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f'模型文件不存在: {path}')
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
obj = pickle.load(f)
|
||||||
|
# 支持 dict 格式 {"model": lgbm_model, ...} 或直接返回模型对象
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get('model', obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rank_model(self):
|
||||||
|
if self._rank_model is None:
|
||||||
|
self._rank_model = self._load_model(RANK_MODEL)
|
||||||
|
return self._rank_model
|
||||||
|
|
||||||
|
@property
|
||||||
|
def top_model(self):
|
||||||
|
if self._top_model is None:
|
||||||
|
self._top_model = self._load_model(TOP_MODEL)
|
||||||
|
return self._top_model
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stacking_model(self):
|
||||||
|
if self._stacking_model is None:
|
||||||
|
self._stacking_model = self._load_model(STACKING_MODEL)
|
||||||
|
return self._stacking_model
|
||||||
|
|
||||||
|
# ---- 预测 ----
|
||||||
|
|
||||||
|
def _predict_with_model(self, model, X: pd.DataFrame, feature_cols: list) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
使用模型预测。自动选择特征子集,兼容 sklearn API (predict/predict_proba)。
|
||||||
|
"""
|
||||||
|
available = [c for c in feature_cols if c in X.columns]
|
||||||
|
missing = set(feature_cols) - set(available)
|
||||||
|
if missing:
|
||||||
|
PrintLog(LogLevel.WARNING,
|
||||||
|
f'[scorer] 缺少特征列 ({len(missing)}): {list(missing)[:5]}...')
|
||||||
|
|
||||||
|
X_sub = X[available].fillna(0).values
|
||||||
|
|
||||||
|
try:
|
||||||
|
if hasattr(model, 'predict_proba'):
|
||||||
|
proba = model.predict_proba(X_sub)
|
||||||
|
if proba.shape[1] >= 2:
|
||||||
|
return proba[:, 1]
|
||||||
|
return proba[:, 0]
|
||||||
|
elif hasattr(model, 'predict'):
|
||||||
|
return model.predict(X_sub)
|
||||||
|
else:
|
||||||
|
return model.predict(X_sub)
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[scorer] 模型预测失败: {e}')
|
||||||
|
raise
|
||||||
|
|
||||||
|
# ---- 主流程 ----
|
||||||
|
|
||||||
|
def run(self, trade_date: date) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
执行完整的 3 级评分管道。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataFrame indexed by stock_code, 含 stacking_probability / rank 等列,
|
||||||
|
按 stacking_probability 降序排列
|
||||||
|
"""
|
||||||
|
PrintLog(LogLevel.INFO, f'[scorer] ===== grid_seeker v6.7r3 评分开始 ({trade_date}) =====')
|
||||||
|
|
||||||
|
# 1. 特征工程
|
||||||
|
pipeline = FeaturePipeline(trade_date)
|
||||||
|
feature_df = pipeline.run()
|
||||||
|
|
||||||
|
if feature_df.empty:
|
||||||
|
PrintLog(LogLevel.WARNING, '[scorer] 无股票通过特征工程, 终止')
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[scorer] 特征工程完成: {len(feature_df)} stocks, '
|
||||||
|
f'{len(feature_df.columns)} dims')
|
||||||
|
|
||||||
|
# 2. Stage 1: Rank 模型 → rank_predicted_rounds (52维)
|
||||||
|
PrintLog(LogLevel.INFO, '[scorer] Stage 1/3: Rank 模型...')
|
||||||
|
feature_df['rank_predicted_rounds'] = self._predict_with_model(
|
||||||
|
self.rank_model, feature_df, RANK_FEATURE_COLS
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Stage 2: Top 模型 → top_elite_prob (53维 = 52基础 + rank)
|
||||||
|
PrintLog(LogLevel.INFO, '[scorer] Stage 2/3: Top 模型...')
|
||||||
|
top_cols = BASE_52_COLS + ['rank_predicted_rounds']
|
||||||
|
feature_df['top_elite_prob'] = self._predict_with_model(
|
||||||
|
self.top_model, feature_df, top_cols
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Stage 3: Stacking 模型 → stacking_probability (55维 = 52基础 + rank + top)
|
||||||
|
PrintLog(LogLevel.INFO, '[scorer] Stage 3/3: Stacking 模型...')
|
||||||
|
stk_cols = BASE_52_COLS + ['rank_predicted_rounds', 'top_elite_prob']
|
||||||
|
feature_df['stacking_probability'] = self._predict_with_model(
|
||||||
|
self.stacking_model, feature_df, stk_cols
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. 排序(直接用 stacking_probability)
|
||||||
|
feature_df['score_rank'] = feature_df['stacking_probability'].rank(
|
||||||
|
ascending=False, method='min'
|
||||||
|
).astype(int)
|
||||||
|
feature_df['candidate_count'] = len(feature_df)
|
||||||
|
feature_df = feature_df.sort_values('score_rank')
|
||||||
|
|
||||||
|
n_above = (feature_df['stacking_probability'] >= STACKING_THRESHOLD).sum()
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[scorer] 评分完成: {len(feature_df)} 只候选, '
|
||||||
|
f'{n_above} 只高于阈值 {STACKING_THRESHOLD}')
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[scorer] Top-5: '
|
||||||
|
f'{feature_df.head(5)[["stacking_probability", "rank_predicted_rounds"]].to_dict("index")}')
|
||||||
|
|
||||||
|
return feature_df
|
||||||
|
|
||||||
|
def persist(self, rankings: pd.DataFrame, trade_date: date):
|
||||||
|
"""将评分结果持久化到 ScoringResult 表"""
|
||||||
|
if rankings.empty:
|
||||||
|
return
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for code, row in rankings.iterrows():
|
||||||
|
records.append({
|
||||||
|
'stock_code': str(code),
|
||||||
|
'trade_date': trade_date,
|
||||||
|
'predicted_profit': float(row.get('stacking_probability', 0)),
|
||||||
|
'rank_predicted_rounds': float(row.get('rank_predicted_rounds', 0))
|
||||||
|
if 'rank_predicted_rounds' in row else None,
|
||||||
|
'top_elite_prob': float(row.get('top_elite_prob', 0))
|
||||||
|
if 'top_elite_prob' in row else None,
|
||||||
|
'stacking_probability': float(row.get('stacking_probability', 0))
|
||||||
|
if 'stacking_probability' in row else None,
|
||||||
|
'score_rank': int(row.get('score_rank', 0)),
|
||||||
|
'candidate_count': int(row.get('candidate_count', 0)),
|
||||||
|
})
|
||||||
|
|
||||||
|
with db.atomic():
|
||||||
|
for batch in _chunked(records, 500):
|
||||||
|
ScoringResult.insert_many(batch).on_conflict_replace().execute()
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[scorer] 评分结果已持久化: {len(records)} 条')
|
||||||
|
|
||||||
|
def get_top_n(self, trade_date: date, n: int = 50) -> list[dict]:
|
||||||
|
"""查询历史评分 Top-N"""
|
||||||
|
rows = (ScoringResult
|
||||||
|
.select()
|
||||||
|
.where(
|
||||||
|
(ScoringResult.trade_date == trade_date) &
|
||||||
|
(ScoringResult.score_rank <= n)
|
||||||
|
)
|
||||||
|
.order_by(ScoringResult.score_rank)
|
||||||
|
.dicts())
|
||||||
|
return list(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunked(lst: list, n: int):
|
||||||
|
for i in range(0, len(lst), n):
|
||||||
|
yield lst[i:i + n]
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
grid_seeker v6.4 数据库模型 — 6 张数据表 + 1 张评分结果表
|
||||||
|
所有模型继承 core.database.BaseModel,复用现有 SQLite 连接。
|
||||||
|
"""
|
||||||
|
from peewee import (
|
||||||
|
CharField, DateField, FloatField, IntegerField,
|
||||||
|
CompositeKey, TextField,
|
||||||
|
)
|
||||||
|
from core.database import BaseModel, db
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. kline_stock — 个股日K线
|
||||||
|
# ============================================================
|
||||||
|
class KlineStock(BaseModel):
|
||||||
|
stock_code = CharField(max_length=10) # 纯数字6位
|
||||||
|
trade_date = DateField()
|
||||||
|
open = FloatField()
|
||||||
|
high = FloatField()
|
||||||
|
low = FloatField()
|
||||||
|
close = FloatField()
|
||||||
|
volume = FloatField() # 成交量(股)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
primary_key = CompositeKey('stock_code', 'trade_date')
|
||||||
|
indexes = (
|
||||||
|
(('stock_code', 'trade_date'), False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2. stocks — 股票基础信息
|
||||||
|
# ============================================================
|
||||||
|
class StockInfo(BaseModel):
|
||||||
|
code = CharField(max_length=12, primary_key=True) # 带 SH/SZ/BJ 前缀
|
||||||
|
name = CharField(max_length=32)
|
||||||
|
exchange = CharField(max_length=4) # SH / SZ / BJ
|
||||||
|
list_date = DateField(null=True)
|
||||||
|
listing_status = CharField(max_length=16, default='normal') # normal / delisted / ST
|
||||||
|
total_share = FloatField(null=True) # 总股本(股)
|
||||||
|
float_share = FloatField(null=True) # 流通股本(股)
|
||||||
|
share_updated_at = DateField(null=True) # 股本数据同步时间
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 3. industry — 股票-行业映射
|
||||||
|
# ============================================================
|
||||||
|
class IndustryMapping(BaseModel):
|
||||||
|
code = CharField(max_length=6, primary_key=True) # 纯数字6位
|
||||||
|
industry_name = CharField(max_length=64, index=True)
|
||||||
|
industry_classification = CharField(max_length=32) # 行业分类体系名称
|
||||||
|
update_date = DateField()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 4. kline_index — 指数日K线
|
||||||
|
# ============================================================
|
||||||
|
class KlineIndex(BaseModel):
|
||||||
|
index_code = CharField(max_length=10) # 指数代码(6位数字)
|
||||||
|
trade_date = DateField()
|
||||||
|
open = FloatField()
|
||||||
|
high = FloatField()
|
||||||
|
low = FloatField()
|
||||||
|
close = FloatField()
|
||||||
|
volume = FloatField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
primary_key = CompositeKey('index_code', 'trade_date')
|
||||||
|
indexes = (
|
||||||
|
(('index_code', 'trade_date'), False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 5. market_regime_daily — 市场状态(本地计算)
|
||||||
|
# ============================================================
|
||||||
|
class MarketRegimeDaily(BaseModel):
|
||||||
|
trade_date = DateField(primary_key=True)
|
||||||
|
advancers = FloatField(default=0) # 当日上涨家数
|
||||||
|
decliners = FloatField(default=0) # 当日下跌家数
|
||||||
|
advance_ratio = FloatField(default=0) # 涨跌比 = advancers/(advancers+decliners)
|
||||||
|
turnover = FloatField(default=0) # 全市场成交额
|
||||||
|
turnover_avg_5d = FloatField(default=0) # 5日滚动均量
|
||||||
|
turnover_ratio_5d = FloatField(default=0) # 量比 = turnover/turnover_avg_5d
|
||||||
|
source = CharField(max_length=32, default='qmt')
|
||||||
|
is_extreme_panic = IntegerField(default=0) # advance_ratio<0.2 且 turnover_ratio_5d>1.5
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 6. sector_features_daily — 行业聚合指数(本地计算)
|
||||||
|
# ============================================================
|
||||||
|
class SectorFeaturesDaily(BaseModel):
|
||||||
|
trade_date = DateField()
|
||||||
|
sector_name = CharField(max_length=64) # 与 industry.industry_name 对应
|
||||||
|
sector_ret = FloatField(default=0) # 行业日收益率(均值)
|
||||||
|
sector_amplitude = FloatField(default=0) # 行业平均振幅
|
||||||
|
close = FloatField(default=100) # 行业指数(基值100)
|
||||||
|
ema10 = FloatField(default=0)
|
||||||
|
ema20 = FloatField(default=0)
|
||||||
|
ema200 = FloatField(default=0)
|
||||||
|
score = IntegerField(default=0) # 趋势评分 0/1/2
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
primary_key = CompositeKey('trade_date', 'sector_name')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 7. ScoringResult — 评分结果(模型输出写入表)
|
||||||
|
# ============================================================
|
||||||
|
class ScoringResult(BaseModel):
|
||||||
|
stock_code = CharField(max_length=6) # 纯数字6位
|
||||||
|
trade_date = DateField() # 评分日
|
||||||
|
predicted_profit = FloatField(default=0) # 最终预测利润(=stacking_probability)
|
||||||
|
rank_predicted_rounds = FloatField(null=True) # Rank 模型输出
|
||||||
|
top_elite_prob = FloatField(null=True) # Top 模型输出
|
||||||
|
stacking_probability = FloatField(null=True) # Stacking 模型输出
|
||||||
|
score_rank = IntegerField(default=0) # 排名
|
||||||
|
candidate_count = IntegerField(default=0) # 候选股总数
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
primary_key = CompositeKey('stock_code', 'trade_date')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 8. PendingPoolAction — 待执行股票池操作(阶段一标记,阶段二执行)
|
||||||
|
# ============================================================
|
||||||
|
class PendingPoolAction(BaseModel):
|
||||||
|
"""pending_pool_actions 表 — T日标记的操作,待 T+1 执行"""
|
||||||
|
action_date = DateField() # T日日期(标记日期)
|
||||||
|
action_type = CharField(max_length=20) # 'eliminate' | 'liquidate'
|
||||||
|
stock_code = CharField(max_length=6) # 股票代码
|
||||||
|
reason = TextField(null=True) # 淘汰原因描述
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
primary_key = CompositeKey('action_date', 'action_type', 'stock_code')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 建表
|
||||||
|
# ============================================================
|
||||||
|
ALL_SCORING_TABLES = [
|
||||||
|
KlineStock, StockInfo, IndustryMapping, KlineIndex,
|
||||||
|
MarketRegimeDaily, SectorFeaturesDaily, ScoringResult, PendingPoolAction,
|
||||||
|
]
|
||||||
|
|
||||||
|
db.create_tables(ALL_SCORING_TABLES)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 数据同步子包
|
||||||
|
from core.scoring.sync.base import BaseSync
|
||||||
|
from core.scoring.sync.kline_sync import KlineStockSync, KlineIndexSync
|
||||||
|
from core.scoring.sync.stocks_sync import StocksSync
|
||||||
|
from core.scoring.sync.industry_sync import IndustrySync
|
||||||
|
from core.scoring.sync.market_regime import MarketRegimeSync
|
||||||
|
from core.scoring.sync.sector_features import SectorFeaturesSync
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
数据同步抽象基类
|
||||||
|
"""
|
||||||
|
import abc
|
||||||
|
from datetime import datetime
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
|
||||||
|
|
||||||
|
class BaseSync(abc.ABC):
|
||||||
|
"""数据同步抽象基类,所有同步操作遵循 _fetch → _upsert 模式"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.stats = {'inserted': 0, 'updated': 0, 'skipped': 0, 'errors': 0}
|
||||||
|
|
||||||
|
def run(self, **kwargs) -> dict:
|
||||||
|
"""同步入口: 拉取数据 → 写入数据库 → 返回统计"""
|
||||||
|
name = self.__class__.__name__
|
||||||
|
PrintLog(LogLevel.INFO, f'[sync] {name} 开始同步...')
|
||||||
|
t0 = datetime.now()
|
||||||
|
try:
|
||||||
|
data = self._fetch(**kwargs)
|
||||||
|
self._upsert(data)
|
||||||
|
elapsed = (datetime.now() - t0).total_seconds()
|
||||||
|
PrintLog(
|
||||||
|
LogLevel.INFO,
|
||||||
|
f'[sync] {name} 完成 ({elapsed:.1f}s) — '
|
||||||
|
f'insert={self.stats["inserted"]} update={self.stats["updated"]} '
|
||||||
|
f'skip={self.stats["skipped"]} err={self.stats["errors"]}'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[sync] {name} 失败: {e}')
|
||||||
|
raise
|
||||||
|
return self.stats
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def _fetch(self, **kwargs):
|
||||||
|
"""从数据源拉取原始数据。子类实现。"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def _upsert(self, data):
|
||||||
|
"""将数据写入数据库。子类实现。"""
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
行业映射同步 — 从 QMT get_sector_list + get_stock_list_in_sector 获取
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from core.scoring.sync.base import BaseSync
|
||||||
|
from core.scoring.models import IndustryMapping
|
||||||
|
from core.database import db
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
|
||||||
|
|
||||||
|
class IndustrySync(BaseSync):
|
||||||
|
"""行业映射同步 — QMT 行业板块 → IndustryMapping 表(全量替换)"""
|
||||||
|
|
||||||
|
def _fetch(self, **kwargs):
|
||||||
|
"""从 QMT 拉取全部行业板块的成份股映射"""
|
||||||
|
from xtquant import xtdata
|
||||||
|
|
||||||
|
all_sectors = xtdata.get_sector_list()
|
||||||
|
PrintLog(LogLevel.INFO, f'[sync] Industry: 共 {len(all_sectors)} 个板块')
|
||||||
|
|
||||||
|
# 尝试使用 get_sector_info 过滤行业板块
|
||||||
|
industry_sectors = []
|
||||||
|
try:
|
||||||
|
sector_info = xtdata.get_sector_info()
|
||||||
|
if sector_info is not None and not sector_info.empty:
|
||||||
|
for _, row in sector_info.iterrows():
|
||||||
|
cat = row.get('category', '')
|
||||||
|
if '行业' in str(cat):
|
||||||
|
industry_sectors.append(row['sector'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 如果 get_sector_info 无效,回退到名称过滤
|
||||||
|
if not industry_sectors:
|
||||||
|
for s in all_sectors:
|
||||||
|
# 排除明显非行业的板块
|
||||||
|
skip_markers = ['概念', '风格', '地域', '地区', '指数', '自定义',
|
||||||
|
'ETF', 'LOF', '债券', '基金', '期货', '期权']
|
||||||
|
if any(m in s for m in skip_markers):
|
||||||
|
continue
|
||||||
|
industry_sectors.append(s)
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO, f'[sync] Industry: 筛选出 {len(industry_sectors)} 个行业板块')
|
||||||
|
|
||||||
|
# 构建 code → {industry_name, classification} 映射
|
||||||
|
mapping = {} # code → (industry_name, classification)
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
for sector_name in industry_sectors:
|
||||||
|
try:
|
||||||
|
stocks = xtdata.get_stock_list_in_sector(sector_name)
|
||||||
|
for full_code in stocks:
|
||||||
|
code = full_code.split('.')[0]
|
||||||
|
if code not in mapping:
|
||||||
|
mapping[code] = {
|
||||||
|
'code': code,
|
||||||
|
'industry_name': sector_name,
|
||||||
|
'industry_classification': 'QMT',
|
||||||
|
'update_date': today,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
self.stats['errors'] += 1
|
||||||
|
|
||||||
|
self.stats['inserted'] = len(mapping)
|
||||||
|
return list(mapping.values())
|
||||||
|
|
||||||
|
def _upsert(self, data: list):
|
||||||
|
"""全量替换: 清空旧数据 → 批量插入新数据"""
|
||||||
|
if not data:
|
||||||
|
PrintLog(LogLevel.WARNING, '[sync] Industry: 无数据, 跳过')
|
||||||
|
return
|
||||||
|
|
||||||
|
with db.atomic():
|
||||||
|
IndustryMapping.delete().execute()
|
||||||
|
for batch in _chunked(data, 500):
|
||||||
|
IndustryMapping.insert_many(batch).execute()
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[sync] Industry: 全量替换完成, {len(data)} 条映射')
|
||||||
|
|
||||||
|
|
||||||
|
def _chunked(lst: list, n: int):
|
||||||
|
for i in range(0, len(lst), n):
|
||||||
|
yield lst[i:i + n]
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""
|
||||||
|
K线数据同步 — 个股日K + 指数日K
|
||||||
|
数据源: QMT xtdata
|
||||||
|
增量同步: 只拉 max(trade_date) 之后的增量数据
|
||||||
|
线程锁: KlineStockSync / KlineIndexSync 各自内部锁
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from core.scoring.sync.base import BaseSync
|
||||||
|
from core.scoring.models import KlineStock, KlineIndex
|
||||||
|
from core.scoring.config import TRACKED_INDICES
|
||||||
|
from core.database import db
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
|
||||||
|
BATCH_SIZE = 50
|
||||||
|
DEFAULT_COUNT = 300
|
||||||
|
|
||||||
|
# 全局同步状态标记(字典引用传递,可被外部轮询)
|
||||||
|
_sync_state = {"kline": False, "index": False, "stocks": False,
|
||||||
|
"industry": False, "market": False, "sector": False}
|
||||||
|
|
||||||
|
|
||||||
|
def is_syncing(key="kline") -> bool:
|
||||||
|
return _sync_state.get(key, False)
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_date(model_cls) -> date | None:
|
||||||
|
from peewee import fn
|
||||||
|
row = model_cls.select(fn.MAX(model_cls.trade_date)).scalar()
|
||||||
|
if isinstance(row, date):
|
||||||
|
return row
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_get(df_dict, code, dt, default=0.0) -> float:
|
||||||
|
"""安全获取 DataFrame 值"""
|
||||||
|
if df_dict is None:
|
||||||
|
return default
|
||||||
|
df = df_dict.get(code)
|
||||||
|
if df is None or code not in df.index:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
val = df.loc[code, dt]
|
||||||
|
if pd.isna(val):
|
||||||
|
return default
|
||||||
|
return float(val)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class KlineStockSync(BaseSync):
|
||||||
|
"""个股日K线同步 — 增量:只拉 max(trade_date) 之后的增量数据"""
|
||||||
|
|
||||||
|
def __init__(self, count: int = DEFAULT_COUNT):
|
||||||
|
super().__init__()
|
||||||
|
self.count = count
|
||||||
|
|
||||||
|
def _fetch(self, **kwargs):
|
||||||
|
from xtquant import xtdata
|
||||||
|
|
||||||
|
# 增量判断: 以数据库最新一条记录为准
|
||||||
|
latest = _latest_date(KlineStock)
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
# 增量起点: last_db_date + 1; 截止: 昨天(盘中不能同步当天数据)
|
||||||
|
# 注意: 不能用 latest >= today 跳过,因为 latest 可能是错误的未收盘数据
|
||||||
|
start_date = (latest + timedelta(days=1)) if latest else None
|
||||||
|
end_date = today - timedelta(days=1) # 固定截止到昨天,收盘后同步昨天数据
|
||||||
|
start_str = start_date.strftime('%Y%m%d') if start_date else ""
|
||||||
|
end_str = end_date.strftime('%Y%m%d')
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[sync] KlineStock: 增量同步 {start_str} ~ {end_str}')
|
||||||
|
|
||||||
|
all_stocks = xtdata.get_stock_list_in_sector("沪深A股")
|
||||||
|
PrintLog(LogLevel.INFO, f'[sync] KlineStock: {len(all_stocks)} 只A股')
|
||||||
|
field_list = ['open', 'high', 'low', 'close', 'volume']
|
||||||
|
total = len(all_stocks)
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
for i, code in enumerate(all_stocks):
|
||||||
|
if i > 0 and i % 50 == 0:
|
||||||
|
PrintLog(LogLevel.INFO, f'[sync] KlineStock: {i}/{total} ({i*100//total}%)')
|
||||||
|
|
||||||
|
try:
|
||||||
|
xtdata.download_history_data(code, period='1d', start_time=start_str, end_time=end_str)
|
||||||
|
except Exception:
|
||||||
|
self.stats['errors'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = xtdata.get_market_data(
|
||||||
|
field_list=field_list, stock_list=[code], period='1d',
|
||||||
|
start_time=start_str, end_time=end_str,
|
||||||
|
dividend_type='none', fill_data=False)
|
||||||
|
inserted += self._upsert_incremental(code, result, start_date, end_date)
|
||||||
|
except Exception:
|
||||||
|
self.stats['errors'] += 1
|
||||||
|
|
||||||
|
self.stats['inserted'] = inserted
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[sync] KlineStock 完成: 新增={inserted} '
|
||||||
|
f'跳过={self.stats["skipped"]} 错误={self.stats["errors"]}')
|
||||||
|
return self.stats
|
||||||
|
|
||||||
|
def _upsert_incremental(self, full_code: str, result: dict, start_date, end_date) -> int:
|
||||||
|
if not result:
|
||||||
|
return 0
|
||||||
|
close_df = result.get('close')
|
||||||
|
if close_df is None or close_df.empty:
|
||||||
|
return 0
|
||||||
|
stock_code = full_code.split('.')[0]
|
||||||
|
records = []
|
||||||
|
vol_df = result.get('volume')
|
||||||
|
for td in close_df.columns:
|
||||||
|
# xtdata 返回的列名可能是字符串 'YYYYMMDD' 或 datetime,需统一转成 date
|
||||||
|
if isinstance(td, str):
|
||||||
|
td_date = datetime.strptime(td, '%Y%m%d').date()
|
||||||
|
else:
|
||||||
|
td_date = td.date() if hasattr(td, 'date') else td
|
||||||
|
# 过滤: 不在增量范围内的跳过 (start_date < td <= end_date)
|
||||||
|
if start_date is not None and td_date < start_date:
|
||||||
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
|
continue
|
||||||
|
if end_date is not None and td_date > end_date:
|
||||||
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
|
continue
|
||||||
|
# 跳过成交量为0的无效数据(盘中未结算数据)
|
||||||
|
vol = vol_df.loc[full_code, td] if vol_df is not None else None
|
||||||
|
if vol is None or (isinstance(vol, float) and pd.isna(vol)) or vol == 0:
|
||||||
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
|
continue
|
||||||
|
close_val = close_df.loc[full_code, td]
|
||||||
|
if close_val is None or (isinstance(close_val, float) and pd.isna(close_val)):
|
||||||
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
|
continue
|
||||||
|
records.append({
|
||||||
|
'stock_code': stock_code,
|
||||||
|
'trade_date': td_date,
|
||||||
|
'open': _safe_get(result.get('open'), full_code, td),
|
||||||
|
'high': _safe_get(result.get('high'), full_code, td),
|
||||||
|
'low': _safe_get(result.get('low'), full_code, td),
|
||||||
|
'close': float(close_val),
|
||||||
|
'volume': float(vol),
|
||||||
|
})
|
||||||
|
if records:
|
||||||
|
with db.atomic():
|
||||||
|
for batch in _chunked(records, 500):
|
||||||
|
KlineStock.insert_many(batch).on_conflict_replace().execute()
|
||||||
|
return len(records)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _upsert(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class KlineIndexSync(BaseSync):
|
||||||
|
"""指数日K线同步 — 增量同步"""
|
||||||
|
|
||||||
|
def __init__(self, indices: list = None, count: int = DEFAULT_COUNT):
|
||||||
|
super().__init__()
|
||||||
|
self.indices = indices or TRACKED_INDICES
|
||||||
|
self.count = count
|
||||||
|
|
||||||
|
def _fetch(self, **kwargs):
|
||||||
|
from xtquant import xtdata
|
||||||
|
|
||||||
|
index_codes = []
|
||||||
|
for code in self.indices:
|
||||||
|
if code.startswith(('000', '001')):
|
||||||
|
index_codes.append(f'{code}.SH')
|
||||||
|
elif code.startswith('399'):
|
||||||
|
index_codes.append(f'{code}.SZ')
|
||||||
|
else:
|
||||||
|
index_codes.append(f'{code}.SH')
|
||||||
|
|
||||||
|
latest = _latest_date(KlineIndex)
|
||||||
|
today = date.today()
|
||||||
|
start_date = (latest + timedelta(days=1)) if latest else None
|
||||||
|
end_date = today - timedelta(days=1) # 截止到昨天
|
||||||
|
start_str = start_date.strftime('%Y%m%d') if start_date else ""
|
||||||
|
end_str = end_date.strftime('%Y%m%d')
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[sync] KlineIndex: 增量同步 {start_str} ~ {end_str}')
|
||||||
|
|
||||||
|
for code in index_codes:
|
||||||
|
try:
|
||||||
|
xtdata.download_history_data(code, period='1d', start_time=start_str, end_time=end_str)
|
||||||
|
except Exception:
|
||||||
|
self.stats['errors'] += 1
|
||||||
|
|
||||||
|
field_list = ['open', 'high', 'low', 'close', 'volume']
|
||||||
|
result = xtdata.get_market_data(
|
||||||
|
field_list=field_list, stock_list=index_codes, period='1d',
|
||||||
|
start_time=start_str, end_time=end_str,
|
||||||
|
dividend_type='none', fill_data=False)
|
||||||
|
|
||||||
|
def _upsert(self, data):
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
latest = _latest_date(KlineIndex)
|
||||||
|
today = date.today()
|
||||||
|
start_date = (latest + timedelta(days=1)) if latest else None
|
||||||
|
end_date = today - timedelta(days=1)
|
||||||
|
records = []
|
||||||
|
close_df = data.get('close')
|
||||||
|
vol_df = data.get('volume')
|
||||||
|
if close_df is None or close_df.empty:
|
||||||
|
return
|
||||||
|
for full_code in close_df.index:
|
||||||
|
index_code = full_code.split('.')[0]
|
||||||
|
for td in close_df.columns:
|
||||||
|
if isinstance(td, str):
|
||||||
|
td_date = datetime.strptime(td, '%Y%m%d').date()
|
||||||
|
else:
|
||||||
|
td_date = td.date() if hasattr(td, 'date') else td
|
||||||
|
# 增量范围过滤 (start_date < td <= end_date)
|
||||||
|
if start_date is not None and td_date < start_date:
|
||||||
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
|
continue
|
||||||
|
if end_date is not None and td_date > end_date:
|
||||||
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
|
continue
|
||||||
|
# 过滤成交量为0的无效数据
|
||||||
|
vol = vol_df.loc[full_code, td] if vol_df is not None else None
|
||||||
|
if vol is None or (isinstance(vol, float) and pd.isna(vol)) or vol == 0:
|
||||||
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
|
continue
|
||||||
|
close_val = close_df.loc[full_code, td]
|
||||||
|
if close_val is None or (isinstance(close_val, float) and pd.isna(close_val)):
|
||||||
|
self.stats['skipped'] = self.stats.get('skipped', 0) + 1
|
||||||
|
continue
|
||||||
|
records.append({
|
||||||
|
'index_code': index_code,
|
||||||
|
'trade_date': td_date,
|
||||||
|
'open': _safe_get(data.get('open'), full_code, td),
|
||||||
|
'high': _safe_get(data.get('high'), full_code, td),
|
||||||
|
'low': _safe_get(data.get('low'), full_code, td),
|
||||||
|
'close': float(close_val),
|
||||||
|
'volume': float(vol),
|
||||||
|
})
|
||||||
|
if records:
|
||||||
|
with db.atomic():
|
||||||
|
for batch in _chunked(records, 500):
|
||||||
|
KlineIndex.insert_many(batch).on_conflict_replace().execute()
|
||||||
|
self.stats['inserted'] = self.stats.get('inserted', 0) + len(records)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunked(lst: list, n: int):
|
||||||
|
for i in range(0, len(lst), n):
|
||||||
|
yield lst[i:i + n]
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
市场状态计算 — 从 kline_stock 聚合生成 market_regime_daily
|
||||||
|
纯本地计算,不依赖外部数据源。
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
from peewee import fn, Case
|
||||||
|
from core.scoring.sync.base import BaseSync
|
||||||
|
from core.scoring.models import KlineStock, MarketRegimeDaily
|
||||||
|
from core.scoring.config import PANIC_ADVANCE_RATIO, PANIC_TURNOVER_RATIO
|
||||||
|
from core.database import db
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
|
||||||
|
|
||||||
|
class MarketRegimeSync(BaseSync):
|
||||||
|
"""市场状态同步 — kline_stock 聚合 → market_regime_daily"""
|
||||||
|
|
||||||
|
def _fetch(self, **kwargs):
|
||||||
|
"""从 KlineStock 逐日聚合涨跌家数和成交额"""
|
||||||
|
PrintLog(LogLevel.INFO, '[sync] MarketRegime: 开始聚合全市场数据...')
|
||||||
|
|
||||||
|
# peewee 聚合查询: 逐日统计 advancers / decliners / turnover
|
||||||
|
query = (KlineStock
|
||||||
|
.select(
|
||||||
|
KlineStock.trade_date,
|
||||||
|
fn.SUM(
|
||||||
|
Case(None, [(KlineStock.close > KlineStock.open, 1)], 0)
|
||||||
|
).alias('advancers'),
|
||||||
|
fn.SUM(
|
||||||
|
Case(None, [(KlineStock.close < KlineStock.open, 1)], 0)
|
||||||
|
).alias('decliners'),
|
||||||
|
fn.SUM(KlineStock.close * KlineStock.volume).alias('turnover'),
|
||||||
|
)
|
||||||
|
.group_by(KlineStock.trade_date)
|
||||||
|
.order_by(KlineStock.trade_date))
|
||||||
|
|
||||||
|
rows = list(query.dicts())
|
||||||
|
if not rows:
|
||||||
|
PrintLog(LogLevel.WARNING, '[sync] MarketRegime: KlineStock 表为空')
|
||||||
|
return None
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
df['trade_date'] = pd.to_datetime(df['trade_date'])
|
||||||
|
df = df.sort_values('trade_date').reset_index(drop=True)
|
||||||
|
|
||||||
|
# 计算涨跌比
|
||||||
|
total = df['advancers'] + df['decliners']
|
||||||
|
df['advance_ratio'] = (df['advancers'] / total.replace(0, 1)).round(4)
|
||||||
|
|
||||||
|
# 5日滚动均量
|
||||||
|
df['turnover_avg_5d'] = (df['turnover']
|
||||||
|
.rolling(window=5, min_periods=1)
|
||||||
|
.mean()
|
||||||
|
.round(2))
|
||||||
|
|
||||||
|
# 量比
|
||||||
|
df['turnover_ratio_5d'] = (df['turnover'] /
|
||||||
|
df['turnover_avg_5d'].replace(0, 1)).round(4)
|
||||||
|
|
||||||
|
# 极端恐慌标记
|
||||||
|
df['is_extreme_panic'] = (
|
||||||
|
(df['advance_ratio'] < PANIC_ADVANCE_RATIO) &
|
||||||
|
(df['turnover_ratio_5d'] > PANIC_TURNOVER_RATIO)
|
||||||
|
).astype(int)
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[sync] MarketRegime: 聚合完成, {len(df)} 个交易日')
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
def _upsert(self, df):
|
||||||
|
"""写入 MarketRegimeDaily 表"""
|
||||||
|
if df is None or df.empty:
|
||||||
|
return
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
records.append({
|
||||||
|
'trade_date': row['trade_date'].date(),
|
||||||
|
'advancers': int(row['advancers']),
|
||||||
|
'decliners': int(row['decliners']),
|
||||||
|
'advance_ratio': float(row['advance_ratio']),
|
||||||
|
'turnover': float(row['turnover']),
|
||||||
|
'turnover_avg_5d': float(row['turnover_avg_5d']),
|
||||||
|
'turnover_ratio_5d': float(row['turnover_ratio_5d']),
|
||||||
|
'source': 'qmt',
|
||||||
|
'is_extreme_panic': int(row['is_extreme_panic']),
|
||||||
|
})
|
||||||
|
|
||||||
|
if records:
|
||||||
|
with db.atomic():
|
||||||
|
for batch in _chunked(records, 500):
|
||||||
|
MarketRegimeDaily.insert_many(batch).on_conflict_replace().execute()
|
||||||
|
self.stats['inserted'] += len(records)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunked(lst: list, n: int):
|
||||||
|
for i in range(0, len(lst), n):
|
||||||
|
yield lst[i:i + n]
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
行业聚合指数计算 — 从 kline_stock + industry 生成 sector_features_daily
|
||||||
|
纯本地计算,不依赖外部数据源。
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
from core.scoring.sync.base import BaseSync
|
||||||
|
from core.scoring.models import KlineStock, IndustryMapping, SectorFeaturesDaily
|
||||||
|
from core.database import db
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
|
||||||
|
|
||||||
|
class SectorFeaturesSync(BaseSync):
|
||||||
|
"""行业聚合指数同步 — kline_stock + industry → sector_features_daily"""
|
||||||
|
|
||||||
|
def _fetch(self, **kwargs):
|
||||||
|
"""从数据库加载原始数据, 计算行业指数特征(分块处理避免内存溢出)"""
|
||||||
|
PrintLog(LogLevel.INFO, '[sync] SectorFeatures: 加载原始数据...')
|
||||||
|
|
||||||
|
# 1. 加载行业映射: code → industry_name
|
||||||
|
industries = list(IndustryMapping.select().dicts())
|
||||||
|
if not industries:
|
||||||
|
PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: IndustryMapping 表为空, 请先执行 industry sync')
|
||||||
|
return None
|
||||||
|
code_to_industry = {row['code']: row['industry_name'] for row in industries}
|
||||||
|
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(code_to_industry)} 条行业映射')
|
||||||
|
|
||||||
|
# 2. 分块加载 K 线数据,避免内存溢出
|
||||||
|
# 聚合结果: {(trade_date, sector_name): [sum_pct_chg, sum_amp, count]}
|
||||||
|
sector_daily_agg = {} # key: (date, sector) -> {'ret_sum': float, 'amp_sum': float, 'count': int}
|
||||||
|
CHUNK_SIZE = 50000
|
||||||
|
last_date_per_stock = {} # stock_code -> prev_close
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO, '[sync] SectorFeatures: 分块处理K线数据...')
|
||||||
|
chunk_num = 0
|
||||||
|
while True:
|
||||||
|
chunk_num += 1
|
||||||
|
rows = list(KlineStock
|
||||||
|
.select(
|
||||||
|
KlineStock.stock_code,
|
||||||
|
KlineStock.trade_date,
|
||||||
|
KlineStock.open,
|
||||||
|
KlineStock.high,
|
||||||
|
KlineStock.low,
|
||||||
|
KlineStock.close,
|
||||||
|
)
|
||||||
|
.order_by(KlineStock.stock_code, KlineStock.trade_date)
|
||||||
|
.offset((chunk_num - 1) * CHUNK_SIZE)
|
||||||
|
.limit(CHUNK_SIZE)
|
||||||
|
.dicts())
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: 处理块 {chunk_num} ({len(rows)} 行)...')
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
code = str(row['stock_code'])
|
||||||
|
td = row['trade_date']
|
||||||
|
open_p = float(row['open'])
|
||||||
|
high = float(row['high'])
|
||||||
|
low = float(row['low'])
|
||||||
|
close = float(row['close'])
|
||||||
|
|
||||||
|
sector = code_to_industry.get(code)
|
||||||
|
if sector is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 计算日收益率和振幅
|
||||||
|
prev_close = last_date_per_stock.get(code)
|
||||||
|
if prev_close is not None and prev_close > 0 and open_p > 0 and close > 0:
|
||||||
|
pct_chg = (close - prev_close) / prev_close * 100
|
||||||
|
amp = (high - low) / open_p * 100
|
||||||
|
key = (td, sector)
|
||||||
|
if key not in sector_daily_agg:
|
||||||
|
sector_daily_agg[key] = {'ret_sum': 0.0, 'amp_sum': 0.0, 'count': 0}
|
||||||
|
sector_daily_agg[key]['ret_sum'] += pct_chg
|
||||||
|
sector_daily_agg[key]['amp_sum'] += amp
|
||||||
|
sector_daily_agg[key]['count'] += 1
|
||||||
|
|
||||||
|
last_date_per_stock[code] = close
|
||||||
|
|
||||||
|
if not sector_daily_agg:
|
||||||
|
PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: 无有效K线数据')
|
||||||
|
return None
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: 聚合完成, {len(sector_daily_agg)} 个行业-日组合')
|
||||||
|
|
||||||
|
# 3. 构建聚合 DataFrame
|
||||||
|
agg_data = []
|
||||||
|
for (td, sector), vals in sector_daily_agg.items():
|
||||||
|
agg_data.append({
|
||||||
|
'trade_date': td,
|
||||||
|
'sector_name': sector,
|
||||||
|
'sector_ret': vals['ret_sum'] / vals['count'],
|
||||||
|
'sector_amplitude': vals['amp_sum'] / vals['count'],
|
||||||
|
})
|
||||||
|
agg = pd.DataFrame(agg_data)
|
||||||
|
agg = agg.sort_values(['sector_name', 'trade_date'])
|
||||||
|
agg['trade_date'] = pd.to_datetime(agg['trade_date'])
|
||||||
|
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(agg)} 行, {agg["sector_name"].nunique()} 个行业')
|
||||||
|
|
||||||
|
# 4. 构建行业指数 (基值=100)
|
||||||
|
agg = agg.sort_values(['sector_name', 'trade_date'])
|
||||||
|
agg['sector_index'] = agg.groupby('sector_name')['sector_ret'].transform(
|
||||||
|
lambda x: (1 + x / 100).cumprod() * 100
|
||||||
|
)
|
||||||
|
# 重新基值=100 (每行业独立)
|
||||||
|
for name, group in agg.groupby('sector_name'):
|
||||||
|
idx = group.index
|
||||||
|
first_val = group['sector_index'].iloc[0]
|
||||||
|
agg.loc[idx, 'sector_index'] = group['sector_index'] / first_val * 100
|
||||||
|
|
||||||
|
# 5. 计算 EMA 均线
|
||||||
|
agg['ema10'] = (agg.groupby('sector_name')['sector_index']
|
||||||
|
.transform(lambda x: x.ewm(span=10, min_periods=1).mean()))
|
||||||
|
agg['ema20'] = (agg.groupby('sector_name')['sector_index']
|
||||||
|
.transform(lambda x: x.ewm(span=20, min_periods=1).mean()))
|
||||||
|
agg['ema200'] = (agg.groupby('sector_name')['sector_index']
|
||||||
|
.transform(lambda x: x.ewm(span=200, min_periods=1).mean()))
|
||||||
|
|
||||||
|
# 6. 趋势评分: close>ema200 得1分 + ema10>ema20 得1分
|
||||||
|
agg['score'] = (
|
||||||
|
(agg['sector_index'] > agg['ema200']).astype(int) +
|
||||||
|
(agg['ema10'] > agg['ema20']).astype(int)
|
||||||
|
)
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[sync] SectorFeatures: 计算完成, {len(agg)} 行, '
|
||||||
|
f'{agg["sector_name"].nunique()} 个行业')
|
||||||
|
|
||||||
|
return agg
|
||||||
|
|
||||||
|
def _upsert(self, agg):
|
||||||
|
"""写入 SectorFeaturesDaily 表"""
|
||||||
|
if agg is None or agg.empty:
|
||||||
|
return
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for _, row in agg.iterrows():
|
||||||
|
records.append({
|
||||||
|
'trade_date': row['trade_date'].date()
|
||||||
|
if hasattr(row['trade_date'], 'date') else row['trade_date'],
|
||||||
|
'sector_name': str(row['sector_name']),
|
||||||
|
'sector_ret': round(float(row['sector_ret']), 4),
|
||||||
|
'sector_amplitude': round(float(row['sector_amplitude']), 4),
|
||||||
|
'close': round(float(row['sector_index']), 4),
|
||||||
|
'ema10': round(float(row['ema10']), 4),
|
||||||
|
'ema20': round(float(row['ema20']), 4),
|
||||||
|
'ema200': round(float(row['ema200']), 4),
|
||||||
|
'score': int(row['score']),
|
||||||
|
})
|
||||||
|
|
||||||
|
if records:
|
||||||
|
with db.atomic():
|
||||||
|
for batch in _chunked(records, 500):
|
||||||
|
SectorFeaturesDaily.insert_many(batch).on_conflict_replace().execute()
|
||||||
|
self.stats['inserted'] += len(records)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunked(lst: list, n: int):
|
||||||
|
for i in range(0, len(lst), n):
|
||||||
|
yield lst[i:i + n]
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
股票基础信息同步 — 从 QMT get_instrument_detail 获取
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from core.scoring.sync.base import BaseSync
|
||||||
|
from core.scoring.models import StockInfo
|
||||||
|
from core.database import db
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
|
||||||
|
BATCH_SIZE = 200
|
||||||
|
|
||||||
|
|
||||||
|
class StocksSync(BaseSync):
|
||||||
|
"""股票基础信息同步 — QMT get_instrument_detail_list"""
|
||||||
|
|
||||||
|
def _fetch(self, **kwargs):
|
||||||
|
"""从 QMT 拉取全部A股基础信息"""
|
||||||
|
from xtquant import xtdata
|
||||||
|
|
||||||
|
all_stocks = xtdata.get_stock_list_in_sector("沪深A股")
|
||||||
|
PrintLog(LogLevel.INFO, f'[sync] Stocks: 获取到 {len(all_stocks)} 只A股')
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
total = len(all_stocks)
|
||||||
|
for i in range(0, total, BATCH_SIZE):
|
||||||
|
batch = all_stocks[i:i + BATCH_SIZE]
|
||||||
|
try:
|
||||||
|
details = xtdata.get_instrument_detail_list(batch)
|
||||||
|
result.update(details)
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR,
|
||||||
|
f'[sync] Stocks batch {i}-{min(i + BATCH_SIZE, total)} failed: {e}')
|
||||||
|
self.stats['errors'] += len(batch)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _upsert(self, data: dict):
|
||||||
|
"""写入 StockInfo 表"""
|
||||||
|
records = []
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
for full_code, inst in data.items():
|
||||||
|
if not inst:
|
||||||
|
self.stats['skipped'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
code_num = full_code.split('.')[0]
|
||||||
|
|
||||||
|
# 判断交易所
|
||||||
|
exchange = inst.get('ExchangeID', '')
|
||||||
|
if not exchange:
|
||||||
|
if '.SH' in full_code:
|
||||||
|
exchange = 'SH'
|
||||||
|
elif '.SZ' in full_code:
|
||||||
|
exchange = 'SZ'
|
||||||
|
elif '.BJ' in full_code:
|
||||||
|
exchange = 'BJ'
|
||||||
|
|
||||||
|
# OpenDate 格式: 'YYYYMMDD' 或 int
|
||||||
|
open_date = inst.get('OpenDate', '')
|
||||||
|
if open_date and len(str(open_date)) == 8:
|
||||||
|
list_date_val = f'{str(open_date)[:4]}-{str(open_date)[4:6]}-{str(open_date)[6:8]}'
|
||||||
|
else:
|
||||||
|
list_date_val = None
|
||||||
|
|
||||||
|
# InstrumentStatus 含义:
|
||||||
|
# 0/1 = 正常股票(含已退市但仍在QMT列表的)
|
||||||
|
# -1 = ST / *ST
|
||||||
|
# 30/31 = *ST
|
||||||
|
# 已退市股(名字含XD/退)K线不足120日,会在候选股过滤时被排除
|
||||||
|
status = inst.get('InstrumentStatus', -1)
|
||||||
|
if status in (-1, 30, 31):
|
||||||
|
listing_status = 'ST'
|
||||||
|
else:
|
||||||
|
listing_status = 'normal'
|
||||||
|
|
||||||
|
total_share = inst.get('TotalVolume', None)
|
||||||
|
float_share = inst.get('FloatVolume', None)
|
||||||
|
|
||||||
|
records.append({
|
||||||
|
'code': full_code,
|
||||||
|
'name': str(inst.get('InstrumentName', '')),
|
||||||
|
'exchange': exchange,
|
||||||
|
'list_date': list_date_val,
|
||||||
|
'listing_status': listing_status,
|
||||||
|
'total_share': float(total_share) if total_share else None,
|
||||||
|
'float_share': float(float_share) if float_share else None,
|
||||||
|
'share_updated_at': today,
|
||||||
|
})
|
||||||
|
|
||||||
|
if records:
|
||||||
|
with db.atomic():
|
||||||
|
for batch in _chunked(records, 500):
|
||||||
|
StockInfo.insert_many(batch).on_conflict_replace().execute()
|
||||||
|
self.stats['inserted'] += len(records)
|
||||||
|
|
||||||
|
|
||||||
|
def _chunked(lst: list, n: int):
|
||||||
|
for i in range(0, len(lst), n):
|
||||||
|
yield lst[i:i + n]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 删除交易标的事件
|
||||||
|
EventTradeTargetUpdate = "trade_target_update"
|
||||||
|
EventTradeTargetDeleted = "trade_target_deleted"
|
||||||
|
|
||||||
|
# 评分系统事件
|
||||||
|
EventScoringCompleted = "scoring_completed" # 评分完成, data: {'date', 'count'}
|
||||||
|
EventSyncProgress = "sync_progress" # 同步进度, data: {'source', 'status', 'stats'}
|
||||||
|
|
||||||
|
# 股票池管理器事件(阶段一)
|
||||||
|
# T日收盘标记完成, data: {action: 'eliminate'|'liquidate', stock_codes: list[str], count: int}
|
||||||
|
EventPoolMark = "pool_mark"
|
||||||
|
|
||||||
|
# T+1日执行完成(阶段二用)
|
||||||
|
# data: {action: 'eliminate'|'liquidate'|'refill', stock_codes: list[str], count: int}
|
||||||
|
EventPoolActionExecute = "pool_action_execute"
|
||||||
@@ -2,6 +2,10 @@ from peewee import CharField, IntegerField, FloatField, BooleanField
|
|||||||
|
|
||||||
from core.database import BaseModel, db
|
from core.database import BaseModel, db
|
||||||
|
|
||||||
|
# 策略类型常量
|
||||||
|
STRATEGY_TYPE_UNCLASSIFIED = 0 # 未分类持仓
|
||||||
|
STRATEGY_TYPE_GRID = 1 # 网格策略
|
||||||
|
|
||||||
|
|
||||||
# 定义Target类,对应targets表
|
# 定义Target类,对应targets表
|
||||||
class SFGridTradeTarget(BaseModel):
|
class SFGridTradeTarget(BaseModel):
|
||||||
@@ -12,12 +16,13 @@ class SFGridTradeTarget(BaseModel):
|
|||||||
init_price = FloatField(null=True) # 建仓成本
|
init_price = FloatField(null=True) # 建仓成本
|
||||||
grid_match_count = IntegerField(default=0)
|
grid_match_count = IntegerField(default=0)
|
||||||
grid_total_profit = FloatField(default=0.0)
|
grid_total_profit = FloatField(default=0.0)
|
||||||
status = IntegerField(default=0) # -1表示新标的,未完成交易配置,0表示新标的,已完成交易配置,1表示已建初始仓,正常交易中
|
status = IntegerField(default=0) # 已废弃,改用 strategy_type + grid_index
|
||||||
enabled = BooleanField(default=False) # 是否启动交易线程
|
enabled = BooleanField(default=False) # 是否启动交易线程
|
||||||
|
strategy_type = IntegerField(default=0) # 0=未分类, 1=网格策略
|
||||||
|
|
||||||
grid_start_price = FloatField(default=10.0) # 基线价格
|
grid_start_price = FloatField(default=10.0) # 基线价格
|
||||||
grid_size = FloatField(default=0.1) # 网格价位差
|
grid_size = FloatField(default=1.0) # 网格价位差
|
||||||
grid_volume = IntegerField(default=100) # 网格交易量
|
grid_volume = IntegerField(default=200) # 网格交易量
|
||||||
grid_upper_count = IntegerField(default=1) # 基线价格上方网格数
|
grid_upper_count = IntegerField(default=1) # 基线价格上方网格数
|
||||||
grid_lower_count = IntegerField(default=10) # 基线价格下方网格数
|
grid_lower_count = IntegerField(default=10) # 基线价格下方网格数
|
||||||
|
|
||||||
@@ -42,3 +47,14 @@ class SFGridTradeTarget(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
db.create_tables([SFGridTradeTarget])
|
db.create_tables([SFGridTradeTarget])
|
||||||
|
|
||||||
|
# 数据库迁移: 为已有表添加 strategy_type 字段(如果不存在)
|
||||||
|
try:
|
||||||
|
from playhouse.migrate import migrate, SqliteMigrator
|
||||||
|
migrator = SqliteMigrator(db)
|
||||||
|
migrate(
|
||||||
|
migrator.add_column('sfgridtradetarget', 'strategy_type', SFGridTradeTarget.strategy_type),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# 字段已存在或迁移失败 — 静默跳过
|
||||||
|
pass
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
"""
|
||||||
|
pool_manager.py — 网格自动交易股票池管理器(阶段一:T日标记)
|
||||||
|
===================================================================
|
||||||
|
后台 daemon 线程运行,每日定时:
|
||||||
|
09:25 K线数据同步
|
||||||
|
09:30 执行评分
|
||||||
|
15:30 沉寂检测标记
|
||||||
|
周五15:30 额外执行周度淘汰标记
|
||||||
|
|
||||||
|
所有操作只记录到数据库,不执行真实交易(阶段二实现)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
from core.scoring.models import PendingPoolAction, ScoringResult
|
||||||
|
from core.sfgrid.model import SFGridTradeTarget
|
||||||
|
from core.sfgrid.bus_events import EventPoolMark
|
||||||
|
from core.eventbus import event_bus
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 配置
|
||||||
|
# ============================================================
|
||||||
|
TOP_N = 10 # 最大持仓数
|
||||||
|
TOP_MODEL_N = 50 # 评分池 Top N
|
||||||
|
ELIM_WINDOW = 2 # 连续 N 周不在 Top50 则淘汰
|
||||||
|
SLUMBER_DAYS = 10 # 沉寂触发天数(连续)
|
||||||
|
SLUMBER_TRIGGERS = 3 # 沉寂触发特征数(5个中触发几个)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 沉寂检测 — 直接复用 ref_backtest_strategy.py 的纯函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def is_slumbering(df: pd.DataFrame,
|
||||||
|
lookback_60: int = 60,
|
||||||
|
lookback_20: int = 20,
|
||||||
|
min_triggers: int = SLUMBER_TRIGGERS) -> bool:
|
||||||
|
"""
|
||||||
|
检测一只股是否陷入'沉寂'(资金离场后长期低位震荡)。
|
||||||
|
5 特征,>= min_triggers 触发则返回 True。
|
||||||
|
|
||||||
|
df 要求:包含 close/high/low/volume 列,index 为日期升序,
|
||||||
|
至少 60 条记录。
|
||||||
|
"""
|
||||||
|
if df is None or len(df) < lookback_60:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 过滤停牌日期(volume=0 的行会导致 log_ret = NaN)
|
||||||
|
active = df[df["volume"] > 0]
|
||||||
|
if len(active) < lookback_60:
|
||||||
|
return False
|
||||||
|
|
||||||
|
sub = active.tail(lookback_60)
|
||||||
|
close = sub["close"].values
|
||||||
|
high = sub["high"].values
|
||||||
|
low = sub["low"].values
|
||||||
|
vol = sub["volume"].values
|
||||||
|
|
||||||
|
# 1. 波动率塌陷
|
||||||
|
log_ret = np.log(close[1:] / close[:-1])
|
||||||
|
if len(log_ret) < lookback_20:
|
||||||
|
return False
|
||||||
|
vol_20d = float(np.std(log_ret[-lookback_20:], ddof=1))
|
||||||
|
vol_60d = float(np.std(log_ret, ddof=1))
|
||||||
|
vol_collapse = (vol_60d > 0) and (vol_20d / vol_60d < 0.6)
|
||||||
|
|
||||||
|
# 2. 振幅萎缩
|
||||||
|
amp_20d = float(np.mean((high[-lookback_20:] - low[-lookback_20:]) / close[-lookback_20:]) * 100)
|
||||||
|
amp_shrink = amp_20d < 2.5
|
||||||
|
|
||||||
|
# 3. 成交量枯竭
|
||||||
|
avg_vol_20 = float(np.mean(vol[-lookback_20:]))
|
||||||
|
avg_vol_60 = float(np.mean(vol))
|
||||||
|
vol_dry = (avg_vol_60 > 0) and (avg_vol_20 / avg_vol_60 < 0.5)
|
||||||
|
|
||||||
|
# 4. 价格弱势
|
||||||
|
price_max_60 = float(np.max(close))
|
||||||
|
price_weak = price_max_60 > 0 and (close[-1] / price_max_60) < 0.85
|
||||||
|
|
||||||
|
# 5. 反弹失败
|
||||||
|
recent_high_30 = float(np.max(high[-30:]))
|
||||||
|
past_high_60 = float(np.max(high))
|
||||||
|
rebound_fail = past_high_60 > 0 and (recent_high_30 / past_high_60) < 0.95
|
||||||
|
|
||||||
|
triggers = [vol_collapse, amp_shrink, vol_dry, price_weak, rebound_fail]
|
||||||
|
return sum(triggers) >= min_triggers
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 工具函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def is_trading_day(td: date) -> bool:
|
||||||
|
"""简单判断是否为交易日(周一~周五)"""
|
||||||
|
return td.weekday() < 5 # 0=周一, 4=周五
|
||||||
|
|
||||||
|
|
||||||
|
def get_week_id(td: date) -> int:
|
||||||
|
"""返回年内周序号(周一为起始)"""
|
||||||
|
return td.isocalendar()[1]
|
||||||
|
|
||||||
|
|
||||||
|
def seconds_to_target(target_hour: int, target_minute: int) -> float:
|
||||||
|
"""计算从现在到目标时间(当天 target_hour:target_minute)的秒数。"""
|
||||||
|
now = datetime.now()
|
||||||
|
today_target = datetime(now.year, now.month, now.day, target_hour, target_minute, 0)
|
||||||
|
if now >= today_target:
|
||||||
|
# 今天已过,推到明天
|
||||||
|
today_target += timedelta(days=1)
|
||||||
|
return (today_target - now).total_seconds()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 股票池管理器
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class PoolManager:
|
||||||
|
"""
|
||||||
|
网格股票池自动管理器(阶段一:T日标记)
|
||||||
|
|
||||||
|
使用 threading.Timer 递归调度,实现每日 09:25 / 09:30 / 15:30 定时任务。
|
||||||
|
所有操作只写入数据库,不执行真实交易。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
|
# 周度淘汰历史:key=股票代码,value=[(week_id, rank), ...]
|
||||||
|
# rank=0 表示在 Top50,rank=-1 表示不在 Top50
|
||||||
|
self._top50_history: dict[str, list[tuple[int, int]]] = defaultdict(list)
|
||||||
|
|
||||||
|
# 调试:手动触发时传入自定义日期(仅供测试用)
|
||||||
|
self._override_date: date | None = None
|
||||||
|
|
||||||
|
# 加载历史排名数据(从 ScoringResult 重建)
|
||||||
|
self._rebuild_top50_history()
|
||||||
|
|
||||||
|
# ---- 对外控制接口 ----
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""启动后台管理线程(幂等)"""
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
|
PrintLog(LogLevel.WARNING, '[PoolManager] 已启动,忽略重复调用')
|
||||||
|
return
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._thread = threading.Thread(target=self._run_loop, daemon=True, name='PoolManager')
|
||||||
|
self._thread.start()
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 已启动')
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""停止后台管理线程"""
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.join(timeout=5)
|
||||||
|
self._thread = None
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 已停止')
|
||||||
|
|
||||||
|
# ---- 每日定时任务 ----
|
||||||
|
|
||||||
|
def _run_loop(self):
|
||||||
|
"""后台线程主循环:计算出距下次任务的时间,注册下一个 Timer"""
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
now = datetime.now()
|
||||||
|
td = self._override_date or now.date()
|
||||||
|
|
||||||
|
# 确定当天要执行的任务及距其的秒数
|
||||||
|
delay, task_name = self._compute_next_delay(now, td)
|
||||||
|
|
||||||
|
# 保证 Timer 不会太久(最多 24 小时),处理节假日顺延
|
||||||
|
if delay <= 0:
|
||||||
|
delay = 60 # 异常时等 1 分钟重算
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[PoolManager] 计划任务 "{task_name}",{delay:.0f} 秒后执行')
|
||||||
|
|
||||||
|
timer = threading.Timer(delay, self._execute_task, args=(task_name, td))
|
||||||
|
timer.name = f'PoolManager-{task_name}'
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
# 等待定时器完成或停止信号
|
||||||
|
timer.join()
|
||||||
|
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
def _compute_next_delay(self, now: datetime, td: date) -> tuple[float, str]:
|
||||||
|
"""
|
||||||
|
计算距下一个任务的时间和任务名称。
|
||||||
|
任务顺序:09:25 → 09:30 → 15:30 → (下一天 09:25)
|
||||||
|
"""
|
||||||
|
h, m = now.hour, now.minute
|
||||||
|
|
||||||
|
if h < 9 or (h == 9 and m < 25):
|
||||||
|
# 现在在 09:25 之前 → 先执行 09:25
|
||||||
|
return seconds_to_target(9, 25), '_sync_data'
|
||||||
|
elif h == 9 and 25 <= m < 30:
|
||||||
|
# 09:25~09:30 之间 → 立即执行 09:30
|
||||||
|
return 0.0, '_run_scoring'
|
||||||
|
elif (h == 9 and m >= 30) or h < 15:
|
||||||
|
# 09:30 之后、15:30 之前 → 执行 15:30
|
||||||
|
return seconds_to_target(15, 30), '_mark_slumber'
|
||||||
|
elif h >= 15:
|
||||||
|
# 15:30 之后 → 推到下一天 09:25
|
||||||
|
delay = seconds_to_target(9, 25) + (td.weekday() < 4 and 1 or 3) * 86400 # 工作日+1,周末+3
|
||||||
|
return delay, '_sync_data'
|
||||||
|
|
||||||
|
# 默认兜底
|
||||||
|
return seconds_to_target(9, 25), '_sync_data'
|
||||||
|
|
||||||
|
def _execute_task(self, task_name: str, td: date):
|
||||||
|
"""根据任务名执行对应任务"""
|
||||||
|
try:
|
||||||
|
if task_name == '_sync_data':
|
||||||
|
self._sync_data()
|
||||||
|
# 同步完成后自动调度评分
|
||||||
|
self._run_scoring()
|
||||||
|
# 调度 15:30
|
||||||
|
self._schedule_next(target_hour=15, target_minute=30,
|
||||||
|
task_name='_mark_slumber', td=td)
|
||||||
|
|
||||||
|
elif task_name == '_run_scoring':
|
||||||
|
self._run_scoring()
|
||||||
|
# 评分完成后调度沉寂检测
|
||||||
|
self._schedule_next(target_hour=15, target_minute=30,
|
||||||
|
task_name='_mark_slumber', td=td)
|
||||||
|
|
||||||
|
elif task_name == '_mark_slumber':
|
||||||
|
self._mark_slumber(td)
|
||||||
|
# 如果是周五,额外调度淘汰标记
|
||||||
|
if td.weekday() == 4: # 周五
|
||||||
|
self._mark_weekly_elim(td)
|
||||||
|
# 调度下一天 09:25
|
||||||
|
self._schedule_next(target_hour=9, target_minute=25,
|
||||||
|
task_name='_sync_data', td=td)
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[PoolManager] 任务 {task_name} 执行异常: {e}')
|
||||||
|
|
||||||
|
def _schedule_next(self, target_hour: int, target_minute: int,
|
||||||
|
task_name: str, td: date):
|
||||||
|
"""注册一个 Timer,在指定时间执行 task_name"""
|
||||||
|
# 计算 delay
|
||||||
|
delay = seconds_to_target(target_hour, target_minute)
|
||||||
|
# 如果 target 在过去(如周末顺延),加 1 天
|
||||||
|
if delay <= 0:
|
||||||
|
delay += 86400
|
||||||
|
|
||||||
|
def wrapper():
|
||||||
|
now = datetime.now()
|
||||||
|
# 重新计算真实日期
|
||||||
|
exec_td = self._override_date or now.date()
|
||||||
|
self._execute_task(task_name, exec_td)
|
||||||
|
|
||||||
|
timer = threading.Timer(delay, wrapper, name=f'PoolManager-sched-{task_name}')
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
# ---- 任务实现 ----
|
||||||
|
|
||||||
|
def _sync_data(self):
|
||||||
|
"""09:25 — 同步 K 线数据"""
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 开始同步K线数据...')
|
||||||
|
try:
|
||||||
|
from core.scoring.sync.kline_sync import KlineStockSync
|
||||||
|
syncer = KlineStockSync()
|
||||||
|
syncer.run()
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] K线数据同步完成')
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[PoolManager] K线同步失败: {e}')
|
||||||
|
|
||||||
|
def _run_scoring(self):
|
||||||
|
"""09:30 — 执行评分"""
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 开始执行评分...')
|
||||||
|
try:
|
||||||
|
from core.scoring.inference.scorer import GridSeekerPipeline
|
||||||
|
pipeline = GridSeekerPipeline()
|
||||||
|
td = self._override_date or date.today()
|
||||||
|
result = pipeline.run(trade_date=td)
|
||||||
|
if not result.empty:
|
||||||
|
pipeline.persist(result, trade_date=td)
|
||||||
|
PrintLog(LogLevel.INFO, f'[PoolManager] 评分完成,写入 {len(result)} 条结果')
|
||||||
|
# 触发 UI 刷新
|
||||||
|
event_bus.publish(EventPoolMark, {
|
||||||
|
'action': 'scoring',
|
||||||
|
'stock_codes': [],
|
||||||
|
'count': len(result),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.WARNING, '[PoolManager] 评分无结果')
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[PoolManager] 评分失败: {e}')
|
||||||
|
|
||||||
|
def _mark_slumber(self, td: date):
|
||||||
|
"""15:30 — 沉寂检测,标记待卖出股"""
|
||||||
|
if not is_trading_day(td):
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 非交易日,跳过沉寂检测')
|
||||||
|
return
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 开始沉寂检测...')
|
||||||
|
|
||||||
|
# 获取所有 enabled=True 的持仓
|
||||||
|
targets = SFGridTradeTarget.select().where(SFGridTradeTarget.enabled == True)
|
||||||
|
if not targets:
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 无持仓,跳过沉寂检测')
|
||||||
|
return
|
||||||
|
|
||||||
|
marked: list[str] = []
|
||||||
|
today_str = td.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
for tgt in targets:
|
||||||
|
try:
|
||||||
|
# 读取该股 K 线数据(从本地 SQLite KlineStock 表)
|
||||||
|
from core.scoring.models import KlineStock
|
||||||
|
klines = list(KlineStock
|
||||||
|
.select()
|
||||||
|
.where(KlineStock.stock_code == tgt.stock_code)
|
||||||
|
.order_by(KlineStock.trade_date)
|
||||||
|
.dicts())
|
||||||
|
|
||||||
|
if not klines or len(klines) < 60:
|
||||||
|
continue
|
||||||
|
|
||||||
|
df = pd.DataFrame(klines)
|
||||||
|
if 'trade_date' in df.columns and not pd.api.types.is_datetime64_any_dtype(df['trade_date']):
|
||||||
|
df['trade_date'] = pd.to_datetime(df['trade_date'])
|
||||||
|
df = df.set_index('trade_date').sort_index()
|
||||||
|
|
||||||
|
if is_slumbering(df):
|
||||||
|
self._write_pending(td, 'liquidate', tgt.stock_code,
|
||||||
|
reason=f'沉寂检测触发({today_str})')
|
||||||
|
marked.append(tgt.stock_code)
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[PoolManager] 沉寂标记: {tgt.stock_code}')
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.WARNING,
|
||||||
|
f'[PoolManager] 沉寂检测异常 {tgt.stock_code}: {e}')
|
||||||
|
|
||||||
|
if marked:
|
||||||
|
event_bus.publish(EventPoolMark, {
|
||||||
|
'action': 'liquidate',
|
||||||
|
'stock_codes': marked,
|
||||||
|
'count': len(marked),
|
||||||
|
})
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[PoolManager] 沉寂检测完成,标记 {len(marked)} 只股')
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 沉寂检测完成,无股触发')
|
||||||
|
|
||||||
|
def _mark_weekly_elim(self, td: date):
|
||||||
|
"""周五 15:30 — 周度评分淘汰标记(连续2周不在Top50则卖出)"""
|
||||||
|
if not is_trading_day(td):
|
||||||
|
return
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 开始周度淘汰检测...')
|
||||||
|
|
||||||
|
# 获取最近 N 周的评分排名
|
||||||
|
week_id = get_week_id(td)
|
||||||
|
today_str = td.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 查今日 Top50
|
||||||
|
top50_codes: Set[str] = set()
|
||||||
|
rows = (ScoringResult
|
||||||
|
.select(ScoringResult.stock_code)
|
||||||
|
.where(ScoringResult.trade_date == td)
|
||||||
|
.where(ScoringResult.score_rank <= TOP_MODEL_N)
|
||||||
|
.dicts())
|
||||||
|
for r in rows:
|
||||||
|
top50_codes.add(r['stock_code'])
|
||||||
|
|
||||||
|
if not top50_codes:
|
||||||
|
PrintLog(LogLevel.WARNING, '[PoolManager] 今日无评分数据,无法进行淘汰检测')
|
||||||
|
return
|
||||||
|
|
||||||
|
# 更新历史排名
|
||||||
|
targets = SFGridTradeTarget.select().where(SFGridTradeTarget.enabled == True)
|
||||||
|
for tgt in targets:
|
||||||
|
code = tgt.stock_code
|
||||||
|
rank_in_top50 = 0 if code in top50_codes else -1
|
||||||
|
self._top50_history[code].append((week_id, rank_in_top50))
|
||||||
|
# 只保留近 8 周记录,防止内存膨胀
|
||||||
|
if len(self._top50_history[code]) > 8:
|
||||||
|
self._top50_history[code] = self._top50_history[code][-8:]
|
||||||
|
|
||||||
|
# 检测连续 N 周不在 Top50 的股
|
||||||
|
marked: list[str] = []
|
||||||
|
for tgt in targets:
|
||||||
|
code = tgt.stock_code
|
||||||
|
hist = self._top50_history.get(code, [])
|
||||||
|
if len(hist) < ELIM_WINDOW:
|
||||||
|
continue
|
||||||
|
recent = hist[-ELIM_WINDOW:]
|
||||||
|
if all(rank == -1 for _, rank in recent):
|
||||||
|
self._write_pending(td, 'eliminate', code,
|
||||||
|
reason=f'连续{ELIM_WINDOW}周不在Top50({today_str})')
|
||||||
|
marked.append(code)
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[PoolManager] 淘汰标记: {code},历史: {recent}')
|
||||||
|
|
||||||
|
if marked:
|
||||||
|
event_bus.publish(EventPoolMark, {
|
||||||
|
'action': 'eliminate',
|
||||||
|
'stock_codes': marked,
|
||||||
|
'count': len(marked),
|
||||||
|
})
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[PoolManager] 周度淘汰检测完成,标记 {len(marked)} 只股')
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.INFO, '[PoolManager] 周度淘汰检测完成,无股触发')
|
||||||
|
|
||||||
|
def _write_pending(self, td: date, action_type: str, stock_code: str, reason: str = ''):
|
||||||
|
"""写入 pending_pool_actions 表(幂等)"""
|
||||||
|
try:
|
||||||
|
PendingPoolAction.insert(
|
||||||
|
action_date=td,
|
||||||
|
action_type=action_type,
|
||||||
|
stock_code=stock_code,
|
||||||
|
reason=reason,
|
||||||
|
).on_conflict_replace().execute()
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.WARNING,
|
||||||
|
f'[PoolManager] 写入待处理操作失败: {e}')
|
||||||
|
|
||||||
|
def _rebuild_top50_history(self):
|
||||||
|
"""启动时从 ScoringResult 表重建 _top50_history(用于淘汰判断)"""
|
||||||
|
try:
|
||||||
|
rows = (ScoringResult
|
||||||
|
.select(ScoringResult.stock_code, ScoringResult.trade_date,
|
||||||
|
ScoringResult.score_rank)
|
||||||
|
.where(ScoringResult.score_rank <= TOP_MODEL_N)
|
||||||
|
.order_by(ScoringResult.trade_date)
|
||||||
|
.dicts())
|
||||||
|
|
||||||
|
week_groups: dict = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
td = r['trade_date']
|
||||||
|
if isinstance(td, str):
|
||||||
|
td = datetime.strptime(td, '%Y-%m-%d').date()
|
||||||
|
week_id = get_week_id(td)
|
||||||
|
week_groups[(r['stock_code'], week_id)].append(r['score_rank'])
|
||||||
|
|
||||||
|
# 取每周最新一条(排名最靠前的)
|
||||||
|
for (code, week_id), ranks in week_groups.items():
|
||||||
|
best_rank = min(ranks)
|
||||||
|
rank_val = 0 if best_rank <= TOP_MODEL_N else -1
|
||||||
|
self._top50_history[code].append((week_id, rank_val))
|
||||||
|
|
||||||
|
# 去重,每 week_id 只留一条
|
||||||
|
for code in self._top50_history:
|
||||||
|
seen = set()
|
||||||
|
cleaned = []
|
||||||
|
for w, r in self._top50_history[code]:
|
||||||
|
if w not in seen:
|
||||||
|
seen.add(w)
|
||||||
|
cleaned.append((w, r))
|
||||||
|
self._top50_history[code] = cleaned
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'[PoolManager] 历史排名已重建,{len(self._top50_history)} 只股有历史数据')
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR, f'[PoolManager] 重建历史排名失败: {e}')
|
||||||
|
|
||||||
|
# ---- 手动触发(供 UI 调试按钮调用)----
|
||||||
|
|
||||||
|
def trigger_sync(self):
|
||||||
|
"""手动触发数据同步"""
|
||||||
|
threading.Thread(target=self._sync_data, daemon=True).start()
|
||||||
|
|
||||||
|
def trigger_scoring(self):
|
||||||
|
"""手动触发评分"""
|
||||||
|
threading.Thread(target=self._run_scoring, daemon=True).start()
|
||||||
|
|
||||||
|
def trigger_slumber(self, td: date | None = None):
|
||||||
|
"""手动触发沉寂检测"""
|
||||||
|
td = td or (self._override_date or date.today())
|
||||||
|
threading.Thread(target=self._mark_slumber, args=(td,), daemon=True).start()
|
||||||
|
|
||||||
|
def trigger_weekly_elim(self, td: date | None = None):
|
||||||
|
"""手动触发周度淘汰检测"""
|
||||||
|
td = td or (self._override_date or date.today())
|
||||||
|
threading.Thread(target=self._mark_weekly_elim, args=(td,), daemon=True).start()
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
"""
|
||||||
|
网格交易策略控制器
|
||||||
|
|
||||||
|
核心逻辑:在预设的价格网格上低买高卖,每个网格节点同时挂一对买卖单,
|
||||||
|
成交后自动切换到相邻网格并刷新订单。
|
||||||
|
|
||||||
|
网格结构示意(以 grid_index 为中心):
|
||||||
|
价格从高到低排列在 getPriceGrid() 列表中
|
||||||
|
grid_index=0 是最低价(底部),越大价格越高(顶部)
|
||||||
|
|
||||||
|
卖出方向(上移): grid_index - 1 (价格更低,空单)
|
||||||
|
买入方向(下移): grid_index + 1 (价格更高,多单)
|
||||||
|
|
||||||
|
成交 → 上移一格(卖出成交): grid_index -= 1,赚取一格差价
|
||||||
|
成交 → 下移一格(买入成交): grid_index += 1,持仓成本降低
|
||||||
|
|
||||||
|
状态机:
|
||||||
|
status=0: 未建仓,需先下建仓单买入初始仓位
|
||||||
|
status=1: 已建仓,运行网格交易(上下各挂一单)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from core.logger import LogLevel, PrintLog
|
||||||
|
from core.qmt import qmtv
|
||||||
|
from core.sfgrid import bus_events
|
||||||
|
from core.sfgrid.bus_events import EventTradeTargetUpdate
|
||||||
|
import core.sfgrid.model as model
|
||||||
|
from core.eventbus import event_bus
|
||||||
|
from core.constants import OrderTypeBuy, OrderTypeSell
|
||||||
|
|
||||||
|
from xtquant import xtconstant
|
||||||
|
from xtquant.xttype import XtOrderError, XtOrderResponse, XtTrade
|
||||||
|
import threading
|
||||||
|
import core.eventbus as eBus
|
||||||
|
|
||||||
|
|
||||||
|
class SFGridStrategy:
|
||||||
|
"""
|
||||||
|
单标的网格交易策略控制器
|
||||||
|
|
||||||
|
每个 SFGridTradeTarget 数据库记录对应一个 SFGridStrategy 实例。
|
||||||
|
负责:建仓 → 挂网格单 → 监听成交/错误事件 → 调整网格 → 刷新订单。
|
||||||
|
|
||||||
|
订单 remark 格式: "{订单类型},{网格索引},{股票代码}"
|
||||||
|
例: "BUY,3,000001" 表示在网格索引 3 处挂买入单,标的 000001
|
||||||
|
例: "INIT,1,000001" 表示建仓单,建仓在网格索引 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, tradeTarget: model.SFGridTradeTarget):
|
||||||
|
"""
|
||||||
|
初始化网格策略控制器
|
||||||
|
|
||||||
|
参数:
|
||||||
|
tradeTarget: 数据库中的交易标记录,包含网格参数、当前状态等
|
||||||
|
"""
|
||||||
|
self.tradeTarget: model.SFGridTradeTarget = tradeTarget
|
||||||
|
|
||||||
|
# orderGrid 必须在所有可能触发回调的操作之前初始化
|
||||||
|
# orderGrid: 网格索引 → 订单编号(seq 或 order_id)的映射
|
||||||
|
# seq 是 xtquant 返回的下单序号(下单瞬间),order_id 是交易所返回的正式订单号(异步回调后更新)
|
||||||
|
self.orderGrid = {} # {grid_index: order_seq | order_id}
|
||||||
|
|
||||||
|
# 数据更新锁:保护 orderGrid 和 tradeTarget 的并发访问
|
||||||
|
# QMT 回调在独立线程中触发,必须在可能触发回调的操作之前创建
|
||||||
|
# 注意:这个锁必须在订阅事件之前创建,防止事件在初始化期间触发
|
||||||
|
# 注意:必须使用 RLock 而非 Lock,因为 refreshGridOrder 在持有此锁时也会被调用
|
||||||
|
#(如 onOrderTrade 回调中),Lock 会导致同一线程重复获取时永久阻塞(死锁)
|
||||||
|
self.dataUpdateLock = threading.RLock()
|
||||||
|
|
||||||
|
# 订阅事件总线:监听订单创建、成交、失败三种事件
|
||||||
|
event_bus.subscribe(eBus.MarketOrderCreated, self.onOrderCreateAsync)
|
||||||
|
event_bus.subscribe(eBus.MarketOrderTraded, self.onOrderTrade)
|
||||||
|
event_bus.subscribe(eBus.MarketOrderError, self.onOrderError)
|
||||||
|
event_bus.subscribe(eBus.EventMarketActiveSwitch, self.onMarketActiveSwitch)
|
||||||
|
|
||||||
|
# 获取当日涨跌停价格(用于价格边界校验)
|
||||||
|
self.todayUpStopPrice = qmtv.dailyUpStop(tradeTarget.stock_code) # type: ignore
|
||||||
|
self.todayDownStopPrice = qmtv.dailyDownStop(tradeTarget.stock_code) # type: ignore
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- [DEBUG] 标的{tradeTarget.targetName()} 构造开始: '
|
||||||
|
f'网格={tradeTarget.grid_index}, 启用={tradeTarget.enabled}')
|
||||||
|
|
||||||
|
# 加载券商侧已存在的未成交订单,恢复到 orderGrid 中
|
||||||
|
self.loadExistOrders()
|
||||||
|
|
||||||
|
# 根据数据库中的 enabled 字段决定是否启动交易
|
||||||
|
self.enabledTrading(tradeTarget.enabled) # type: ignore
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- [DEBUG] 标的{tradeTarget.targetName()} 构造结束: '
|
||||||
|
f'grid_index={self.tradeTarget.grid_index}')
|
||||||
|
|
||||||
|
# ── 订单加载 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def loadExistOrders(self):
|
||||||
|
"""
|
||||||
|
从券商侧加载该策略的未成交订单,恢复到 orderGrid
|
||||||
|
|
||||||
|
用于程序重启后恢复状态:数据库中可能没有记录所有挂单,
|
||||||
|
通过 queryPendingOrder 从 QMT 获取实际存在的订单。
|
||||||
|
"""
|
||||||
|
orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore
|
||||||
|
for order in orders:
|
||||||
|
# 只处理本策略的订单(通过 strategy_name 过滤)
|
||||||
|
if order.strategy_name != self.getName():
|
||||||
|
continue
|
||||||
|
parsed = self._parse_remark(order.order_remark)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
_, gridIdx, _ = parsed
|
||||||
|
self.orderGrid[gridIdx] = order.order_id
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- 标的[{self.tradeTarget.targetName()}] 初始化: '
|
||||||
|
f'加载现有订单, grid-{gridIdx} order_id:{self.orderGrid[gridIdx]}')
|
||||||
|
|
||||||
|
def printPendingOrder(self):
|
||||||
|
"""调试用:打印当前所有挂单"""
|
||||||
|
for idx, order_id in self.orderGrid.items():
|
||||||
|
PrintLog(LogLevel.DEBUG, f" {idx} : {order_id}")
|
||||||
|
|
||||||
|
# ── 市场状态切换 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def onMarketActiveSwitch(self, isActive: bool):
|
||||||
|
"""
|
||||||
|
市场数据状态切换回调(由 UI 层调用)
|
||||||
|
|
||||||
|
当市场数据从不可用变为可用时,如果策略已启用则刷新网格订单。
|
||||||
|
"""
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- [市场状态切换] 标的{self.tradeTarget.targetName()} '
|
||||||
|
f'isActive={isActive}, enabled={self.tradeTarget.enabled}')
|
||||||
|
if isActive and self.tradeTarget.enabled:
|
||||||
|
self.refreshGridOrder()
|
||||||
|
|
||||||
|
# ── 核心:网格下单逻辑 ────────────────────────────────────
|
||||||
|
|
||||||
|
def refreshGridOrder(self):
|
||||||
|
"""
|
||||||
|
刷新网格挂单 —— 策略的核心下单方法
|
||||||
|
|
||||||
|
逻辑分支:
|
||||||
|
1. 前置检查: 市场未激活 或 策略未启用 → 跳过不下单
|
||||||
|
2. status=0 (未建仓): 下一个建仓单(买入初始仓位)
|
||||||
|
3. status=1 (已建仓): 在 grid_index 上下各挂一单
|
||||||
|
- 上方 (sellIdx = grid_index - 1): 挂卖出单(价格更低时卖出获利)
|
||||||
|
- 下方 (buyIdx = grid_index + 1): 挂买入单(价格更低时补仓)
|
||||||
|
每个方向都先检查是否已存在同价位订单,避免重复下单
|
||||||
|
"""
|
||||||
|
# ── 前置检查:市场和策略状态 ──
|
||||||
|
# 注意:这里用 dataUpdateLock 包裹检查和下单操作,防止竞态条件:
|
||||||
|
# 主线程在检查 isMarketActive 时,另一线程的行情回调可能同时将其设为 True,
|
||||||
|
# 导致部分标的通过检查下单,部分被拦截(表现为一票有单、一票无单)
|
||||||
|
with self.dataUpdateLock:
|
||||||
|
if not qmtv.isMarketActive or not self.tradeTarget.enabled:
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- 市场 {qmtv.isMarketActive}, 策略 {self.getName()} '
|
||||||
|
f'{self.tradeTarget.enabled}, 不下单')
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取当前该标的所有未成交订单
|
||||||
|
orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore
|
||||||
|
|
||||||
|
# ── 统一网格逻辑 ──
|
||||||
|
# grid_index=0 空仓: 只挂买单 @ grid[1],无持仓可卖
|
||||||
|
# grid_index>0 有仓: 上方挂卖单 @ grid[idx-1],下方挂买单 @ grid[idx+1]
|
||||||
|
if self.tradeTarget.grid_index >= 0:
|
||||||
|
currentIdx = self.tradeTarget.grid_index # type: ignore
|
||||||
|
|
||||||
|
# --- 上方挂卖出单(空单)---
|
||||||
|
# 条件: grid_index > 0,即当前位置不是价格最低点,还有向下(卖出)空间
|
||||||
|
if currentIdx > 0:
|
||||||
|
sellIdx = currentIdx - 1 # 向上一个网格
|
||||||
|
sellPrice = self.tradeTarget.getPriceGrid()[sellIdx]
|
||||||
|
sell_remark = self._make_remark(OrderTypeSell, sellIdx)
|
||||||
|
|
||||||
|
# 检查是否已存在同 remark 的卖单(避免重复挂单)
|
||||||
|
# 注意:必须同时查 QMT 订单簿和本地 orderGrid
|
||||||
|
# - QMT 订单簿:已确认的订单(onOrderCreateAsync 之后)
|
||||||
|
# - orderGrid:本地下单后、回调前的新单(orderAsync 返回后直接写入)
|
||||||
|
# 两者并集才能完整覆盖所有已存在订单,防止 onOrderCreateAsync 回调
|
||||||
|
# 之前再次触发 refreshGridOrder 导致重复下单
|
||||||
|
qmt_has_order = any(o.order_remark == sell_remark for o in orders)
|
||||||
|
local_has_order = sellIdx in self.orderGrid
|
||||||
|
if not qmt_has_order and not local_has_order:
|
||||||
|
# 卖单价格超过涨停价 → 今日无法成交,跳过下单
|
||||||
|
# 防御性检查:若属性未初始化(初始化顺序导致),先获取
|
||||||
|
if not hasattr(self, 'todayUpStopPrice') or self.todayUpStopPrice is None:
|
||||||
|
self.todayUpStopPrice = qmtv.dailyUpStop(self.tradeTarget.stock_code) # type: ignore
|
||||||
|
if sellPrice > self.todayUpStopPrice:
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- 标的[{self.tradeTarget.targetName()}] '
|
||||||
|
f'上方网格[{sellIdx}]卖价 {sellPrice:.3f} > 涨停价 {self.todayUpStopPrice:.3f},'
|
||||||
|
f'今日无法下卖单 (当前网格基准 grid-{currentIdx})')
|
||||||
|
else:
|
||||||
|
tmpOrderSeq = qmtv.orderAsync(
|
||||||
|
str(self.tradeTarget.stock_code),
|
||||||
|
self.tradeTarget.grid_volume,
|
||||||
|
xtconstant.STOCK_SELL, # 卖出
|
||||||
|
sellPrice,
|
||||||
|
xtconstant.FIX_PRICE,
|
||||||
|
sell_remark,
|
||||||
|
self.getName(),
|
||||||
|
)
|
||||||
|
self.orderGrid[sellIdx] = tmpOrderSeq
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
|
||||||
|
f'下空单,价格: {sellPrice:.3f}')
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
|
||||||
|
f'已存在同价位空单,跳过下单')
|
||||||
|
|
||||||
|
# --- 下方挂买入单(多单)---
|
||||||
|
# 条件: grid_index < 价格网格长度-1,即当前位置不是价格最高点,还有向上(买入)空间
|
||||||
|
if currentIdx < len(self.tradeTarget.getPriceGrid()) - 1:
|
||||||
|
buyIdx = currentIdx + 1 # 向下一个网格
|
||||||
|
buyPrice = self.tradeTarget.getPriceGrid()[buyIdx]
|
||||||
|
buy_remark = self._make_remark(OrderTypeBuy, buyIdx)
|
||||||
|
|
||||||
|
# 检查是否已存在同 remark 的买单(避免重复挂单)
|
||||||
|
# 必须同时查 QMT 订单簿和本地 orderGrid(见上方卖单注释)
|
||||||
|
qmt_has_order = any(o.order_remark == buy_remark for o in orders)
|
||||||
|
local_has_order = buyIdx in self.orderGrid
|
||||||
|
if not qmt_has_order and not local_has_order:
|
||||||
|
# 买单价格低于跌停价 → 今日无法成交,跳过下单
|
||||||
|
# 防御性检查:若属性未初始化(初始化顺序导致),先获取
|
||||||
|
if not hasattr(self, 'todayDownStopPrice') or self.todayDownStopPrice is None:
|
||||||
|
self.todayDownStopPrice = qmtv.dailyDownStop(self.tradeTarget.stock_code) # type: ignore
|
||||||
|
if buyPrice < self.todayDownStopPrice:
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- 标的[{self.tradeTarget.targetName()}] '
|
||||||
|
f'下方网格[{buyIdx}]买价 {buyPrice:.3f} < 跌停价 {self.todayDownStopPrice:.3f},'
|
||||||
|
f'今日无法下买单 (当前网格基准 grid-{currentIdx})')
|
||||||
|
else:
|
||||||
|
tmpOrderSeq = qmtv.orderAsync(
|
||||||
|
str(self.tradeTarget.stock_code),
|
||||||
|
self.tradeTarget.grid_volume,
|
||||||
|
xtconstant.STOCK_BUY, # 买入
|
||||||
|
buyPrice,
|
||||||
|
xtconstant.FIX_PRICE,
|
||||||
|
buy_remark,
|
||||||
|
self.getName(),
|
||||||
|
)
|
||||||
|
self.orderGrid[buyIdx] = tmpOrderSeq
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
|
||||||
|
f'下多单,价格: {buyPrice:.3f}')
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
|
||||||
|
f'已存在同价位多单,跳过下单')
|
||||||
|
else:
|
||||||
|
# grid_index 已到达价格网格上边界,无法再挂买入单(价格已经到顶)
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: '
|
||||||
|
f'已过下边界,停止多单交易')
|
||||||
|
|
||||||
|
# ── 标的管理 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def deleteTradeTarget(self, tradeTarget: model.SFGridTradeTarget):
|
||||||
|
"""
|
||||||
|
从数据库中删除该交易标的
|
||||||
|
|
||||||
|
同时发布 EventTradeTargetDeleted 事件通知 UI 刷新。
|
||||||
|
"""
|
||||||
|
PrintLog(LogLevel.INFO, f'|- 标的{tradeTarget.targetName()}信息删除: START')
|
||||||
|
self.dataUpdateLock.acquire()
|
||||||
|
try:
|
||||||
|
tradeTarget.delete_instance()
|
||||||
|
event_bus.publish(bus_events.EventTradeTargetDeleted, tradeTarget)
|
||||||
|
PrintLog(LogLevel.INFO, f'|- 标的{tradeTarget.targetName()}信息删除: END')
|
||||||
|
finally:
|
||||||
|
self.dataUpdateLock.release()
|
||||||
|
|
||||||
|
# ── 交易启停控制 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def enabledTrading(self, enabled: bool) -> model.SFGridTradeTarget:
|
||||||
|
"""
|
||||||
|
启用或停用该标的的网格交易
|
||||||
|
|
||||||
|
启用时 (enabled=True):
|
||||||
|
- grid_index=0 空仓: 直接调用 refreshGridOrder(只挂买单)
|
||||||
|
- grid_index>0 有仓: 检查持仓是否满足 grid_volume × grid_index
|
||||||
|
满足则刷新网格单,不满足则回退 enabled=False(风控保护)
|
||||||
|
|
||||||
|
停用时 (enabled=False):
|
||||||
|
- 取消该标的所有未成交订单,停止交易监控
|
||||||
|
"""
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f" |- [DEBUG] enabledTrading({enabled}) 调用前: "
|
||||||
|
f"grid_index={self.tradeTarget.grid_index}")
|
||||||
|
|
||||||
|
self.tradeTarget.enabled = enabled # type: ignore
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
# ── 启用交易 ──
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f" |- 标的{self.tradeTarget.targetName()}交易启动, "
|
||||||
|
f"持仓量:{self.tradeTarget.current_position}")
|
||||||
|
|
||||||
|
if self.tradeTarget.grid_index == 0:
|
||||||
|
# 空仓: refreshGridOrder 会在 grid[1] 挂第一笔买单
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f" |- 标的{self.tradeTarget.targetName()}空仓, "
|
||||||
|
f"等待首次买入建仓")
|
||||||
|
else:
|
||||||
|
# 有仓: 检查现有持仓是否满足当前网格位置的仓位需求
|
||||||
|
# 最小需求仓位 = 每格股数 × 当前网格索引
|
||||||
|
# 例: grid_volume=100, grid_index=3 → 需持股 300 股
|
||||||
|
minRequirePosition: int = self.tradeTarget.grid_volume * int(self.tradeTarget.grid_index) # type: ignore
|
||||||
|
|
||||||
|
if minRequirePosition <= int(self.tradeTarget.current_position): # type: ignore
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f' |- 仓位检查: 持仓需求充足, '
|
||||||
|
f'(gridVolume*gridIndex)={minRequirePosition}, '
|
||||||
|
f'当前持仓:{self.tradeTarget.current_position}')
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f' |- 仓位检查: 持仓需求不足, '
|
||||||
|
f'(gridVolume*gridIndex)={minRequirePosition}, '
|
||||||
|
f'当前持仓:{self.tradeTarget.current_position}, '
|
||||||
|
f'交易启动失败')
|
||||||
|
self.tradeTarget.enabled = False # type: ignore
|
||||||
|
|
||||||
|
# 刷新网格订单(空仓只挂买单,有仓买卖对冲)
|
||||||
|
# 只有市场活跃时才下单,收盘后不再尝试下单
|
||||||
|
if qmtv.isMarketActive:
|
||||||
|
self.refreshGridOrder()
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f' |- 市场已休市,跳过刷新网格订单')
|
||||||
|
|
||||||
|
else:
|
||||||
|
# ── 停用交易: 取消所有未成交订单 ──
|
||||||
|
orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore
|
||||||
|
for order in orders:
|
||||||
|
try:
|
||||||
|
qmtv.xt_trader.cancel_order_stock_async(qmtv.account, order.order_id)
|
||||||
|
except AttributeError:
|
||||||
|
pass # 模拟模式无 xt_trader,跳过撤单
|
||||||
|
|
||||||
|
if len(orders) > 0:
|
||||||
|
PrintLog(LogLevel.INFO, f' |- 取消未成交订单 {len(orders)}')
|
||||||
|
PrintLog(LogLevel.INFO, f" |- 标的{self.tradeTarget.targetName()}交易监控暂停")
|
||||||
|
|
||||||
|
# 持久化状态到数据库
|
||||||
|
self.saveProxy()
|
||||||
|
return self.tradeTarget
|
||||||
|
|
||||||
|
def isEnabled(self) -> bool:
|
||||||
|
"""查询交易是否已启用"""
|
||||||
|
PrintLog(LogLevel.DEBUG, f'|- 检查交易状态[{self.tradeTarget.stock_code}-{self.tradeTarget.stock_name}] - {self.tradeTarget.enabled}')
|
||||||
|
return bool(self.tradeTarget.enabled)
|
||||||
|
|
||||||
|
# ── 事件回调: 订单创建 ────────────────────────────────────
|
||||||
|
|
||||||
|
def onOrderCreateAsync(self, response: XtOrderResponse):
|
||||||
|
"""
|
||||||
|
QMT 异步下单成功回调
|
||||||
|
|
||||||
|
xtquant 下单是异步的:orderAsync() 返回 seq(序号),
|
||||||
|
交易所确认后通过此回调返回正式的 order_id。
|
||||||
|
|
||||||
|
此处将 orderGrid 中的临时 seq 替换为正式 order_id。
|
||||||
|
"""
|
||||||
|
parsed = self._filter_event(response.order_remark, response.strategy_name)
|
||||||
|
if parsed is None:
|
||||||
|
return
|
||||||
|
_, gridIdx, _ = parsed
|
||||||
|
|
||||||
|
self.dataUpdateLock.acquire()
|
||||||
|
try:
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f"委托创建通知 onOrderCreateAsync[{self.tradeTarget.targetName()}]: "
|
||||||
|
f"{response.order_id}")
|
||||||
|
# 将 orderGrid 中的临时 seq 替换为正式 order_id
|
||||||
|
self.orderGrid[gridIdx] = response.order_id
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f"委托创建通知 onOrderCreateAsync 更新 grid-{gridIdx} "
|
||||||
|
f"seq:{response.seq} -> order_id:{response.order_id}")
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR,
|
||||||
|
f"|- 委托创建通知 onOrderCreateAsync"
|
||||||
|
f"[{self.tradeTarget.stock_code}-{self.tradeTarget.stock_name}]: "
|
||||||
|
f"{response.order_id} - {str(e)}")
|
||||||
|
finally:
|
||||||
|
self.dataUpdateLock.release()
|
||||||
|
|
||||||
|
# ── 事件回调: 订单失败 ────────────────────────────────────
|
||||||
|
|
||||||
|
def onOrderError(self, order_error: XtOrderError):
|
||||||
|
"""
|
||||||
|
QMT 委托失败回调
|
||||||
|
|
||||||
|
当 xtquant 拒绝订单时触发(如资金不足、代码格式错误、涨跌停限制等)。
|
||||||
|
清理 orderGrid 中对应网格索引的孤立条目,防止后续 refreshGridOrder
|
||||||
|
误判"已有同价位订单"而跳过重新下单。
|
||||||
|
"""
|
||||||
|
parsed = self._filter_event(order_error.order_remark, order_error.strategy_name)
|
||||||
|
if parsed is None:
|
||||||
|
return
|
||||||
|
_, gridIdx, _ = parsed
|
||||||
|
|
||||||
|
self.dataUpdateLock.acquire()
|
||||||
|
try:
|
||||||
|
# 从 orderGrid 中移除失败的订单条目,后续 refreshGridOrder 会重新挂单
|
||||||
|
if gridIdx in self.orderGrid:
|
||||||
|
del self.orderGrid[gridIdx]
|
||||||
|
|
||||||
|
PrintLog(LogLevel.ERROR,
|
||||||
|
f'委托失败[{self.tradeTarget.targetName()}] grid-{gridIdx}: '
|
||||||
|
f'order_id={order_error.order_id}, error_id={order_error.error_id}, '
|
||||||
|
f'error_msg={order_error.error_msg}')
|
||||||
|
except Exception as e:
|
||||||
|
PrintLog(LogLevel.ERROR,
|
||||||
|
f'委托失败处理异常[{self.tradeTarget.stock_code}]: {str(e)}')
|
||||||
|
finally:
|
||||||
|
self.dataUpdateLock.release()
|
||||||
|
|
||||||
|
# ── 事件回调: 订单成交 ────────────────────────────────────
|
||||||
|
|
||||||
|
def onOrderTrade(self, trade: XtTrade):
|
||||||
|
"""
|
||||||
|
QMT 委托成交通知回调
|
||||||
|
|
||||||
|
收到成交后:
|
||||||
|
1. 判断成交方向(买入下移 / 卖出上移)→ 更新 grid_index
|
||||||
|
2. 首次建仓(grid_index==0 时成交)→ 记录 init_price
|
||||||
|
3. 卖出成交 → 累计 grid_match_count 和 grid_total_profit
|
||||||
|
4. 清理 orderGrid → 持久化 → 刷新网格挂单
|
||||||
|
"""
|
||||||
|
# ── 过滤:只处理本策略本标的的成交 ──
|
||||||
|
parsed = self._filter_event(trade.order_remark, trade.strategy_name)
|
||||||
|
if parsed is None:
|
||||||
|
return
|
||||||
|
_, gridIdx, _ = parsed # gridIdx: 成交订单对应的网格索引(int)
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- 委托成交通知'
|
||||||
|
f'[{self.tradeTarget.stock_code}-{self.tradeTarget.stock_name}-{trade.order_id}] : '
|
||||||
|
f'{trade.order_id}')
|
||||||
|
|
||||||
|
self.dataUpdateLock.acquire()
|
||||||
|
try:
|
||||||
|
# ── 首次建仓:记录建仓价 ──
|
||||||
|
# grid_index==0 表示成交前处于空仓状态,这笔成交就是首次建仓
|
||||||
|
if self.tradeTarget.grid_index == 0:
|
||||||
|
self.tradeTarget.init_price = trade.traded_price # type: ignore
|
||||||
|
|
||||||
|
# ── 同步更新持仓量 ──
|
||||||
|
# 注意:xtquant 的成交推送不包含最新持仓,此处根据成交方向估算变动
|
||||||
|
# 买入成交(建仓/补仓)→ 持仓增加
|
||||||
|
# 卖出成交(减仓/清仓)→ 持仓减少
|
||||||
|
if gridIdx > self.tradeTarget.grid_index:
|
||||||
|
# 买入方向:持仓增加
|
||||||
|
self.tradeTarget.current_position += int(trade.traded_volume) # type: ignore
|
||||||
|
elif gridIdx < self.tradeTarget.grid_index:
|
||||||
|
# 卖出方向:持仓减少
|
||||||
|
self.tradeTarget.current_position -= int(trade.traded_volume) # type: ignore
|
||||||
|
|
||||||
|
# ── 网格方向判断 ──
|
||||||
|
# 比较成交单的网格索引 vs 当前网格索引,判断价格移动方向
|
||||||
|
oriIdx = self.tradeTarget.grid_index # 成交前的网格位置
|
||||||
|
|
||||||
|
if gridIdx > self.tradeTarget.grid_index:
|
||||||
|
# 成交单在下方(更大索引 = 更低价格)→ 买入成交,持仓下移
|
||||||
|
self.tradeTarget.grid_index += 1 # type: ignore
|
||||||
|
# 首次建仓时 oriIdx==0,加上"建仓单"前缀便于识别
|
||||||
|
desc = "建仓单(下移)" if oriIdx == 0 else "下移一格"
|
||||||
|
|
||||||
|
elif gridIdx < self.tradeTarget.grid_index:
|
||||||
|
# 成交单在上方(更小索引 = 更高价格)→ 卖出成交,持仓上移
|
||||||
|
self.tradeTarget.grid_index -= 1 # type: ignore
|
||||||
|
# 卖出获利:累计匹配次数和利润
|
||||||
|
self.tradeTarget.grid_match_count += 1 # type: ignore
|
||||||
|
# 单格利润 = grid_size × 成交量
|
||||||
|
self.tradeTarget.grid_total_profit += ( # type: ignore
|
||||||
|
self.tradeTarget.grid_size * trade.traded_volume)
|
||||||
|
desc = "上移一格"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# gridIdx == grid_index: 同格成交,正常情况下不会出现
|
||||||
|
desc = "同格(异常)"
|
||||||
|
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- [{self.tradeTarget.targetName()}] '
|
||||||
|
f'原网格 {oriIdx} → 现网格 {self.tradeTarget.grid_index}'
|
||||||
|
f'({desc})')
|
||||||
|
|
||||||
|
# ── 成交后统一处理 ──
|
||||||
|
# 1. 持久化状态到数据库(grid_index、持仓量等已变更)
|
||||||
|
self.saveProxy()
|
||||||
|
# 2. 从 orderGrid 清理已成交订单(pop 防 xtquant 重复推送 KeyError)
|
||||||
|
self.orderGrid.pop(gridIdx, None)
|
||||||
|
# 3. 打印成交报告
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f"|- 成交报告[{self.tradeTarget.targetName()}] : "
|
||||||
|
f"====================================")
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f"|- 标的[{self.tradeTarget.targetName()}] "
|
||||||
|
f"{desc}-单号{trade.order_id}已成交 ")
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f' 成交价: {trade.traded_price} 成交量: {trade.traded_volume}')
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f' 手续费 : {trade.commission:.3f}')
|
||||||
|
# 4. 刷新网格订单:在新的 grid_index 位置重新挂买卖单
|
||||||
|
# 只有市场活跃时才下单,收盘后不再尝试下单
|
||||||
|
if qmtv.isMarketActive:
|
||||||
|
self.refreshGridOrder()
|
||||||
|
else:
|
||||||
|
PrintLog(LogLevel.INFO,
|
||||||
|
f'|- 成交后市场已休市,跳过刷新网格订单')
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self.dataUpdateLock.release()
|
||||||
|
|
||||||
|
# ── 工具方法 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_remark(self, order_tag: str, grid_idx: int) -> str:
|
||||||
|
"""构建订单 remark: '{type},{gridIdx},{stockCode}'"""
|
||||||
|
return f'{order_tag},{grid_idx},{self.tradeTarget.stock_code}'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_remark(remark: str):
|
||||||
|
"""
|
||||||
|
解析订单 remark → (orderType:str, gridIdx:int, stockCode:str)
|
||||||
|
格式不符返回 None
|
||||||
|
"""
|
||||||
|
if not remark:
|
||||||
|
return None
|
||||||
|
parts = remark.split(',')
|
||||||
|
if len(parts) < 3:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return parts[0], int(parts[1]), parts[2]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _filter_event(self, remark: str, strategy_name: str):
|
||||||
|
"""
|
||||||
|
事件过滤器:解析 remark 并校验是否属于本策略本标的
|
||||||
|
通过返回 parsed tuple,不通过返回 None
|
||||||
|
"""
|
||||||
|
parsed = self._parse_remark(remark)
|
||||||
|
if parsed is None:
|
||||||
|
return None
|
||||||
|
if strategy_name != self.getName() or self.tradeTarget.stock_code != parsed[2]:
|
||||||
|
return None
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def getName(self):
|
||||||
|
"""返回策略名称,用于在 QMT 中标识订单归属"""
|
||||||
|
return "SFGRID"
|
||||||
|
|
||||||
|
def saveProxy(self):
|
||||||
|
"""
|
||||||
|
持久化 tradeTarget 到数据库,并发布 UI 更新事件
|
||||||
|
|
||||||
|
每次状态变更后调用,确保数据库与内存一致,
|
||||||
|
同时通知 UI 刷新表格显示。
|
||||||
|
"""
|
||||||
|
PrintLog(LogLevel.DEBUG,
|
||||||
|
f'|- [DEBUG] saveProxy: {self.tradeTarget.targetName()} '
|
||||||
|
f'网格={self.tradeTarget.grid_index}')
|
||||||
|
rc = self.tradeTarget.save()
|
||||||
|
event_bus.publish(EventTradeTargetUpdate, self.tradeTarget)
|
||||||
|
return rc
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# 删除交易标的事件
|
|
||||||
EventTradeTargetUpdate = "trade_target_update"
|
|
||||||
EventTradeTargetDeleted = "trade_target_deleted"
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
from core.logger import LogLevel, PrintLog
|
|
||||||
from core.qmt import qmtv
|
|
||||||
from core.sfgrid import bus_events
|
|
||||||
from core.sfgrid.bus_events import EventTradeTargetUpdate
|
|
||||||
import core.sfgrid.model as model
|
|
||||||
from core.eventbus import event_bus
|
|
||||||
from core.constants import OrderTypeBuy, OrderTypeSell, OrderTypeInit
|
|
||||||
|
|
||||||
from xtquant import xtconstant
|
|
||||||
from xtquant.xttype import XtOrderResponse, XtTrade
|
|
||||||
import threading
|
|
||||||
import core.eventbus as eBus
|
|
||||||
|
|
||||||
|
|
||||||
class SFGridStrategy:
|
|
||||||
|
|
||||||
def __init__(self, tradeTarget: model.SFGridTradeTarget):
|
|
||||||
self.tradeTarget:model.SFGridTradeTarget = tradeTarget
|
|
||||||
event_bus.subscribe(eBus.MarketOrderCreated, self.onOrderCreateAsync)
|
|
||||||
event_bus.subscribe(eBus.MarketOrderTraded, self.onOrderTrade)
|
|
||||||
self.todayUpStopPrice=qmtv.dailyUpStop(tradeTarget.stock_code) # type: ignore
|
|
||||||
self.todayDownStopPrice=qmtv.dailyDownStop(tradeTarget.stock_code) # type: ignore
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 标的{tradeTarget.targetName()}初始化: 停涨价 {self.todayUpStopPrice:.3f}, 停跌价 {self.todayDownStopPrice:.3f}')
|
|
||||||
self.orderGrid = {} # grid index, order_seq | order_id
|
|
||||||
self.loadExistOrders()
|
|
||||||
self.enabledTrading(tradeTarget.enabled) # type: ignore
|
|
||||||
self.dataUpdateLock = threading.Lock()
|
|
||||||
|
|
||||||
def loadExistOrders(self):
|
|
||||||
orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore
|
|
||||||
for order in orders:
|
|
||||||
if order.strategy_name != self.getName():
|
|
||||||
continue
|
|
||||||
gridIdx = int(order.order_remark.split(',')[1])
|
|
||||||
self.orderGrid[gridIdx] = order.order_id
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 标的[{self.tradeTarget.targetName()}] 初始化: 加载现有订单, grid-{gridIdx} order_id:{self.orderGrid[gridIdx]}')
|
|
||||||
|
|
||||||
def printPendingOrder(self):
|
|
||||||
for idx, order_id in self.orderGrid.items():
|
|
||||||
PrintLog(LogLevel.DEBUG, f" {idx} : {order_id}")
|
|
||||||
|
|
||||||
def onMarketActiveSwitch(self, isActive: bool):
|
|
||||||
if isActive and self.tradeTarget.enabled:
|
|
||||||
self.refreshGridOrder()
|
|
||||||
|
|
||||||
def refreshGridOrder(self): # 下网格单
|
|
||||||
if not qmtv.isMarketActive or not self.tradeTarget.enabled:
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 市场 {qmtv.isMarketActive}, 策略 {self.getName()} {self.tradeTarget.enabled}, 不下单')
|
|
||||||
return
|
|
||||||
|
|
||||||
currentIdx:int = 0
|
|
||||||
|
|
||||||
orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore
|
|
||||||
|
|
||||||
if self.tradeTarget.status == 0 and len([order for order in orders if order.order_remark == f'{OrderTypeInit},1,{self.tradeTarget.stock_code}']) == 0: # status == 0 表示已配置好交易参数,且不存在执行中的建仓单
|
|
||||||
price = self.tradeTarget.getPriceGrid()[0]
|
|
||||||
remark = f'{OrderTypeInit},1,{self.tradeTarget.stock_code}'
|
|
||||||
tmpOrderSeq = qmtv.orderAsync(
|
|
||||||
str(self.tradeTarget.stock_code),
|
|
||||||
self.tradeTarget.grid_volume,
|
|
||||||
xtconstant.STOCK_BUY,
|
|
||||||
price,
|
|
||||||
xtconstant.FIX_PRICE,
|
|
||||||
remark, # remark # type: ignore
|
|
||||||
self.getName(), # strategy_name
|
|
||||||
)
|
|
||||||
self.orderGrid[1] = tmpOrderSeq # seq
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 标的[{self.tradeTarget.targetName()}] 初始化: 建仓单,建仓价: {price:.3f}')
|
|
||||||
elif self.tradeTarget.status == 1: # 下网格单
|
|
||||||
currentIdx = self.tradeTarget.grid_index # type: ignore
|
|
||||||
orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore
|
|
||||||
|
|
||||||
# 向上下一单,向下下一单
|
|
||||||
if currentIdx > 0: # 可以下空单
|
|
||||||
sellIdx = currentIdx - 1
|
|
||||||
sellPrice = self.tradeTarget.getPriceGrid()[sellIdx]
|
|
||||||
remark = f'{OrderTypeSell},{sellIdx},{self.tradeTarget.stock_code}'
|
|
||||||
if len([order for order in orders if order.order_remark == remark]) == 0: # 网格节点没有卖单,下单
|
|
||||||
# 不存在策略内同价位订单,下单
|
|
||||||
tmpOrderSeq = qmtv.orderAsync(
|
|
||||||
str(self.tradeTarget.stock_code),
|
|
||||||
self.tradeTarget.grid_volume,
|
|
||||||
xtconstant.STOCK_SELL,
|
|
||||||
sellPrice,
|
|
||||||
xtconstant.FIX_PRICE,
|
|
||||||
remark, # remark # type: ignore
|
|
||||||
self.getName(), # strategy_name
|
|
||||||
)
|
|
||||||
self.orderGrid[sellIdx] = tmpOrderSeq # seq
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: 下空单,价格: {sellPrice:.3f}')
|
|
||||||
else:
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: 已存在同价位空单,跳过下单')
|
|
||||||
if currentIdx < len(self.tradeTarget.getPriceGrid()) - 1: # 可以下多单
|
|
||||||
print(f'length: {len(self.tradeTarget.getPriceGrid())}, currentIdx = {currentIdx}')
|
|
||||||
buyIdx = currentIdx + 1
|
|
||||||
buyPrice = self.tradeTarget.getPriceGrid()[buyIdx]
|
|
||||||
remark = f'{OrderTypeBuy},{buyIdx},{self.tradeTarget.stock_code}'
|
|
||||||
if len([order for order in orders if order.order_type == xtconstant.STOCK_BUY and order.price == buyPrice]) == 0:
|
|
||||||
tmpOrderSeq = qmtv.orderAsync(
|
|
||||||
str(self.tradeTarget.stock_code),
|
|
||||||
self.tradeTarget.grid_volume,
|
|
||||||
xtconstant.STOCK_BUY,
|
|
||||||
buyPrice,
|
|
||||||
xtconstant.FIX_PRICE,
|
|
||||||
remark, # remark # type: ignore
|
|
||||||
self.getName(), # strategy_name
|
|
||||||
)
|
|
||||||
self.orderGrid[buyIdx] = tmpOrderSeq # seq
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: 下多单,价格: {buyPrice:.3f}')
|
|
||||||
else:
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: 已存在同价位多单,跳过下单')
|
|
||||||
else:
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 标的[{self.tradeTarget.targetName()}] 网格策略: 已过下边界,停止多单交易')
|
|
||||||
|
|
||||||
def deleteTradeTarget(self, tradeTarget:model.SFGridTradeTarget):
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 标的{tradeTarget.targetName()}信息删除: START')
|
|
||||||
self.dataUpdateLock.acquire()
|
|
||||||
try:
|
|
||||||
tradeTarget.delete_instance()
|
|
||||||
event_bus.publish(bus_events.EventTradeTargetDeleted, tradeTarget)
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 标的{tradeTarget.targetName()}信息删除: END')
|
|
||||||
finally:
|
|
||||||
self.dataUpdateLock.release()
|
|
||||||
|
|
||||||
def enabledTrading(self, enabled: bool) -> model.SFGridTradeTarget:
|
|
||||||
self.tradeTarget.enabled = enabled # type: ignore
|
|
||||||
|
|
||||||
if enabled:
|
|
||||||
PrintLog(LogLevel.INFO, f" |- 标的{self.tradeTarget.targetName()}交易启动, 持仓量:{self.tradeTarget.current_position}")
|
|
||||||
if self.tradeTarget.status == 0: # 未建仓
|
|
||||||
PrintLog(LogLevel.INFO, f" |- 标的{self.tradeTarget.targetName()}初始状态, 设置网格序号 1,")
|
|
||||||
self.tradeTarget.grid_index = 1 # pyright: ignore[reportAttributeAccessIssue]
|
|
||||||
else: # 已建仓
|
|
||||||
# 交易阶段,检查仓位,检查现有订单
|
|
||||||
PrintLog(LogLevel.INFO, f" |- 标的{self.tradeTarget.targetName()}已有仓位或非初始状态 无需建初始仓 当前仓位: {self.tradeTarget.current_position} 状态: {self.tradeTarget.status}")
|
|
||||||
minRequirePosition:int = self.tradeTarget.grid_volume * int(self.tradeTarget.grid_index) # type: ignore
|
|
||||||
if minRequirePosition <= int(self.tradeTarget.current_position): # type: ignore
|
|
||||||
PrintLog(LogLevel.INFO, f' |- 仓位检查: 持仓需求充足, (gridVolume*gridIndex)={minRequirePosition}, 当前持仓:{self.tradeTarget.current_position}')
|
|
||||||
else:
|
|
||||||
PrintLog(LogLevel.INFO, f' |- 仓位检查: 持仓需求不足, (gridVolume*gridIndex)={minRequirePosition}, 当前持仓:{self.tradeTarget.current_position}, 交易启动失败')
|
|
||||||
self.tradeTarget.enabled = False # type: ignore
|
|
||||||
self.refreshGridOrder()
|
|
||||||
else:
|
|
||||||
orders = qmtv.queryPendingOrder(self.tradeTarget.stock_code, self.getName()) # type: ignore
|
|
||||||
for order in orders:
|
|
||||||
qmtv.xttrader.cancel_order_stock_async(qmtv.account, order.order_id)
|
|
||||||
if len(orders) > 0:
|
|
||||||
PrintLog(LogLevel.INFO, f' |- 取消未成交订单 {len(orders)}')
|
|
||||||
PrintLog(LogLevel.INFO, f" |- 标的{self.tradeTarget.targetName()}交易监控暂停")
|
|
||||||
|
|
||||||
self.saveProxy()
|
|
||||||
return self.tradeTarget
|
|
||||||
|
|
||||||
def isEnabled(self) -> bool:
|
|
||||||
print(f'|- 检查交易状态[{self.tradeTarget.stock_code}-{self.tradeTarget.stock_name}] - {self.tradeTarget.enabled}')
|
|
||||||
return bool(self.tradeTarget.enabled) # 修复返回类型问题
|
|
||||||
|
|
||||||
def onOrderCreateAsync(self, response:XtOrderResponse): # 下单成功回调,更新orderID到 self.orderGrid
|
|
||||||
remark = response.order_remark.split(',')
|
|
||||||
stockCode = remark[2] # 从remark中获取stockCode
|
|
||||||
if response.strategy_name != self.getName() or len(remark) < 3 or self.tradeTarget.stock_code != stockCode:
|
|
||||||
return
|
|
||||||
self.dataUpdateLock.acquire()
|
|
||||||
try:
|
|
||||||
gridIdx = remark[1] # 从remark中获取gridIdx
|
|
||||||
PrintLog(LogLevel.INFO, f"委托创建通知 onOrderCreateAsync[{self.tradeTarget.targetName()}]: {response.order_id}")
|
|
||||||
self.orderGrid[gridIdx] = response.order_id
|
|
||||||
PrintLog(LogLevel.INFO, f"委托创建通知 onOrderCreateAsync 更新 grid-{gridIdx} seq:{response.seq} -> order_id:{response.order_id}")
|
|
||||||
except Exception as e:
|
|
||||||
PrintLog(LogLevel.ERROR, f"|- 委托创建通知 onOrderCreateAsync[{self.tradeTarget.stock_code}-{self.tradeTarget.stock_name}]: {response.order_id} - {str(e)}")
|
|
||||||
finally:
|
|
||||||
self.dataUpdateLock.release()
|
|
||||||
|
|
||||||
def onOrderTrade(self, trade:XtTrade): # TODO 委托成交通知,处理成交后网格切换
|
|
||||||
remark = trade.order_remark.split(',')
|
|
||||||
if trade.strategy_name != self.getName() or len(remark) < 3 or self.tradeTarget.stock_code != trade.stock_code:
|
|
||||||
return
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 委托成交通知[{self.tradeTarget.stock_code}-{self.tradeTarget.stock_name}-{trade.order_id}] : {trade.order_id}')
|
|
||||||
|
|
||||||
self.dataUpdateLock.acquire()
|
|
||||||
try:
|
|
||||||
orderType = trade.order_remark.split(',')[0]
|
|
||||||
gridIdx = trade.order_remark.split(',')[1] # 从remark中获取gridIdx
|
|
||||||
type:str = ""
|
|
||||||
if orderType == OrderTypeInit:
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 委托成交通知[{self.tradeTarget.targetName()}-{trade.order_id}] - 建仓单成交')
|
|
||||||
self.tradeTarget.status = 1 # type: ignore
|
|
||||||
self.tradeTarget.init_price = trade.traded_price # type: ignore
|
|
||||||
self.tradeTarget.grid_index = 1 # type: ignore
|
|
||||||
type = "建仓单"
|
|
||||||
else:
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 委托成交通知[{self.tradeTarget.targetName()}-{trade.order_id}] - 网格单成交')
|
|
||||||
oriIdx = self.tradeTarget.grid_index
|
|
||||||
if gridIdx > self.tradeTarget.grid_index:
|
|
||||||
type = "下移一格"
|
|
||||||
self.tradeTarget.grid_index +=1
|
|
||||||
elif gridIdx < self.tradeTarget.grid_index:
|
|
||||||
type = "上移一格"
|
|
||||||
self.tradeTarget.grid_match_count += 1
|
|
||||||
self.tradeTarget.grid_total_profit += self.tradeTarget.grid_size * trade.traded_volume
|
|
||||||
self.tradeTarget.grid_index -= 1
|
|
||||||
else:
|
|
||||||
type = "保持格, 理论上不应该输出"
|
|
||||||
PrintLog(LogLevel.INFO, f'|- 委托成交通知[{self.tradeTarget.stock_code}-{self.tradeTarget.stock_name} - 原网格位置 {oriIdx}, 现网格位置 {self.tradeTarget.grid_index}')
|
|
||||||
|
|
||||||
self.saveProxy()
|
|
||||||
del self.orderGrid[gridIdx]
|
|
||||||
PrintLog(LogLevel.INFO, f"|- 成交报告[{self.tradeTarget.targetName()}] : ====================================")
|
|
||||||
PrintLog(LogLevel.INFO, f"|- 标的[{self.tradeTarget.targetName()}] {type}-单号{trade.order_id}已成交 ")
|
|
||||||
PrintLog(LogLevel.INFO, f' 成交价: {trade.traded_price} 成交量: {trade.traded_volume}')
|
|
||||||
PrintLog(LogLevel.INFO, f' 手续费 : {trade.commission:.3f}')
|
|
||||||
self.refreshGridOrder() # 更新网格订单
|
|
||||||
finally:
|
|
||||||
self.dataUpdateLock.release()
|
|
||||||
|
|
||||||
|
|
||||||
def getName(self):
|
|
||||||
return "SFGRID"
|
|
||||||
|
|
||||||
def saveProxy(self):
|
|
||||||
rc = self.tradeTarget.save()
|
|
||||||
event_bus.publish(EventTradeTargetUpdate, self.tradeTarget)
|
|
||||||
return rc
|
|
||||||
@@ -1,985 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk, messagebox
|
|
||||||
from datetime import datetime
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import core.eventbus as eBus
|
|
||||||
from core.logger import LogLevel, PrintLog
|
|
||||||
from core.sfgrid import bus_events
|
|
||||||
from core.sfgrid.model import SFGridTradeTarget
|
|
||||||
from core.qmt import qmtv
|
|
||||||
from core.sfgrid.sfgrid_strategy import SFGridStrategy
|
|
||||||
|
|
||||||
|
|
||||||
class TradeTargetUI(ttk.Frame):
|
|
||||||
def __init__(self, parent):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.tradeTargetData:dict[int, SFGridTradeTarget] = {} # id->trade_target
|
|
||||||
self.stockCodeIdMap:dict[str, int] = {}
|
|
||||||
self.strategy_ctrl:dict[int, SFGridStrategy] = {} # stock_code->trade_target
|
|
||||||
self.targetMarketPrice: dict[int, float] = {}
|
|
||||||
self.targetAvgPrice: dict[int, float] = {}
|
|
||||||
self.listening_stock = []
|
|
||||||
# 监控价格,默认值为10
|
|
||||||
self.monitor_price = 10.0
|
|
||||||
|
|
||||||
self.init_trade_target_pool()
|
|
||||||
|
|
||||||
# 市场监控数据
|
|
||||||
self.marketData: dict[str, Any] = {} # 存储市场数据 {stock_code: {stock_name, last_price, time}}
|
|
||||||
|
|
||||||
# 市场监控窗口显示状态
|
|
||||||
self.market_monitor_visible = True
|
|
||||||
|
|
||||||
# 创建界面
|
|
||||||
self.create_ui()
|
|
||||||
eBus.event_bus.subscribe(eBus.MarketDataUpdate, self.onMarketDataUpdated)
|
|
||||||
|
|
||||||
eBus.event_bus.subscribe(bus_events.EventTradeTargetUpdate, self.onStrategyUpdate)
|
|
||||||
eBus.event_bus.subscribe(bus_events.EventTradeTargetDeleted, self.onTradeTargetDeleted)
|
|
||||||
|
|
||||||
|
|
||||||
def init_trade_target_pool(self):
|
|
||||||
results = SFGridTradeTarget.select()
|
|
||||||
for temp in results:
|
|
||||||
tradeTarget:SFGridTradeTarget = temp
|
|
||||||
pos = qmtv.getStockPosition(tradeTarget.stock_code)
|
|
||||||
tradeTarget.current_position = 0 if pos is None else pos.volume # type: ignore
|
|
||||||
if pos is None:
|
|
||||||
self.targetAvgPrice[tradeTarget.get_id()] = 0.0
|
|
||||||
else:
|
|
||||||
self.targetAvgPrice[tradeTarget.get_id()] = pos.avg_price
|
|
||||||
PrintLog(LogLevel.INFO, f'- [成功]获取持仓信息: {tradeTarget.stock_code} {tradeTarget.targetName()} {tradeTarget.current_position} {pos.avg_price}')
|
|
||||||
|
|
||||||
self.updateTradeTarget(tradeTarget, True) # 初始化的时候
|
|
||||||
|
|
||||||
PrintLog(LogLevel.INFO, f'- [成功]交易标的信息初始化, 共 {len(self.tradeTargetData)} 个标的')
|
|
||||||
|
|
||||||
|
|
||||||
# 收集所有市场数据用于市场监控
|
|
||||||
def onMarketDataUpdated(self, data):
|
|
||||||
for stock_code, tickData in data.items():
|
|
||||||
if stock_code in self.stockCodeIdMap:
|
|
||||||
id:int = self.stockCodeIdMap[stock_code]
|
|
||||||
self.targetMarketPrice[id] = tickData['lastPrice']
|
|
||||||
tradeTarget = self.tradeTargetData[id]
|
|
||||||
# timeStr = datetime.fromtimestamp(tickData['time']/1000)
|
|
||||||
lastPrice = float("{:.3f}".format(tickData['lastPrice']))
|
|
||||||
tradeTarget.market_price = lastPrice # type: ignore
|
|
||||||
# PrintLog(LogLevel.INFO, f'|- 市价更新[{tradeTarget.targetName()}] - {timeStr.strftime("%H:%M:%S")} 市价更新: {lastPrice}======================{id}')
|
|
||||||
self.updateTradeTarget(tradeTarget, False) # 市价更新
|
|
||||||
else:
|
|
||||||
# 非目标交易,发布市场数据更新事件用于市场监控
|
|
||||||
lastPrice = tickData['lastPrice']
|
|
||||||
# 使用用户设置的监控价格替代硬编码的10
|
|
||||||
if lastPrice == self.monitor_price or stock_code in self.listening_stock:
|
|
||||||
# 发布市场数据更新事件用于市场监控
|
|
||||||
if stock_code not in self.listening_stock:
|
|
||||||
self.listening_stock.append(stock_code)
|
|
||||||
# 更新市场监控数据用于UI显示
|
|
||||||
current_time = datetime.now().strftime("%H:%M:%S")
|
|
||||||
self.marketData[str(stock_code)] = {
|
|
||||||
'stock_name': qmtv.getInstrumentName(stock_code),
|
|
||||||
'last_price': tickData['lastPrice'],
|
|
||||||
'time': current_time
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# 来自策略的数据更新
|
|
||||||
def onStrategyUpdate(self, target: SFGridTradeTarget):
|
|
||||||
id = target.get_id()
|
|
||||||
self.tradeTargetData[id] = target
|
|
||||||
|
|
||||||
|
|
||||||
# priceChange 用于控制是否对更新价格数据,进行交易判断
|
|
||||||
def updateTradeTarget(self, target: SFGridTradeTarget, save: bool = True):
|
|
||||||
if save:
|
|
||||||
target.save()
|
|
||||||
|
|
||||||
id = target.get_id()
|
|
||||||
# PrintLog(LogLevel.INFO, f' [序号-{id}] 股票代码: {target.stock_code}-{target.stock_name}: {target.plan_buy_price} {target.plan_sell_price}') # type: ignore
|
|
||||||
# 更新或添加数据到本地缓存
|
|
||||||
self.tradeTargetData[id] = target
|
|
||||||
|
|
||||||
if id not in self.strategy_ctrl:
|
|
||||||
self.stockCodeIdMap[target.stock_code] = id # type: ignore
|
|
||||||
self.strategy_ctrl[id] = SFGridStrategy(target) # pyright: ignore[reportArgumentType]
|
|
||||||
if id in self.targetAvgPrice:
|
|
||||||
pos = qmtv.getStockPosition(target.stock_code)
|
|
||||||
if pos is not None:
|
|
||||||
self.targetAvgPrice[id] = pos.avg_price
|
|
||||||
|
|
||||||
# UI CREATE
|
|
||||||
def create_ui(self):
|
|
||||||
"""创建UI界面"""
|
|
||||||
# 主框架(使用self作为父容器)
|
|
||||||
main_frame = ttk.Frame(self)
|
|
||||||
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
||||||
|
|
||||||
# 创建工具栏
|
|
||||||
toolbar_frame = ttk.Frame(main_frame)
|
|
||||||
toolbar_frame.pack(fill=tk.X, pady=(0, 10))
|
|
||||||
|
|
||||||
# 工具栏按钮
|
|
||||||
ttk.Button(toolbar_frame, text="➕ 添加标的",
|
|
||||||
command=self.btnHandlerAddTradeTarget, width=12).pack(side=tk.LEFT, padx=2)
|
|
||||||
ttk.Button(toolbar_frame, text="🗑 删除标的",
|
|
||||||
command=self.btnHandlerDelSelectedTradeTarget, width=12).pack(side=tk.LEFT, padx=2)
|
|
||||||
ttk.Button(toolbar_frame, text="▶️ 启动交易",
|
|
||||||
command=self.btnHandlerStartSelectedTrade, width=12).pack(side=tk.LEFT, padx=2)
|
|
||||||
ttk.Button(toolbar_frame, text="⏸ 暂停交易",
|
|
||||||
command=self.btnHandlerStopSelectedTrade, width=12).pack(side=tk.LEFT, padx=2)
|
|
||||||
ttk.Button(toolbar_frame, text="🛠 交易设置",
|
|
||||||
command=self.btnHandlerTradeSettings, width=12).pack(side=tk.LEFT, padx=2)
|
|
||||||
|
|
||||||
ttk.Button(toolbar_frame, text="▣ 边栏",
|
|
||||||
command=self.btnHandlerToggleMarketMonitor, width=8).pack(side=tk.RIGHT, padx=2)
|
|
||||||
# 添加价格监控输入字段和确认按钮
|
|
||||||
ttk.Button(toolbar_frame, text="确认",
|
|
||||||
command=self.btnHandlerSetMonitorPrice, width=8).pack(side=tk.RIGHT, padx=2)
|
|
||||||
self.monitor_price_entry = ttk.Entry(toolbar_frame, width=8)
|
|
||||||
self.monitor_price_entry.insert(0, str(self.monitor_price))
|
|
||||||
self.monitor_price_entry.pack(side=tk.RIGHT, padx=2)
|
|
||||||
ttk.Label(toolbar_frame, text="价格").pack(side=tk.RIGHT, padx=(20, 2))
|
|
||||||
ttk.Label(toolbar_frame, text="监控配置").pack(side=tk.RIGHT, padx=(20, 2))
|
|
||||||
|
|
||||||
|
|
||||||
# 表格区域
|
|
||||||
self.create_tables_area(main_frame)
|
|
||||||
|
|
||||||
# 启动刷新线程
|
|
||||||
self.refresh_thread = threading.Thread(target=self.refresh_loop, daemon=True)
|
|
||||||
self.refresh_thread.start()
|
|
||||||
|
|
||||||
|
|
||||||
def refresh_loop(self):
|
|
||||||
"""刷新循环"""
|
|
||||||
while True:
|
|
||||||
self.after(0, self.refresh_table)
|
|
||||||
self.after(0, self.populate_market_table)
|
|
||||||
time.sleep(0.5) # 每0.5秒刷新一次
|
|
||||||
|
|
||||||
|
|
||||||
def create_tables_area(self, parent):
|
|
||||||
"""创建表格区域"""
|
|
||||||
# 创建主表格框架(水平排列)
|
|
||||||
tables_frame = ttk.Frame(parent)
|
|
||||||
tables_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 5))
|
|
||||||
|
|
||||||
# 左侧交易标的区域
|
|
||||||
trade_frame = ttk.LabelFrame(tables_frame, text="交易标的详情", padding=10)
|
|
||||||
trade_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
|
|
||||||
|
|
||||||
# 创建交易标的表格
|
|
||||||
self.create_trade_target_table(trade_frame)
|
|
||||||
|
|
||||||
# 右侧市场监控区域
|
|
||||||
self.market_frame = ttk.LabelFrame(tables_frame, text="市场监控", padding=10)
|
|
||||||
self.market_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(5, 0))
|
|
||||||
|
|
||||||
# 创建市场监控表格
|
|
||||||
self.create_market_monitor_table(self.market_frame)
|
|
||||||
|
|
||||||
|
|
||||||
def create_trade_target_table(self, parent):
|
|
||||||
"""创建交易标的表格"""
|
|
||||||
|
|
||||||
columns = ("ID",
|
|
||||||
"股票代码", "股票名称", "市场价", "当前持仓", "建仓成本",
|
|
||||||
"平均成本", "网格匹配次数", "网格收益", "交易状态"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.trade_table = ttk.Treeview(parent, columns=columns, show='headings', height=15)
|
|
||||||
|
|
||||||
# 专业化的列配置
|
|
||||||
column_configs = {
|
|
||||||
"ID": (50, tk.CENTER),
|
|
||||||
"股票代码": (80, tk.CENTER),
|
|
||||||
"股票名称": (80, tk.E),
|
|
||||||
"市场价": (70, tk.E),
|
|
||||||
"当前持仓": (80, tk.E),
|
|
||||||
"建仓成本": (60, tk.E),
|
|
||||||
"平均成本": (60, tk.E),
|
|
||||||
"网格匹配次数": (60, tk.E),
|
|
||||||
"网格收益": (60, tk.E),
|
|
||||||
"交易状态": (80, tk.CENTER)
|
|
||||||
}
|
|
||||||
|
|
||||||
for col in columns:
|
|
||||||
width, anchor = column_configs[col]
|
|
||||||
self.trade_table.heading(col, text=col)
|
|
||||||
self.trade_table.column(col, width=width, anchor=anchor) # type: ignore
|
|
||||||
|
|
||||||
# 填充数据
|
|
||||||
self.populate_trade_table()
|
|
||||||
|
|
||||||
# 滚动条
|
|
||||||
scrollbar = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.trade_table.yview)
|
|
||||||
self.trade_table.configure(yscrollcommand=scrollbar.set)
|
|
||||||
|
|
||||||
self.trade_table.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
||||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
||||||
|
|
||||||
# 绑定双击事件
|
|
||||||
self.trade_table.bind("<Double-1>", self.on_table_double_click)
|
|
||||||
|
|
||||||
def create_market_monitor_table(self, parent):
|
|
||||||
"""创建市场监控表格"""
|
|
||||||
columns = ("时间", "股票名称", "最新价格")
|
|
||||||
|
|
||||||
self.market_table = ttk.Treeview(parent, columns=columns, show='headings', height=15)
|
|
||||||
|
|
||||||
# 列配置
|
|
||||||
column_configs = {
|
|
||||||
"时间": (50, tk.CENTER),
|
|
||||||
"股票名称": (80, tk.CENTER),
|
|
||||||
"最新价格": (50, tk.CENTER)
|
|
||||||
}
|
|
||||||
|
|
||||||
for col in columns:
|
|
||||||
width, anchor = column_configs[col]
|
|
||||||
self.market_table.heading(col, text=col)
|
|
||||||
self.market_table.column(col, width=width, anchor=anchor) # type: ignore
|
|
||||||
|
|
||||||
# 滚动条
|
|
||||||
scrollbar = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.market_table.yview)
|
|
||||||
self.market_table.configure(yscrollcommand=scrollbar.set)
|
|
||||||
|
|
||||||
self.market_table.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
||||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
||||||
|
|
||||||
# 绑定双击事件
|
|
||||||
self.market_table.bind("<Double-1>", self.on_market_table_double_click)
|
|
||||||
|
|
||||||
# 填充初始数据
|
|
||||||
self.populate_market_table()
|
|
||||||
|
|
||||||
def populate_market_table(self):
|
|
||||||
"""填充市场监控表格数据"""
|
|
||||||
# 保存当前选中的项
|
|
||||||
selected_items = self.market_table.selection()
|
|
||||||
selected_values = []
|
|
||||||
for item in selected_items:
|
|
||||||
values = self.market_table.item(item)['values']
|
|
||||||
if values:
|
|
||||||
selected_values.append(values[1]) # 保存股票代码
|
|
||||||
|
|
||||||
# 清空现有数据
|
|
||||||
for item in self.market_table.get_children():
|
|
||||||
self.market_table.delete(item)
|
|
||||||
|
|
||||||
# 填充市场数据
|
|
||||||
tmp = self.marketData.copy()
|
|
||||||
for stock_code, data in tmp.items():
|
|
||||||
# 处理时间格式,仅显示 hh:mm:ss
|
|
||||||
time_str = data['time']
|
|
||||||
# 如果时间字符串包含空格,说明包含日期和时间,只取时间部分
|
|
||||||
if ' ' in time_str:
|
|
||||||
time_str = time_str.split(' ')[1]
|
|
||||||
|
|
||||||
# 确保时间格式为 hh:mm:ss,如果只有 hh:mm 则补充 :00
|
|
||||||
if ':' in time_str:
|
|
||||||
time_components = time_str.split(':')
|
|
||||||
if len(time_components) == 2:
|
|
||||||
# 只有小时和分钟,补充秒
|
|
||||||
time_str = f"{time_components[0]}:{time_components[1]}:00"
|
|
||||||
elif len(time_components) >= 3:
|
|
||||||
# 有小时、分钟和秒,只取前三个部分
|
|
||||||
time_str = f"{time_components[0]}:{time_components[1]}:{time_components[2]}"
|
|
||||||
|
|
||||||
values = [
|
|
||||||
time_str,
|
|
||||||
data['stock_name']+f"-{stock_code}",
|
|
||||||
f"{data['last_price']:.3f}",
|
|
||||||
stock_code
|
|
||||||
]
|
|
||||||
self.market_table.insert('', tk.END, values=values)
|
|
||||||
|
|
||||||
# 恢复之前选中的项
|
|
||||||
if selected_values:
|
|
||||||
for item in self.market_table.get_children():
|
|
||||||
values = self.market_table.item(item)['values']
|
|
||||||
if values and values[1] in selected_values: # 比较股票代码
|
|
||||||
self.market_table.selection_add(item)
|
|
||||||
|
|
||||||
def on_market_table_double_click(self, event):
|
|
||||||
"""市场监控表格双击事件"""
|
|
||||||
selected = self.market_table.selection()
|
|
||||||
if selected:
|
|
||||||
item = selected[0]
|
|
||||||
values = self.market_table.item(item)['values']
|
|
||||||
print(values)
|
|
||||||
stock_name = values[1]
|
|
||||||
last_price = values[2]
|
|
||||||
stock_code = values[3]
|
|
||||||
|
|
||||||
# 检查是否已在交易池中
|
|
||||||
is_in_trade_pool = any(target.stock_code == stock_code for target in self.tradeTargetData.values())
|
|
||||||
|
|
||||||
if is_in_trade_pool:
|
|
||||||
messagebox.showinfo("提示", f"{stock_code} ({stock_name}) 已在交易池中")
|
|
||||||
else:
|
|
||||||
result = messagebox.askyesno(
|
|
||||||
"添加交易标的",
|
|
||||||
f"确定要将以下股票添加到交易池吗?\n\n"
|
|
||||||
f"股票代码: {stock_code}\n"
|
|
||||||
f"股票名称: {stock_name}\n"
|
|
||||||
f"最新价格: {last_price}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
# 发布事件通知主控制器添加标的
|
|
||||||
self.addTradeTarget(stock_code)
|
|
||||||
|
|
||||||
def get_trade_enabled_indicator(self, target: SFGridTradeTarget) -> str:
|
|
||||||
"""获取交易状态指示器"""
|
|
||||||
if target.status == -1:
|
|
||||||
return "请做交易设置"
|
|
||||||
elif target.status >= 0:
|
|
||||||
if target.enabled:
|
|
||||||
return "▶ 运行中"
|
|
||||||
else:
|
|
||||||
return "⏸ 已停止"
|
|
||||||
|
|
||||||
|
|
||||||
def populate_trade_table(self):
|
|
||||||
"""填充交易标的表格数据"""
|
|
||||||
for id, target in self.tradeTargetData.items():
|
|
||||||
values = [
|
|
||||||
id,
|
|
||||||
target.stock_code, # "股票代码"
|
|
||||||
target.stock_name, # "股票名称"
|
|
||||||
f"{self.targetMarketPrice[id]:.3f}" if id in self.targetMarketPrice else '-', # "市场价"
|
|
||||||
target.current_position, # "当前持仓"
|
|
||||||
'-' if target.init_price is None else f"{target.init_price:.3f}", # "建仓成本"
|
|
||||||
f"{self.targetAvgPrice[id]:.3f}", # "平均成本"
|
|
||||||
target.grid_match_count, # "网格匹配次数"
|
|
||||||
f"{target.grid_total_profit:.3f}", # "网格收益"
|
|
||||||
self.get_trade_enabled_indicator(target) # type: ignore
|
|
||||||
]
|
|
||||||
|
|
||||||
self.trade_table.insert('', tk.END, values=values)
|
|
||||||
|
|
||||||
|
|
||||||
def on_table_double_click(self, event):
|
|
||||||
"""表格双击事件"""
|
|
||||||
selected = self.trade_table.selection()
|
|
||||||
if selected:
|
|
||||||
item = selected[0]
|
|
||||||
values = self.trade_table.item(item)['values']
|
|
||||||
ctrl = self.strategy_ctrl[values[0]]
|
|
||||||
PrintLog(LogLevel.DEBUG, f"双击查看详情: {values[0]} - {values[1]}")
|
|
||||||
PrintLog(LogLevel.DEBUG, f"双击查看详情 - 订单网格")
|
|
||||||
ctrl.printPendingOrder()
|
|
||||||
|
|
||||||
def get_selected_target(self):
|
|
||||||
"""获取选中的交易标的"""
|
|
||||||
selected = self.trade_table.selection()
|
|
||||||
if not selected:
|
|
||||||
messagebox.showwarning("未选中", "请先选择一个交易标的")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 获取选中行的ID
|
|
||||||
item = selected[0]
|
|
||||||
values = self.trade_table.item(item)['values']
|
|
||||||
target_id = values[0]
|
|
||||||
|
|
||||||
# 从列表中找到对应的target对象
|
|
||||||
for id in self.tradeTargetData:
|
|
||||||
if int(target_id) == id: # type: ignore
|
|
||||||
return self.tradeTargetData[id]
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def refresh_table(self):
|
|
||||||
"""刷新表格数据"""
|
|
||||||
# 保存当前选中的项
|
|
||||||
selected_items = self.trade_table.selection()
|
|
||||||
selected_values = []
|
|
||||||
for item in selected_items:
|
|
||||||
values = self.trade_table.item(item)['values']
|
|
||||||
if values:
|
|
||||||
selected_values.append(values[0]) # 保存ID
|
|
||||||
|
|
||||||
# 清空表格
|
|
||||||
for item in self.trade_table.get_children():
|
|
||||||
self.trade_table.delete(item)
|
|
||||||
|
|
||||||
# 重新填充
|
|
||||||
self.populate_trade_table()
|
|
||||||
|
|
||||||
# 恢复之前选中的项
|
|
||||||
if selected_values:
|
|
||||||
for item in self.trade_table.get_children():
|
|
||||||
values = self.trade_table.item(item)['values']
|
|
||||||
if values and values[0] in selected_values:
|
|
||||||
self.trade_table.selection_add(item)
|
|
||||||
|
|
||||||
# 刷新市场监控表格
|
|
||||||
self.populate_market_table()
|
|
||||||
|
|
||||||
def create_grid_view_window(self, target: SFGridTradeTarget):
|
|
||||||
"""创建网格配置查看窗口(只读)"""
|
|
||||||
# 获取顶层窗口
|
|
||||||
root = self.winfo_toplevel()
|
|
||||||
|
|
||||||
# 创建顶层窗口
|
|
||||||
view_window = tk.Toplevel(root)
|
|
||||||
view_window.title(f"网格配置查看 - {target.stock_code} ({target.stock_name})")
|
|
||||||
view_window.geometry("500x450")
|
|
||||||
view_window.resizable(False, False)
|
|
||||||
|
|
||||||
# 设置窗口模态
|
|
||||||
view_window.transient(root)
|
|
||||||
view_window.grab_set()
|
|
||||||
|
|
||||||
# 居中显示
|
|
||||||
root.update_idletasks()
|
|
||||||
x = root.winfo_x() + (root.winfo_width() // 2) - 250
|
|
||||||
y = root.winfo_y() + (root.winfo_height() // 2) - 225
|
|
||||||
view_window.geometry(f"500x450+{x}+{y}")
|
|
||||||
|
|
||||||
# 创建主框架
|
|
||||||
main_frame = ttk.Frame(view_window, padding=20)
|
|
||||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# 显示股票信息
|
|
||||||
info_frame = ttk.LabelFrame(main_frame, text="标的详情", padding=10)
|
|
||||||
info_frame.pack(fill=tk.X, pady=(0, 10))
|
|
||||||
|
|
||||||
ttk.Label(info_frame, text=f"股票代码: {target.stock_code}").grid(row=0, column=0, sticky=tk.W, pady=2)
|
|
||||||
ttk.Label(info_frame, text=f"股票名称: {target.stock_name}").grid(row=0, column=1, sticky=tk.W, padx=(20, 0), pady=2)
|
|
||||||
ttk.Label(info_frame, text=f"状态: 已建初始仓(仅查看模式)").grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=2)
|
|
||||||
|
|
||||||
# 创建网格配置查看框架
|
|
||||||
config_frame = ttk.LabelFrame(main_frame, text="网格配置", padding=10)
|
|
||||||
config_frame.pack(fill=tk.X, pady=(0, 10))
|
|
||||||
|
|
||||||
# 基准价格
|
|
||||||
base_price_frame = ttk.Frame(config_frame)
|
|
||||||
base_price_frame.pack(fill=tk.X, pady=5)
|
|
||||||
ttk.Label(base_price_frame, text="基准价格:", width=15).pack(side=tk.LEFT)
|
|
||||||
ttk.Label(base_price_frame, text=f"{target.grid_start_price:.3f}", width=15, anchor=tk.W).pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Label(base_price_frame, text="元", foreground='gray').pack(side=tk.LEFT)
|
|
||||||
|
|
||||||
# 网格大小
|
|
||||||
grid_size_frame = ttk.Frame(config_frame)
|
|
||||||
grid_size_frame.pack(fill=tk.X, pady=5)
|
|
||||||
ttk.Label(grid_size_frame, text="网格大小:", width=15).pack(side=tk.LEFT)
|
|
||||||
ttk.Label(grid_size_frame, text=f"{target.grid_size:.3f}", width=15, anchor=tk.W).pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Label(grid_size_frame, text="元", foreground='gray').pack(side=tk.LEFT)
|
|
||||||
|
|
||||||
# 网格交易量
|
|
||||||
grid_volume_frame = ttk.Frame(config_frame)
|
|
||||||
grid_volume_frame.pack(fill=tk.X, pady=5)
|
|
||||||
ttk.Label(grid_volume_frame, text="网格交易量:", width=15).pack(side=tk.LEFT)
|
|
||||||
ttk.Label(grid_volume_frame, text=str(target.grid_volume), width=15, anchor=tk.W).pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Label(grid_volume_frame, text="股", foreground='gray').pack(side=tk.LEFT)
|
|
||||||
|
|
||||||
# 上方网格数量
|
|
||||||
upper_count_frame = ttk.Frame(config_frame)
|
|
||||||
upper_count_frame.pack(fill=tk.X, pady=5)
|
|
||||||
ttk.Label(upper_count_frame, text="上方网格数量:", width=15).pack(side=tk.LEFT)
|
|
||||||
ttk.Label(upper_count_frame, text=str(target.grid_upper_count), width=15, anchor=tk.W).pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Label(upper_count_frame, text="格", foreground='gray').pack(side=tk.LEFT)
|
|
||||||
|
|
||||||
# 下方网格数量
|
|
||||||
lower_count_frame = ttk.Frame(config_frame)
|
|
||||||
lower_count_frame.pack(fill=tk.X, pady=5)
|
|
||||||
ttk.Label(lower_count_frame, text="下方网格数量:", width=15).pack(side=tk.LEFT)
|
|
||||||
ttk.Label(lower_count_frame, text=str(target.grid_lower_count), width=15, anchor=tk.W).pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Label(lower_count_frame, text="格", foreground='gray').pack(side=tk.LEFT)
|
|
||||||
|
|
||||||
# 生成网格价格序列
|
|
||||||
price_grid_frame = ttk.LabelFrame(main_frame, text="网格价格序列", padding=10)
|
|
||||||
price_grid_frame.pack(fill=tk.X, pady=(0, 10))
|
|
||||||
|
|
||||||
# 计算并显示网格价格序列
|
|
||||||
price_list = target.getPriceGrid()
|
|
||||||
price_text = ", ".join([f"{price:.3f}" for price in price_list])
|
|
||||||
|
|
||||||
# 创建文本框显示网格价格序列
|
|
||||||
text_frame = ttk.Frame(price_grid_frame)
|
|
||||||
text_frame.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
text_widget = tk.Text(text_frame, height=4, wrap=tk.WORD)
|
|
||||||
text_widget.insert(tk.END, price_text)
|
|
||||||
text_widget.config(state=tk.DISABLED) # 只读
|
|
||||||
|
|
||||||
scrollbar = ttk.Scrollbar(text_frame, orient=tk.VERTICAL, command=text_widget.yview)
|
|
||||||
text_widget.configure(yscrollcommand=scrollbar.set)
|
|
||||||
|
|
||||||
text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
||||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
||||||
|
|
||||||
# 关闭按钮
|
|
||||||
button_frame = ttk.Frame(main_frame)
|
|
||||||
button_frame.pack(fill=tk.X, pady=(10, 0))
|
|
||||||
ttk.Button(button_frame, text="关闭", command=view_window.destroy).pack(side=tk.RIGHT, padx=5)
|
|
||||||
|
|
||||||
def create_grid_config_window(self, target: SFGridTradeTarget):
|
|
||||||
"""创建网格配置窗口(可编辑)"""
|
|
||||||
# 获取顶层窗口
|
|
||||||
root = self.winfo_toplevel()
|
|
||||||
|
|
||||||
# 创建顶层窗口
|
|
||||||
config_window = tk.Toplevel(root)
|
|
||||||
config_window.title(f"网格配置 - {target.stock_code} ({target.stock_name})")
|
|
||||||
config_window.geometry("550x550")
|
|
||||||
config_window.resizable(False, False)
|
|
||||||
|
|
||||||
# 设置窗口模态
|
|
||||||
config_window.transient(root)
|
|
||||||
config_window.grab_set()
|
|
||||||
|
|
||||||
# 居中显示
|
|
||||||
root.update_idletasks()
|
|
||||||
x = root.winfo_x() + (root.winfo_width() // 2) - 275
|
|
||||||
y = root.winfo_y() + (root.winfo_height() // 2) - 275
|
|
||||||
config_window.geometry(f"550x550+{x}+{y}")
|
|
||||||
|
|
||||||
# 创建主框架
|
|
||||||
main_frame = ttk.Frame(config_window, padding=20)
|
|
||||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# 显示股票信息
|
|
||||||
info_frame = ttk.LabelFrame(main_frame, text="标的详情", padding=10)
|
|
||||||
info_frame.pack(fill=tk.X, pady=(0, 10))
|
|
||||||
|
|
||||||
ttk.Label(info_frame, text=f"股票代码: {target.stock_code}").grid(row=0, column=0, sticky=tk.W, pady=2)
|
|
||||||
ttk.Label(info_frame, text=f"股票名称: {target.stock_name}").grid(row=0, column=1, sticky=tk.W, padx=(20, 0), pady=2)
|
|
||||||
ttk.Label(info_frame, text=f"状态: 新标的(可配置模式)").grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=2)
|
|
||||||
|
|
||||||
# 创建网格配置框架
|
|
||||||
config_frame = ttk.LabelFrame(main_frame, text="网格配置", padding=15)
|
|
||||||
config_frame.pack(fill=tk.X, pady=(0, 10))
|
|
||||||
|
|
||||||
# 创建输入框字典用于保存引用
|
|
||||||
entries = {}
|
|
||||||
|
|
||||||
# 基准价格
|
|
||||||
base_price_frame = ttk.Frame(config_frame)
|
|
||||||
base_price_frame.pack(fill=tk.X, pady=5)
|
|
||||||
ttk.Label(base_price_frame, text="基准价格:", width=15).pack(side=tk.LEFT)
|
|
||||||
base_price_entry = ttk.Entry(base_price_frame, width=15)
|
|
||||||
base_price_entry.insert(0, str(target.grid_start_price))
|
|
||||||
base_price_entry.pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Label(base_price_frame, text="元", foreground='gray').pack(side=tk.LEFT)
|
|
||||||
entries['grid_start_price'] = base_price_entry
|
|
||||||
|
|
||||||
# 网格大小
|
|
||||||
grid_size_frame = ttk.Frame(config_frame)
|
|
||||||
grid_size_frame.pack(fill=tk.X, pady=5)
|
|
||||||
ttk.Label(grid_size_frame, text="网格大小:", width=15).pack(side=tk.LEFT)
|
|
||||||
grid_size_entry = ttk.Entry(grid_size_frame, width=15)
|
|
||||||
grid_size_entry.insert(0, str(target.grid_size))
|
|
||||||
grid_size_entry.pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Label(grid_size_frame, text="元", foreground='gray').pack(side=tk.LEFT)
|
|
||||||
entries['grid_size'] = grid_size_entry
|
|
||||||
|
|
||||||
# 网格交易量
|
|
||||||
grid_volume_frame = ttk.Frame(config_frame)
|
|
||||||
grid_volume_frame.pack(fill=tk.X, pady=5)
|
|
||||||
ttk.Label(grid_volume_frame, text="网格交易量:", width=15).pack(side=tk.LEFT)
|
|
||||||
grid_volume_entry = ttk.Entry(grid_volume_frame, width=15)
|
|
||||||
grid_volume_entry.insert(0, str(target.grid_volume))
|
|
||||||
grid_volume_entry.pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Label(grid_volume_frame, text="手", foreground='gray').pack(side=tk.LEFT)
|
|
||||||
entries['grid_volume'] = grid_volume_entry
|
|
||||||
|
|
||||||
# 上方网格数量
|
|
||||||
upper_count_frame = ttk.Frame(config_frame)
|
|
||||||
upper_count_frame.pack(fill=tk.X, pady=5)
|
|
||||||
ttk.Label(upper_count_frame, text="上方网格数量:", width=15).pack(side=tk.LEFT)
|
|
||||||
upper_count_entry = ttk.Entry(upper_count_frame, width=15)
|
|
||||||
upper_count_entry.insert(0, str(target.grid_upper_count))
|
|
||||||
upper_count_entry.pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Label(upper_count_frame, text="格", foreground='gray').pack(side=tk.LEFT)
|
|
||||||
entries['grid_upper_count'] = upper_count_entry
|
|
||||||
|
|
||||||
# 下方网格数量
|
|
||||||
lower_count_frame = ttk.Frame(config_frame)
|
|
||||||
lower_count_frame.pack(fill=tk.X, pady=5)
|
|
||||||
ttk.Label(lower_count_frame, text="下方网格数量:", width=15).pack(side=tk.LEFT)
|
|
||||||
lower_count_entry = ttk.Entry(lower_count_frame, width=15)
|
|
||||||
lower_count_entry.insert(0, str(target.grid_lower_count))
|
|
||||||
lower_count_entry.pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Label(lower_count_frame, text="格", foreground='gray').pack(side=tk.LEFT)
|
|
||||||
entries['grid_lower_count'] = lower_count_entry
|
|
||||||
|
|
||||||
# 预览按钮和结果显示
|
|
||||||
preview_frame = ttk.LabelFrame(main_frame, text="网格价格序列预览", padding=10)
|
|
||||||
preview_frame.pack(fill=tk.X, pady=(0, 10))
|
|
||||||
|
|
||||||
preview_result = tk.StringVar(value="点击'预览'查看生成的网格价格序列")
|
|
||||||
|
|
||||||
def calculate_grid_prices():
|
|
||||||
"""计算网格价格序列"""
|
|
||||||
try:
|
|
||||||
base_price = float(base_price_entry.get())
|
|
||||||
grid_size = float(grid_size_entry.get())
|
|
||||||
upper_count = int(upper_count_entry.get())
|
|
||||||
lower_count = int(lower_count_entry.get())
|
|
||||||
|
|
||||||
prices = []
|
|
||||||
|
|
||||||
# 计算上方网格价格
|
|
||||||
for i in range(upper_count, 0, -1):
|
|
||||||
price = base_price + grid_size * i
|
|
||||||
prices.append(round(price, 3))
|
|
||||||
|
|
||||||
# 添加基准价格
|
|
||||||
prices.append(base_price)
|
|
||||||
|
|
||||||
# 计算下方网格价格
|
|
||||||
for i in range(1, lower_count + 1):
|
|
||||||
price = base_price - grid_size * i
|
|
||||||
# 确保价格不为负
|
|
||||||
if price >= 0:
|
|
||||||
prices.append(round(price, 3))
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
return prices
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def update_preview():
|
|
||||||
"""更新网格价格序列预览"""
|
|
||||||
prices = calculate_grid_prices()
|
|
||||||
if prices:
|
|
||||||
price_str = ", ".join([str(p) for p in prices])
|
|
||||||
preview_result.set(f"网格价格序列: {price_str}")
|
|
||||||
else:
|
|
||||||
preview_result.set("参数错误,请检查输入!")
|
|
||||||
|
|
||||||
# 绑定输入变化自动预览
|
|
||||||
for entry_widget in entries.values():
|
|
||||||
entry_widget.bind("<KeyRelease>", lambda e: update_preview())
|
|
||||||
entry_widget.bind("<FocusOut>", lambda e: update_preview())
|
|
||||||
|
|
||||||
# 预览按钮
|
|
||||||
preview_button_frame = ttk.Frame(preview_frame)
|
|
||||||
preview_button_frame.pack(fill=tk.X, pady=5)
|
|
||||||
# ttk.Button(preview_button_frame, text="预览", command=update_preview).pack(side=tk.LEFT)
|
|
||||||
|
|
||||||
# 预览结果显示
|
|
||||||
preview_label = ttk.Label(preview_button_frame, textvariable=preview_result, foreground='blue')
|
|
||||||
preview_label.pack(side=tk.LEFT, padx=10)
|
|
||||||
|
|
||||||
# 初始预览
|
|
||||||
update_preview()
|
|
||||||
|
|
||||||
# 按钮框架
|
|
||||||
button_frame = ttk.Frame(main_frame)
|
|
||||||
button_frame.pack(fill=tk.X, pady=(10, 0))
|
|
||||||
|
|
||||||
def save_config():
|
|
||||||
"""保存配置"""
|
|
||||||
try:
|
|
||||||
# 获取输入值
|
|
||||||
grid_start_price = float(base_price_entry.get())
|
|
||||||
grid_size = float(grid_size_entry.get())
|
|
||||||
grid_volume = int(grid_volume_entry.get())
|
|
||||||
grid_upper_count = int(upper_count_entry.get())
|
|
||||||
grid_lower_count = int(lower_count_entry.get())
|
|
||||||
|
|
||||||
# 更新target对象(使用setattr来正确设置Peewee字段的值)
|
|
||||||
setattr(target, 'grid_start_price', grid_start_price)
|
|
||||||
setattr(target, 'grid_size', grid_size)
|
|
||||||
setattr(target, 'grid_volume', grid_volume)
|
|
||||||
setattr(target, 'grid_upper_count', grid_upper_count)
|
|
||||||
setattr(target, 'grid_lower_count', grid_lower_count)
|
|
||||||
setattr(target, 'status', 0)
|
|
||||||
|
|
||||||
# 更新策略控制器
|
|
||||||
self.updateTradeTarget(target, True) # 网格配置变更
|
|
||||||
|
|
||||||
# 关闭窗口
|
|
||||||
config_window.destroy()
|
|
||||||
|
|
||||||
# 添加日志
|
|
||||||
PrintLog(LogLevel.INFO, f"网格配置已保存: {target.stock_code} - {target.stock_name}")
|
|
||||||
messagebox.showinfo("成功", "网格配置已保存!")
|
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
messagebox.showerror("错误", "输入参数有误,请检查!")
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("错误", f"保存配置失败:{str(e)}")
|
|
||||||
PrintLog(LogLevel.ERROR, f"保存网格配置失败: {str(e)}")
|
|
||||||
|
|
||||||
# 保存和取消按钮
|
|
||||||
ttk.Button(button_frame, text="保存", command=save_config).pack(side=tk.RIGHT, padx=5)
|
|
||||||
ttk.Button(button_frame, text="取消", command=config_window.destroy).pack(side=tk.RIGHT, padx=5)
|
|
||||||
|
|
||||||
def decrease_grid_index(self, grid_index_var: tk.IntVar, target: SFGridTradeTarget, required_position_label: ttk.Label, position_status_label: ttk.Label):
|
|
||||||
"""减少网格序号"""
|
|
||||||
current_value = grid_index_var.get()
|
|
||||||
if current_value > 0:
|
|
||||||
grid_index_var.set(current_value - 1)
|
|
||||||
# 同步更新需求持仓量和持仓状态
|
|
||||||
self.update_required_position_and_status(grid_index_var.get(), target, required_position_label, position_status_label)
|
|
||||||
|
|
||||||
def increase_grid_index(self, grid_index_var: tk.IntVar, max_index: int, target: SFGridTradeTarget, required_position_label: ttk.Label, position_status_label: ttk.Label):
|
|
||||||
"""增加网格序号"""
|
|
||||||
current_value = grid_index_var.get()
|
|
||||||
if current_value < max_index:
|
|
||||||
grid_index_var.set(current_value + 1)
|
|
||||||
# 同步更新需求持仓量和持仓状态
|
|
||||||
self.update_required_position_and_status(grid_index_var.get(), target, required_position_label, position_status_label)
|
|
||||||
|
|
||||||
def update_position_status(self, current_position: int, required_position: int, status_label: ttk.Label):
|
|
||||||
"""更新持仓量状态提示"""
|
|
||||||
if current_position >= required_position:
|
|
||||||
status_label.config(text="持仓量充足", foreground="green")
|
|
||||||
else:
|
|
||||||
shortage = required_position - current_position
|
|
||||||
status_label.config(text=f"还需补充 {shortage} 手仓位", foreground="red")
|
|
||||||
|
|
||||||
|
|
||||||
def update_required_position_and_status(self, grid_index: int, target: SFGridTradeTarget, required_position_label: ttk.Label, position_status_label: ttk.Label):
|
|
||||||
"""更新需求持仓量和持仓状态"""
|
|
||||||
# 计算需求持仓量
|
|
||||||
required_position:int = grid_index * target.grid_volume # type: ignore
|
|
||||||
required_position_label.config(text=str(required_position))
|
|
||||||
|
|
||||||
# 更新持仓量状态
|
|
||||||
current_position = getattr(target, 'current_position')
|
|
||||||
self.update_position_status(current_position, required_position, position_status_label)
|
|
||||||
|
|
||||||
|
|
||||||
# 交易池管理
|
|
||||||
def addTradeTarget(self, stock_code: str, gridIndex: int = 1): # 新增
|
|
||||||
"""处理添加交易标的事件"""
|
|
||||||
try:
|
|
||||||
stock_name = qmtv.getInstrumentName(stock_code)
|
|
||||||
if not stock_name:
|
|
||||||
PrintLog(LogLevel.ERROR, f'无法获取股票代码 {stock_code} 的名称,请检查代码是否正确')
|
|
||||||
return
|
|
||||||
PrintLog(LogLevel.DEBUG, f'添加交易标的: {stock_code} {stock_name}')
|
|
||||||
|
|
||||||
# 检查是否已存在该标的
|
|
||||||
existing_target = SFGridTradeTarget.get_or_none(SFGridTradeTarget.stock_code == stock_code)
|
|
||||||
if existing_target:
|
|
||||||
PrintLog(LogLevel.INFO, f'交易标的 {stock_code} {stock_name} 已存在')
|
|
||||||
return
|
|
||||||
|
|
||||||
# 刷新标的持仓
|
|
||||||
pos = qmtv.getStockPosition(stock_code) # type: ignore
|
|
||||||
new_target = SFGridTradeTarget.create(
|
|
||||||
stock_name=stock_name,
|
|
||||||
stock_code=stock_code,
|
|
||||||
current_position="0" if pos is None else str(pos.volume),
|
|
||||||
grid_index=gridIndex,
|
|
||||||
init_price=0.0,
|
|
||||||
status=-1
|
|
||||||
)
|
|
||||||
# 更新标的池
|
|
||||||
self.updateTradeTarget(new_target, True) # 新增标的,相当于也是初始化
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
PrintLog(LogLevel.ERROR, f'新增交易标的失败 {stock_code} {e}')
|
|
||||||
|
|
||||||
# button handlers =============================================================================================
|
|
||||||
def btnHandlerGridCorrect(self):
|
|
||||||
|
|
||||||
target = self.get_selected_target()
|
|
||||||
if not target:
|
|
||||||
return
|
|
||||||
self.create_grid_correction_window(target)
|
|
||||||
|
|
||||||
|
|
||||||
def btnHandlerToggleMarketMonitor(self):
|
|
||||||
"""切换市场监控窗口显示/隐藏"""
|
|
||||||
if self.market_monitor_visible:
|
|
||||||
# 隐藏市场监控窗口
|
|
||||||
self.market_frame.pack_forget()
|
|
||||||
self.market_monitor_visible = False
|
|
||||||
else:
|
|
||||||
# 显示市场监控窗口
|
|
||||||
self.market_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(5, 0))
|
|
||||||
self.market_monitor_visible = True
|
|
||||||
|
|
||||||
def btnHandlerTradeSettings(self):
|
|
||||||
"""网格配置功能"""
|
|
||||||
target = self.get_selected_target()
|
|
||||||
if not target:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 检查标的的状态,status为1时仅可查看
|
|
||||||
if target.status == -1 or target.status == 0:
|
|
||||||
self.create_grid_config_window(target)
|
|
||||||
else:
|
|
||||||
# 创建只读的网格配置查看窗口
|
|
||||||
self.create_grid_view_window(target)
|
|
||||||
|
|
||||||
def btnHandlerStartSelectedTrade(self):
|
|
||||||
"""启动选中的交易"""
|
|
||||||
target = self.get_selected_target()
|
|
||||||
if not target:
|
|
||||||
return
|
|
||||||
|
|
||||||
if target.status < 0:
|
|
||||||
messagebox.showinfo("提示", f"{target.stock_code} ({target.stock_name}) 未配置交易参数, 请做交易设置。")
|
|
||||||
return
|
|
||||||
|
|
||||||
if target.enabled: # type: ignore
|
|
||||||
messagebox.showinfo("提示", f"{target.stock_code} ({target.stock_name}) 已经在运行中")
|
|
||||||
return
|
|
||||||
|
|
||||||
result = messagebox.askyesno(
|
|
||||||
"确认启动",
|
|
||||||
f"确定要启动以下交易标的吗?\n\n"
|
|
||||||
f"股票代码: {target.stock_code}\n"
|
|
||||||
f"股票名称: {target.stock_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
PrintLog(LogLevel.INFO, f'启动标的交易 {target.targetName()}')
|
|
||||||
target.enabled = True # type: ignore
|
|
||||||
|
|
||||||
id = target.get_id()
|
|
||||||
if id in self.strategy_ctrl:
|
|
||||||
tradeController: SFGridStrategy = self.strategy_ctrl[target.get_id()]
|
|
||||||
tradeTarget = tradeController.enabledTrading(True)
|
|
||||||
self.tradeTargetData[id] = tradeTarget
|
|
||||||
else:
|
|
||||||
PrintLog(LogLevel.INFO, f"\t创建标的交易控制器 {target.targetName()}")
|
|
||||||
|
|
||||||
def btnHandlerStopSelectedTrade(self):
|
|
||||||
"""暂停选中的交易"""
|
|
||||||
target = self.get_selected_target()
|
|
||||||
if not target:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not target.enabled: # type: ignore
|
|
||||||
messagebox.showinfo("提示", f"{target.stock_code} ({target.stock_name}) 已经是暂停状态")
|
|
||||||
return
|
|
||||||
|
|
||||||
result = messagebox.askyesno(
|
|
||||||
"确认暂停",
|
|
||||||
f"确定要暂停以下交易标的吗?\n\n"
|
|
||||||
f"股票代码: {target.stock_code}\n"
|
|
||||||
f"股票名称: {target.stock_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
PrintLog(LogLevel.INFO, f'暂停标的交易 {target.targetName()}')
|
|
||||||
id = target.get_id()
|
|
||||||
if id in self.strategy_ctrl:
|
|
||||||
tradeController: SFGridStrategy = self.strategy_ctrl[target.get_id()]
|
|
||||||
tradeController.enabledTrading(False)
|
|
||||||
else:
|
|
||||||
print(f"标的交易控制器不存在 {target.stock_code} {target.stock_name}\n")
|
|
||||||
|
|
||||||
def btnHandlerDelSelectedTradeTarget(self):
|
|
||||||
"""删除选中的交易标的"""
|
|
||||||
target = self.get_selected_target()
|
|
||||||
if not target:
|
|
||||||
return
|
|
||||||
|
|
||||||
result = messagebox.askyesno(
|
|
||||||
"确认删除",
|
|
||||||
f"确定要删除以下交易标的吗?\n\n"
|
|
||||||
f"股票代码: {target.stock_code}\n"
|
|
||||||
f"股票名称: {target.stock_name}\n\n"
|
|
||||||
f"⚠️ 此操作不可恢复!",
|
|
||||||
icon='warning'
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
id = target.get_id()
|
|
||||||
# try:
|
|
||||||
if id in self.strategy_ctrl:
|
|
||||||
ctrl = self.strategy_ctrl[id]
|
|
||||||
ctrl.deleteTradeTarget(target)
|
|
||||||
else:
|
|
||||||
self.onTradeTargetDeleted(target)
|
|
||||||
PrintLog(LogLevel.INFO, f"已发送删除请求: {target.stock_code} - {target.stock_name}")
|
|
||||||
|
|
||||||
def onTradeTargetDeleted(self, target: SFGridTradeTarget):
|
|
||||||
id = target.get_id()
|
|
||||||
del self.tradeTargetData[id]
|
|
||||||
del self.strategy_ctrl[id]
|
|
||||||
del self.stockCodeIdMap[target.stock_code] # type: ignore
|
|
||||||
|
|
||||||
def btnHandlerAddTradeTarget(self):
|
|
||||||
"""添加新的交易标的"""
|
|
||||||
# 获取顶层窗口
|
|
||||||
root = self.winfo_toplevel()
|
|
||||||
|
|
||||||
# 创建顶层窗口
|
|
||||||
add_window = tk.Toplevel(root)
|
|
||||||
add_window.title("添加交易标的")
|
|
||||||
add_window.geometry("400x150")
|
|
||||||
add_window.resizable(False, False)
|
|
||||||
|
|
||||||
# 设置窗口模态
|
|
||||||
add_window.transient(root)
|
|
||||||
add_window.grab_set()
|
|
||||||
|
|
||||||
# 居中显示
|
|
||||||
root.update_idletasks()
|
|
||||||
x = root.winfo_x() + (root.winfo_width() // 2) - 200
|
|
||||||
y = root.winfo_y() + (root.winfo_height() // 2) - 75
|
|
||||||
add_window.geometry(f"400x150+{x}+{y}")
|
|
||||||
|
|
||||||
# 创建输入框架
|
|
||||||
input_frame = ttk.Frame(add_window, padding=20)
|
|
||||||
input_frame.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# 股票代码输入
|
|
||||||
ttk.Label(input_frame, text="股票代码:").grid(row=0, column=0, sticky=tk.W, pady=5)
|
|
||||||
stock_code_entry = ttk.Entry(input_frame, width=30)
|
|
||||||
stock_code_entry.grid(row=0, column=1, pady=5, padx=(10, 0))
|
|
||||||
stock_code_entry.focus()
|
|
||||||
|
|
||||||
# 按钮框架
|
|
||||||
button_frame = ttk.Frame(input_frame)
|
|
||||||
button_frame.grid(row=1, column=0, columnspan=2, pady=20)
|
|
||||||
|
|
||||||
def confirm_add():
|
|
||||||
stock_code = stock_code_entry.get().strip()
|
|
||||||
if not stock_code:
|
|
||||||
messagebox.showwarning("输入错误", "请输入股票代码")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 发布事件通知主控制器添加标的
|
|
||||||
self.addTradeTarget(stock_code)
|
|
||||||
add_window.destroy()
|
|
||||||
|
|
||||||
def cancel_add():
|
|
||||||
add_window.destroy()
|
|
||||||
|
|
||||||
# 确认和取消按钮
|
|
||||||
ttk.Button(button_frame, text="确认", command=confirm_add, width=10).pack(side=tk.LEFT, padx=5)
|
|
||||||
ttk.Button(button_frame, text="取消", command=cancel_add, width=10).pack(side=tk.LEFT, padx=5)
|
|
||||||
|
|
||||||
# 绑定回车键确认
|
|
||||||
stock_code_entry.bind('<Return>', lambda event: confirm_add())
|
|
||||||
|
|
||||||
PrintLog(LogLevel.INFO, "点击添加交易标的按钮")
|
|
||||||
|
|
||||||
def btnHandlerSetMonitorPrice(self):
|
|
||||||
"""设置监控价格"""
|
|
||||||
try:
|
|
||||||
# 获取输入的价格
|
|
||||||
price_str = self.monitor_price_entry.get()
|
|
||||||
new_price = float(price_str)
|
|
||||||
|
|
||||||
# 更新监控价格
|
|
||||||
self.monitor_price = new_price
|
|
||||||
|
|
||||||
# 清空当前监控的数据
|
|
||||||
self.marketData.clear()
|
|
||||||
self.listening_stock.clear()
|
|
||||||
|
|
||||||
# 清空市场监控表格
|
|
||||||
for item in self.market_table.get_children():
|
|
||||||
self.market_table.delete(item)
|
|
||||||
|
|
||||||
PrintLog(LogLevel.INFO, f"监控价格已更新为: {new_price}")
|
|
||||||
except ValueError:
|
|
||||||
messagebox.showerror("错误", "请输入有效的数字")
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# coding:utf-8
|
|
||||||
import os
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import ttk, filedialog, messagebox
|
|
||||||
import configparser
|
|
||||||
from core.main_ui import MainWindow
|
|
||||||
import config as sdConstants
|
|
||||||
from core.qmt import qmtv
|
|
||||||
|
|
||||||
class ConfigWindow:
|
|
||||||
def __init__(self, root):
|
|
||||||
self.root = root
|
|
||||||
self.root.title("系统配置")
|
|
||||||
self.root.geometry("500x250")
|
|
||||||
self.root.resizable(False, False)
|
|
||||||
|
|
||||||
# 居中显示
|
|
||||||
self.root.withdraw() # 先隐藏窗口
|
|
||||||
self.root.update_idletasks()
|
|
||||||
x = (self.root.winfo_screenwidth() // 2) - (500 // 2)
|
|
||||||
y = (self.root.winfo_screenheight() // 2) - (250 // 2)
|
|
||||||
self.root.geometry(f"500x250+{x}+{y}")
|
|
||||||
self.root.deiconify() # 再显示窗口
|
|
||||||
|
|
||||||
self.miniQMTPath = tk.StringVar()
|
|
||||||
self.account_no = tk.StringVar()
|
|
||||||
|
|
||||||
self.create_widgets()
|
|
||||||
|
|
||||||
def create_widgets(self):
|
|
||||||
# 创建主框架
|
|
||||||
main_frame = ttk.Frame(self.root, padding="20")
|
|
||||||
main_frame.pack(fill=tk.BOTH, expand=True)
|
|
||||||
|
|
||||||
# miniQMT路径配置
|
|
||||||
path_frame = ttk.Frame(main_frame)
|
|
||||||
path_frame.pack(fill=tk.X, pady=5)
|
|
||||||
|
|
||||||
path_label = ttk.Label(path_frame, text="miniQMT路径:")
|
|
||||||
path_label.pack(side=tk.LEFT)
|
|
||||||
|
|
||||||
path_entry = ttk.Entry(path_frame, textvariable=self.miniQMTPath, width=40)
|
|
||||||
path_entry.pack(side=tk.LEFT, padx=(10, 5), fill=tk.X, expand=True)
|
|
||||||
|
|
||||||
browse_btn = ttk.Button(path_frame, text="浏览", command=self.browse_folder)
|
|
||||||
browse_btn.pack(side=tk.LEFT)
|
|
||||||
|
|
||||||
# 资金账号配置
|
|
||||||
account_frame = ttk.Frame(main_frame)
|
|
||||||
account_frame.pack(fill=tk.X, pady=5)
|
|
||||||
|
|
||||||
account_label = ttk.Label(account_frame, text="资金账号:")
|
|
||||||
account_label.pack(side=tk.LEFT)
|
|
||||||
|
|
||||||
account_entry = ttk.Entry(account_frame, textvariable=self.account_no, width=40)
|
|
||||||
account_entry.pack(side=tk.LEFT, padx=(10, 0))
|
|
||||||
|
|
||||||
# 说明文本
|
|
||||||
info_label = ttk.Label(
|
|
||||||
main_frame,
|
|
||||||
text="请配置miniQMT的userdata_mini路径和资金账号\n路径示例: D:/Programs/DTQMT/userdata_mini",
|
|
||||||
foreground="gray"
|
|
||||||
)
|
|
||||||
info_label.pack(pady=10)
|
|
||||||
|
|
||||||
# 按钮框架
|
|
||||||
button_frame = ttk.Frame(main_frame)
|
|
||||||
button_frame.pack(fill=tk.X, pady=10)
|
|
||||||
|
|
||||||
save_btn = ttk.Button(button_frame, text="保存配置", command=self.save_config)
|
|
||||||
save_btn.pack(side=tk.RIGHT)
|
|
||||||
|
|
||||||
cancel_btn = ttk.Button(button_frame, text="取消", command=self.root.destroy)
|
|
||||||
cancel_btn.pack(side=tk.RIGHT, padx=(0, 10))
|
|
||||||
|
|
||||||
def browse_folder(self):
|
|
||||||
folder_selected = filedialog.askdirectory()
|
|
||||||
if folder_selected:
|
|
||||||
self.miniQMTPath.set(folder_selected)
|
|
||||||
|
|
||||||
def save_config(self):
|
|
||||||
mini_qmt_path = self.miniQMTPath.get().strip()
|
|
||||||
account_number = self.account_no.get().strip()
|
|
||||||
|
|
||||||
# 检查miniQMT路径
|
|
||||||
if not mini_qmt_path:
|
|
||||||
messagebox.showerror("错误", "请选择miniQMT路径")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not os.path.exists(mini_qmt_path):
|
|
||||||
messagebox.showerror("错误", "miniQMT路径不存在")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 检查账号
|
|
||||||
if not account_number:
|
|
||||||
messagebox.showerror("错误", "请输入资金账号")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 保存配置
|
|
||||||
try:
|
|
||||||
sdConstants.save_config(mini_qmt_path.replace('\\', '/'), account_number)
|
|
||||||
messagebox.showinfo("成功", "配置已保存")
|
|
||||||
self.root.destroy()
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("错误", f"保存配置失败: {str(e)}")
|
|
||||||
|
|
||||||
def check_and_create_config():
|
|
||||||
"""检查配置文件,如果不存在则打开配置窗口"""
|
|
||||||
root = tk.Tk()
|
|
||||||
config_window = ConfigWindow(root)
|
|
||||||
root.mainloop()
|
|
||||||
|
|
||||||
def initialize_system():
|
|
||||||
"""初始化系统"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
# 初始化配置
|
|
||||||
if sdConstants.exist_config() and sdConstants.initConfig():
|
|
||||||
# 初始化qmtv
|
|
||||||
qmtv.init_qmtv()
|
|
||||||
connected = qmtv.connect()
|
|
||||||
if connected:
|
|
||||||
# 连接成功,启动主窗口
|
|
||||||
window = MainWindow(sdConstants.log_level)
|
|
||||||
window.run()
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
option = messagebox.askokcancel("连接失败", "QMT连接失败,请检查")
|
|
||||||
if option:
|
|
||||||
check_and_create_config()
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
option = messagebox.askokcancel("错误", "请检查配置")
|
|
||||||
if option:
|
|
||||||
check_and_create_config()
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
messagebox.showerror("错误", f"系统初始化失败: {str(e)}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import tkinter as tk
|
|
||||||
root = tk.Tk()
|
|
||||||
app = MainBoardWindow(root)
|
|
||||||
app.run()
|
|
||||||
|
|
||||||
# initialize_system()
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
|||||||
|
# 统一网格逻辑
|
||||||
|
|
||||||
|
## 核心规则
|
||||||
|
|
||||||
|
对任意 `grid_index`,两个方向各挂一单:
|
||||||
|
|
||||||
|
| 方向 | 条件 | 价格 | 含义 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 卖出(上移) | `grid_index > 0` | `grid[grid_index - 1]` | 涨回到上一格时卖出获利 |
|
||||||
|
| 买入(下移) | `grid_index < len(grid)-1` | `grid[grid_index + 1]` | 跌到下一格时补仓 |
|
||||||
|
|
||||||
|
不需要"建仓"概念,`grid_index=0` 自然表示空仓。
|
||||||
|
|
||||||
|
## 流程图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
START["refreshGridOrder()"] --> GUARD{"isMarketActive AND enabled ?"}
|
||||||
|
GUARD -->|No| EXIT0["跳过不下单"]
|
||||||
|
GUARD -->|Yes| QUERY["查询未成交订单<br/>queryPendingOrder()"]
|
||||||
|
|
||||||
|
QUERY --> IDX["currentIdx = grid_index"]
|
||||||
|
|
||||||
|
IDX --> SELL{"currentIdx > 0 ?"}
|
||||||
|
|
||||||
|
SELL -->|"No<br/>(空仓,无持仓可卖)"| BUY
|
||||||
|
|
||||||
|
SELL -->|"Yes"| SELL_IDX["sellIdx = currentIdx - 1<br/>卖价 = grid[sellIdx]"]
|
||||||
|
SELL_IDX --> SELL_EXIST{"已有同 remark 卖单?"}
|
||||||
|
SELL_EXIST -->|No| SELL_CHECK{"卖价 > 涨停价 ?"}
|
||||||
|
SELL_CHECK -->|Yes| SELL_SKIP["跳过(超出涨停)"]
|
||||||
|
SELL_CHECK -->|No| SELL_PLACE["挂卖出单<br/>orderGrid[sellIdx] = seq"]
|
||||||
|
SELL_EXIST -->|Yes| SELL_DUP["跳过(已挂单)"]
|
||||||
|
|
||||||
|
SELL_SKIP --> BUY
|
||||||
|
SELL_PLACE --> BUY
|
||||||
|
SELL_DUP --> BUY
|
||||||
|
|
||||||
|
BUY{"currentIdx < len(grid)-1 ?"}
|
||||||
|
|
||||||
|
BUY -->|"No<br/>(已到最低价)"| EXIT["结束"]
|
||||||
|
|
||||||
|
BUY -->|"Yes"| BUY_IDX["buyIdx = currentIdx + 1<br/>买价 = grid[buyIdx]"]
|
||||||
|
BUY_IDX --> BUY_EXIST{"已有同 remark 买单?"}
|
||||||
|
BUY_EXIST -->|No| BUY_CHECK{"买价 < 跌停价 ?"}
|
||||||
|
BUY_CHECK -->|Yes| BUY_SKIP["跳过(低于跌停)"]
|
||||||
|
BUY_CHECK -->|No| BUY_PLACE["挂买入单<br/>orderGrid[buyIdx] = seq"]
|
||||||
|
BUY_EXIST -->|Yes| BUY_DUP["跳过(已挂单)"]
|
||||||
|
|
||||||
|
BUY_SKIP --> EXIT
|
||||||
|
BUY_PLACE --> EXIT
|
||||||
|
BUY_DUP --> EXIT
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三种典型状态
|
||||||
|
|
||||||
|
```
|
||||||
|
grid = [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
|
||||||
|
↑ ↑ ↑ ↑
|
||||||
|
0 1 2 3 ...
|
||||||
|
|
||||||
|
|
||||||
|
grid_index=0(空仓):
|
||||||
|
┌────┬────┬────┬────┐
|
||||||
|
│ 11 │ 10 │ 9 │ 8 │ ...
|
||||||
|
└────┴────┴────┴────┘
|
||||||
|
sell=无 buy=10 ← 第一笔买单
|
||||||
|
|
||||||
|
|
||||||
|
grid_index=1(持1份@10元):
|
||||||
|
┌────┬────┬────┬────┐
|
||||||
|
│ 11 │ 10 │ 9 │ 8 │ ...
|
||||||
|
└────┴────┴────┴────┘
|
||||||
|
sell=11 → buy=9 →
|
||||||
|
|
||||||
|
|
||||||
|
grid_index=3(持3份@8,9,10元):
|
||||||
|
┌────┬────┬────┬────┐
|
||||||
|
│ 11 │ 10 │ 9 │ 8 │ ...
|
||||||
|
└────┴────┴────┴────┘
|
||||||
|
↑ sell=9 buy=7 →
|
||||||
|
当前位置=3
|
||||||
|
|
||||||
|
|
||||||
|
成交后处理(onOrderTrade):
|
||||||
|
卖单成交 gridIdx < currentIdx → grid_index -= 1(上移,赚差价)
|
||||||
|
买单成交 gridIdx > currentIdx → grid_index += 1(下移,补仓)
|
||||||
|
然后 refreshGridOrder → 在新位置重新挂单
|
||||||
|
```
|
||||||
|
|
||||||
|
## 和之前的区别
|
||||||
|
|
||||||
|
| | 之前 | 之后 |
|
||||||
|
|---|---|---|
|
||||||
|
| 分支数 | 2 个(status=0 / status=1) | 1 个(统一网格逻辑) |
|
||||||
|
| 空仓第一笔 | INIT 单 @ grid[0]=11 | 普通买单 @ grid[1]=10 |
|
||||||
|
| grid[0]=11 的用途 | 建仓买入 | 永远只卖不买 |
|
||||||
|
| 状态字段 | status + grid_index | 仅 grid_index |
|
||||||
-259
@@ -1,259 +0,0 @@
|
|||||||
from kuanke.wizard import *
|
|
||||||
from jqdata import *
|
|
||||||
import pandas as pd
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
# ==================== 初始化 ====================
|
|
||||||
def initialize(context):
|
|
||||||
set_params(context)
|
|
||||||
# 开启防未来函数
|
|
||||||
set_option('avoid_future_data', True)
|
|
||||||
# 用真实价格交易
|
|
||||||
set_option('use_real_price', True)
|
|
||||||
# 过滤order中低于error级别的日志
|
|
||||||
log.set_level('order', 'error')
|
|
||||||
log.set_level('system', 'error')
|
|
||||||
log.set_level('strategy', 'debug')
|
|
||||||
|
|
||||||
set_benchmark('000001.XSHG')
|
|
||||||
set_order_cost(OrderCost(open_tax=0, close_tax=0.001, open_commission=0.0002, close_commission=0.0002, min_commission=5), type='stock')
|
|
||||||
set_slippage(FixedSlippage(0.01))
|
|
||||||
|
|
||||||
run_daily(before_trading, '9:30')
|
|
||||||
|
|
||||||
# -------------------- 参数设置 --------------------
|
|
||||||
def set_params(context):
|
|
||||||
context.max_price = 6
|
|
||||||
context.min_price = 5.01
|
|
||||||
context.grid_base_min = 1 # 最小价格
|
|
||||||
context.grid_base_max = 5 # 建仓价格
|
|
||||||
context.grid_interval = 0.5 # 下跌n元加仓
|
|
||||||
context.profit_target = 0.5 # 上涨n元清仓
|
|
||||||
|
|
||||||
context.min_stocks = 10
|
|
||||||
context.max_stocks = 25
|
|
||||||
context.base_max_stocks = 25
|
|
||||||
context.max_layers = 7
|
|
||||||
|
|
||||||
context.base_position_pct = 0.15
|
|
||||||
context.max_position_pct = 0.15
|
|
||||||
context.target_usage = 0.98
|
|
||||||
context.reserve_ratio = 0.02
|
|
||||||
|
|
||||||
context.first_round_max = 10
|
|
||||||
context.add_batch_size = 3
|
|
||||||
context.add_cash_threshold = 0.4
|
|
||||||
|
|
||||||
g.stock_pool = []
|
|
||||||
g.grid_info = {}
|
|
||||||
g.monitoring_stocks = set()
|
|
||||||
g.first_round_done = False
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 盘前 ====================
|
|
||||||
def before_trading(context):
|
|
||||||
january_clear(context)
|
|
||||||
if context.current_dt.month == 1:
|
|
||||||
return
|
|
||||||
stock_pool = get_stock_pool(context)
|
|
||||||
g.stock_pool = stock_pool
|
|
||||||
g.monitoring_stocks.update([s for s in stock_pool if s not in g.grid_info])
|
|
||||||
g.first_round_done = len(g.grid_info) >= context.first_round_max
|
|
||||||
|
|
||||||
# -------------------- 股票池 --------------------
|
|
||||||
def get_stock_pool(context):
|
|
||||||
# 1. 全部 A 股(不含退市)
|
|
||||||
df_sec = get_all_securities(types=['stock'], date=context.previous_date)
|
|
||||||
codes = list(df_sec.index)
|
|
||||||
|
|
||||||
# 2. 过滤 ST、科创板、北交所
|
|
||||||
def is_valid(code):
|
|
||||||
name = df_sec.loc[code, 'display_name']
|
|
||||||
if 'ST' in name or '退' in name or 'st' in name:
|
|
||||||
return False
|
|
||||||
if code.startswith('688'): # 科创板
|
|
||||||
return False
|
|
||||||
if code.startswith('83') or code.startswith('87') or code.startswith('9'): # 北交所
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
codes = [c for c in codes if is_valid(c)]
|
|
||||||
|
|
||||||
if not codes:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 3. 过滤停牌 & 价格区间
|
|
||||||
try:
|
|
||||||
price_df = get_price(codes,
|
|
||||||
end_date=context.current_dt,
|
|
||||||
count=1,
|
|
||||||
fields=['pre_close'],
|
|
||||||
panel=False)
|
|
||||||
|
|
||||||
if price_df is None or price_df.empty:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 过滤价格区间
|
|
||||||
price_df = price_df[
|
|
||||||
(price_df['pre_close'].notna()) &
|
|
||||||
(price_df['pre_close'] >= context.min_price) &
|
|
||||||
(price_df['pre_close'] <= context.max_price)
|
|
||||||
]
|
|
||||||
|
|
||||||
valid_codes = price_df['code'].tolist()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"获取价格数据失败: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
if not valid_codes:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 4. 过滤停牌(开盘价缺失)
|
|
||||||
try:
|
|
||||||
open_df = get_price(valid_codes,
|
|
||||||
end_date=context.current_dt,
|
|
||||||
count=1,
|
|
||||||
fields=['open'],
|
|
||||||
panel=False)
|
|
||||||
|
|
||||||
if open_df is None or open_df.empty:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 过滤掉开盘价为空的股票
|
|
||||||
open_df = open_df[open_df['open'].notna()]
|
|
||||||
final_codes = open_df['code'].tolist()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"获取开盘价数据失败: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
return final_codes
|
|
||||||
|
|
||||||
# -------------------- 一月清仓 --------------------
|
|
||||||
def january_clear(context):
|
|
||||||
if context.current_dt.month == 1:
|
|
||||||
log.info("进入1月,执行年度清仓...")
|
|
||||||
for stock in list(context.portfolio.positions.keys()):
|
|
||||||
order_target(stock, 0)
|
|
||||||
if stock in g.grid_info:
|
|
||||||
del g.grid_info[stock]
|
|
||||||
g.monitoring_stocks.add(stock)
|
|
||||||
|
|
||||||
# ==================== 盘中 ====================
|
|
||||||
def handle_data(context, data):
|
|
||||||
if context.current_dt.month == 1:
|
|
||||||
return
|
|
||||||
manage_positions(context, data)
|
|
||||||
usage = (context.portfolio.total_value - context.portfolio.available_cash) / context.portfolio.total_value
|
|
||||||
dynamic_max = get_dynamic_max_stocks(context)
|
|
||||||
if len(g.grid_info) < dynamic_max and usage < context.target_usage:
|
|
||||||
try_build_new(context, data)
|
|
||||||
|
|
||||||
# -------------------- 动态上限 --------------------
|
|
||||||
def get_dynamic_max_stocks(context):
|
|
||||||
return context.max_stocks if g.first_round_done else context.first_round_max
|
|
||||||
|
|
||||||
# -------------------- 建仓 --------------------
|
|
||||||
def try_build_new(context, data):
|
|
||||||
position_pct = context.max_position_pct
|
|
||||||
dynamic_max = get_dynamic_max_stocks(context)
|
|
||||||
count = 0
|
|
||||||
for stock in list(g.monitoring_stocks):
|
|
||||||
if len(g.grid_info) >= dynamic_max or count >= 3:
|
|
||||||
break
|
|
||||||
|
|
||||||
price = data[stock].close
|
|
||||||
if context.grid_base_min <= price <= context.grid_base_max:
|
|
||||||
total_value = context.portfolio.total_value
|
|
||||||
stock_amount = total_value * position_pct
|
|
||||||
grid = GridInfo(price, stock_amount, context.max_layers, context.grid_interval, context.profit_target)
|
|
||||||
layer_amount = grid.get_layer_amount(0)
|
|
||||||
buy_amount = int(layer_amount / price / 100) * 100
|
|
||||||
if buy_amount > 0:
|
|
||||||
order(stock, buy_amount)
|
|
||||||
grid.add_position(price, buy_amount, 0)
|
|
||||||
g.grid_info[stock] = grid
|
|
||||||
g.monitoring_stocks.discard(stock)
|
|
||||||
count += 1
|
|
||||||
log.info(f"[建仓] {stock} 价格{price:.2f} 数量{buy_amount}")
|
|
||||||
|
|
||||||
# -------------------- 管理持仓 --------------------
|
|
||||||
def manage_positions(context, data):
|
|
||||||
for stock, grid in list(g.grid_info.items()):
|
|
||||||
|
|
||||||
price = data[stock].close
|
|
||||||
|
|
||||||
# 止盈
|
|
||||||
sellable = grid.get_sellable_positions(price)
|
|
||||||
if sellable:
|
|
||||||
for idx, pos in reversed(sellable):
|
|
||||||
order(stock, -pos['amount'])
|
|
||||||
grid.remove_position(idx)
|
|
||||||
profit = (price - pos['price']) * pos['amount']
|
|
||||||
log.info(f"[止盈] {stock} 盈利{profit:.2f}")
|
|
||||||
|
|
||||||
# 加仓
|
|
||||||
layer = grid.should_add_layer(price)
|
|
||||||
if layer is not None:
|
|
||||||
layer_amount = grid.get_layer_amount(layer)
|
|
||||||
buy_amount = int(layer_amount / price / 100) * 100
|
|
||||||
if buy_amount > 0:
|
|
||||||
order(stock, buy_amount)
|
|
||||||
grid.add_position(price, buy_amount, layer)
|
|
||||||
log.info(f"[加仓] {stock} 层级{layer} 数量{buy_amount}")
|
|
||||||
else:
|
|
||||||
log.info(f"[加仓失败] {stock} 层级{layer} 金额不足")
|
|
||||||
|
|
||||||
# 清仓
|
|
||||||
if len(grid.positions) == 0:
|
|
||||||
del g.grid_info[stock]
|
|
||||||
g.monitoring_stocks.add(stock)
|
|
||||||
log.info(f"[清仓] {stock}")
|
|
||||||
|
|
||||||
# ==================== 盘后 ====================
|
|
||||||
def after_trading_end(context):
|
|
||||||
log.info(f"持仓数:{len(g.grid_info)},监控数:{len(g.monitoring_stocks)}")
|
|
||||||
|
|
||||||
# ==================== 网格类 ====================
|
|
||||||
class GridInfo:
|
|
||||||
def __init__(self, base_price, total_amount, max_layers, interval, profit_target):
|
|
||||||
self.base_price = float(base_price)
|
|
||||||
self.total_amount = float(total_amount)
|
|
||||||
self.max_layers = int(max_layers)
|
|
||||||
self.interval = float(interval)
|
|
||||||
self.profit_target = float(profit_target)
|
|
||||||
self.layer_prices = {i: base_price - i * interval for i in range(self.max_layers)}
|
|
||||||
self.layer_weights = self._calc_weights()
|
|
||||||
self.positions = []
|
|
||||||
|
|
||||||
def _calc_weights(self):
|
|
||||||
weights = {i: 1.0 + 0.05 * i for i in range(self.max_layers)}
|
|
||||||
total = sum(list(weights.values()))
|
|
||||||
return {k: v / total for k, v in weights.items()}
|
|
||||||
|
|
||||||
def get_layer_amount(self, layer):
|
|
||||||
return self.total_amount * self.layer_weights[layer]
|
|
||||||
|
|
||||||
def add_position(self, price, amount, layer):
|
|
||||||
self.positions.append({'price': price, 'amount': amount, 'layer': layer})
|
|
||||||
|
|
||||||
def get_sellable_positions(self, current_price):
|
|
||||||
return [(i, p) for i, p in enumerate(self.positions) if current_price >= p['price'] + self.profit_target]
|
|
||||||
|
|
||||||
def remove_position(self, index):
|
|
||||||
return self.positions.pop(index)
|
|
||||||
|
|
||||||
def should_add_layer(self, current_price):
|
|
||||||
for layer in range(self.max_layers):
|
|
||||||
target = self.layer_prices[layer]
|
|
||||||
diff = abs(current_price - target)
|
|
||||||
|
|
||||||
# 获取该层级的所有持仓
|
|
||||||
layer_positions = [p for p in self.positions if p['layer'] == layer]
|
|
||||||
has_position = len(layer_positions) > 0
|
|
||||||
|
|
||||||
if diff <= 0.1 and not has_position:
|
|
||||||
return layer
|
|
||||||
return None
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
date,cash,stock_value,total_value,positions,total_shares,month
|
||||||
|
2023-05-31,60000.0,0.0,60000.0,0,0,2023-05
|
||||||
|
2023-06-30,60000.0,0.0,60000.0,0,0,2023-06
|
||||||
|
2023-07-31,60000.0,0.0,60000.0,0,0,2023-07
|
||||||
|
2023-08-31,60000.0,0.0,60000.0,0,0,2023-08
|
||||||
|
2023-09-30,60000.0,0.0,60000.0,0,0,2023-09
|
||||||
|
2023-10-31,60000.0,0.0,60000.0,0,0,2023-10
|
||||||
|
2023-11-30,60000.0,0.0,60000.0,0,0,2023-11
|
||||||
|
2023-12-31,60000.0,0.0,60000.0,0,0,2023-12
|
||||||
|
2024-01-31,60000.0,0.0,60000.0,0,0,2024-01
|
||||||
|
2024-02-29,60000.0,0.0,60000.0,0,0,2024-02
|
||||||
|
2024-03-31,60000.0,0.0,60000.0,0,0,2024-03
|
||||||
|
2024-04-30,60000.0,0.0,60000.0,0,0,2024-04
|
||||||
|
2024-05-31,60000.0,0.0,60000.0,0,0,2024-05
|
||||||
|
2024-06-30,60000.0,0.0,60000.0,0,0,2024-06
|
||||||
|
2024-07-31,60000.0,0.0,60000.0,0,0,2024-07
|
||||||
|
2024-08-31,60000.0,0.0,60000.0,0,0,2024-08
|
||||||
|
2024-09-30,60000.0,0.0,60000.0,0,0,2024-09
|
||||||
|
2024-10-31,60000.0,0.0,60000.0,0,0,2024-10
|
||||||
|
2024-11-30,60000.0,0.0,60000.0,0,0,2024-11
|
||||||
|
2024-12-31,60000.0,0.0,60000.0,0,0,2024-12
|
||||||
|
2025-01-31,60000.0,0.0,60000.0,0,0,2025-01
|
||||||
|
2025-02-28,60000.0,0.0,60000.0,0,0,2025-02
|
||||||
|
2025-03-31,60000.0,0.0,60000.0,0,0,2025-03
|
||||||
|
2025-04-30,60000.0,0.0,60000.0,0,0,2025-04
|
||||||
|
2025-05-31,60000.0,0.0,60000.0,0,0,2025-05
|
||||||
|
2025-06-30,60000.0,0.0,60000.0,0,0,2025-06
|
||||||
|
2025-07-31,60000.0,0.0,60000.0,0,0,2025-07
|
||||||
|
2025-08-31,60000.0,0.0,60000.0,0,0,2025-08
|
||||||
|
2025-09-30,60000.0,0.0,60000.0,0,0,2025-09
|
||||||
|
2025-10-31,60000.0,0.0,60000.0,0,0,2025-10
|
||||||
|
2025-11-30,60000.0,0.0,60000.0,0,0,2025-11
|
||||||
|
2025-12-31,60000.0,0.0,60000.0,0,0,2025-12
|
||||||
|
2026-01-31,60000.0,0.0,60000.0,0,0,2026-01
|
||||||
|
2026-02-28,60000.0,0.0,60000.0,0,0,2026-02
|
||||||
|
2026-03-31,60000.0,0.0,60000.0,0,0,2026-03
|
||||||
|
2026-04-30,60000.0,0.0,60000.0,0,0,2026-04
|
||||||
|
2026-05-27,60000.0,0.0,60000.0,0,0,2026-05
|
||||||
|
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
|
@@ -0,0 +1,158 @@
|
|||||||
|
date,cash,stock_value,total_value,positions,year
|
||||||
|
2023-04-28,60000.0,0.0,60000.0,10,2023
|
||||||
|
2023-05-05,58400.0,1618.0,60018.0,10,2023
|
||||||
|
2023-05-12,55400.0,4570.0,59970.0,10,2023
|
||||||
|
2023-05-19,52200.0,7428.0,59628.0,10,2023
|
||||||
|
2023-05-26,45200.0,14102.0,59302.0,10,2023
|
||||||
|
2023-06-02,49800.0,11204.0,61004.0,10,2023
|
||||||
|
2023-06-09,55600.0,5996.0,61596.0,10,2023
|
||||||
|
2023-06-16,55600.0,5988.0,61588.0,10,2023
|
||||||
|
2023-06-23,53800.0,7522.0,61322.0,10,2023
|
||||||
|
2023-06-30,49200.0,11602.0,60802.0,10,2023
|
||||||
|
2023-07-07,51200.0,9542.0,60742.0,10,2023
|
||||||
|
2023-07-14,48400.0,12576.0,60976.0,10,2023
|
||||||
|
2023-07-21,50200.0,10406.0,60606.0,10,2023
|
||||||
|
2023-07-28,50200.0,10268.0,60468.0,10,2023
|
||||||
|
2023-08-04,50200.0,10622.0,60822.0,10,2023
|
||||||
|
2023-08-11,50200.0,10078.0,60278.0,10,2023
|
||||||
|
2023-08-18,47400.0,12614.0,60014.0,10,2023
|
||||||
|
2023-08-25,46200.0,13572.0,59772.0,10,2023
|
||||||
|
2023-09-01,46200.0,14042.0,60242.0,10,2023
|
||||||
|
2023-09-08,46800.0,12942.0,59742.0,10,2023
|
||||||
|
2023-09-15,45200.0,14398.0,59598.0,10,2023
|
||||||
|
2023-09-22,42600.0,17276.0,59876.0,10,2023
|
||||||
|
2023-09-29,42600.0,17430.0,60030.0,10,2023
|
||||||
|
2023-10-06,42600.0,17430.0,60030.0,10,2023
|
||||||
|
2023-10-13,42600.0,16750.0,59350.0,10,2023
|
||||||
|
2023-10-20,41000.0,17366.0,58366.0,10,2023
|
||||||
|
2023-10-27,39600.0,18870.0,58470.0,10,2023
|
||||||
|
2023-11-03,42600.0,17234.0,59834.0,10,2023
|
||||||
|
2023-11-10,45400.0,15998.0,61398.0,10,2023
|
||||||
|
2023-11-17,45400.0,16098.0,61498.0,10,2023
|
||||||
|
2023-11-24,47000.0,14652.0,61652.0,10,2023
|
||||||
|
2023-12-01,45800.0,16434.0,62234.0,10,2023
|
||||||
|
2023-12-08,48800.0,13898.0,62698.0,10,2023
|
||||||
|
2023-12-15,50800.0,12312.0,63112.0,10,2023
|
||||||
|
2023-12-22,45400.0,15928.0,61328.0,10,2023
|
||||||
|
2023-12-29,41000.0,20256.0,61256.0,10,2023
|
||||||
|
2024-01-05,42800.0,17966.0,60766.0,10,2024
|
||||||
|
2024-01-12,40000.0,20254.0,60254.0,10,2024
|
||||||
|
2024-01-19,37600.0,22196.0,59796.0,10,2024
|
||||||
|
2024-01-26,35000.0,25716.0,60716.0,10,2024
|
||||||
|
2024-02-02,25600.0,31044.0,56644.0,10,2024
|
||||||
|
2024-02-09,19400.0,36784.0,56184.0,10,2024
|
||||||
|
2024-02-16,19400.0,36784.0,56184.0,10,2024
|
||||||
|
2024-02-23,29400.0,33418.0,62818.0,10,2024
|
||||||
|
2024-03-01,35200.0,28470.0,63670.0,10,2024
|
||||||
|
2024-03-08,36400.0,26802.0,63202.0,10,2024
|
||||||
|
2024-03-15,40800.0,23672.0,64472.0,10,2024
|
||||||
|
2024-03-22,48600.0,19494.0,68094.0,10,2024
|
||||||
|
2024-03-29,48800.0,17936.0,66736.0,10,2024
|
||||||
|
2024-04-05,44800.0,21374.0,66174.0,10,2024
|
||||||
|
2024-04-12,42200.0,22450.0,64650.0,10,2024
|
||||||
|
2024-04-19,35000.0,27966.0,62966.0,10,2024
|
||||||
|
2024-04-26,36600.0,28114.0,64714.0,10,2024
|
||||||
|
2024-05-03,39000.0,27576.0,66576.0,10,2024
|
||||||
|
2024-05-10,42200.0,24400.0,66600.0,10,2024
|
||||||
|
2024-05-17,39800.0,26702.0,66502.0,10,2024
|
||||||
|
2024-05-24,38200.0,27704.0,65904.0,10,2024
|
||||||
|
2024-05-31,38400.0,26486.0,64886.0,10,2024
|
||||||
|
2024-06-07,32200.0,30776.0,62976.0,10,2024
|
||||||
|
2024-06-14,32200.0,31802.0,64002.0,10,2024
|
||||||
|
2024-06-21,31000.0,31084.0,62084.0,10,2024
|
||||||
|
2024-06-28,29000.0,32180.0,61180.0,10,2024
|
||||||
|
2024-07-05,29000.0,32292.0,61292.0,10,2024
|
||||||
|
2024-07-12,27200.0,33578.0,60778.0,10,2024
|
||||||
|
2024-07-19,27200.0,33172.0,60372.0,10,2024
|
||||||
|
2024-07-26,24200.0,35970.0,60170.0,10,2024
|
||||||
|
2024-08-02,24200.0,36992.0,61192.0,10,2024
|
||||||
|
2024-08-09,26000.0,34954.0,60954.0,10,2024
|
||||||
|
2024-08-16,26000.0,34780.0,60780.0,10,2024
|
||||||
|
2024-08-23,24400.0,35044.0,59444.0,10,2024
|
||||||
|
2024-08-30,23400.0,37774.0,61174.0,10,2024
|
||||||
|
2024-09-06,23400.0,36744.0,60144.0,10,2024
|
||||||
|
2024-09-13,23400.0,36430.0,59830.0,10,2024
|
||||||
|
2024-09-20,25200.0,35478.0,60678.0,10,2024
|
||||||
|
2024-09-27,30200.0,35280.0,65480.0,10,2024
|
||||||
|
2024-10-04,37000.0,32120.0,69120.0,10,2024
|
||||||
|
2024-10-11,40600.0,26824.0,67424.0,10,2024
|
||||||
|
2024-10-18,40600.0,28430.0,69030.0,10,2024
|
||||||
|
2024-10-25,43600.0,27342.0,70942.0,10,2024
|
||||||
|
2024-11-01,49600.0,23190.0,72790.0,10,2024
|
||||||
|
2024-11-08,53200.0,21184.0,74384.0,10,2024
|
||||||
|
2024-11-15,56000.0,19820.0,75820.0,10,2024
|
||||||
|
2024-11-22,59400.0,16790.0,76190.0,10,2024
|
||||||
|
2024-11-29,63600.0,14610.0,78210.0,10,2024
|
||||||
|
2024-12-06,63800.0,15148.0,78948.0,10,2024
|
||||||
|
2024-12-13,70600.0,9146.0,79746.0,10,2024
|
||||||
|
2024-12-20,67800.0,11830.0,79630.0,10,2024
|
||||||
|
2024-12-27,63400.0,14588.0,77988.0,10,2024
|
||||||
|
2025-01-03,55800.0,20000.0,75800.0,10,2025
|
||||||
|
2025-01-10,54400.0,21344.0,75744.0,10,2025
|
||||||
|
2025-01-17,57400.0,20314.0,77714.0,10,2025
|
||||||
|
2025-01-24,57400.0,20416.0,77816.0,10,2025
|
||||||
|
2025-01-31,57400.0,20178.0,77578.0,10,2025
|
||||||
|
2025-02-07,57400.0,21214.0,78614.0,10,2025
|
||||||
|
2025-02-14,63600.0,16530.0,80130.0,10,2025
|
||||||
|
2025-02-21,65000.0,14804.0,79804.0,10,2025
|
||||||
|
2025-02-28,62400.0,16588.0,78988.0,10,2025
|
||||||
|
2025-03-07,62400.0,16784.0,79184.0,10,2025
|
||||||
|
2025-03-14,62400.0,17590.0,79990.0,10,2025
|
||||||
|
2025-03-21,62400.0,16742.0,79142.0,10,2025
|
||||||
|
2025-03-28,62400.0,16480.0,78880.0,10,2025
|
||||||
|
2025-04-04,59800.0,18538.0,78338.0,10,2025
|
||||||
|
2025-04-11,55200.0,22474.0,77674.0,10,2025
|
||||||
|
2025-04-18,58600.0,19992.0,78592.0,10,2025
|
||||||
|
2025-04-25,58800.0,20036.0,78836.0,10,2025
|
||||||
|
2025-05-02,57200.0,21798.0,78998.0,10,2025
|
||||||
|
2025-05-09,59000.0,20540.0,79540.0,10,2025
|
||||||
|
2025-05-16,59000.0,20316.0,79316.0,10,2025
|
||||||
|
2025-05-23,60800.0,18556.0,79356.0,10,2025
|
||||||
|
2025-05-30,60800.0,18834.0,79634.0,10,2025
|
||||||
|
2025-06-06,60800.0,19254.0,80054.0,10,2025
|
||||||
|
2025-06-13,62000.0,18528.0,80528.0,10,2025
|
||||||
|
2025-06-20,63200.0,16976.0,80176.0,10,2025
|
||||||
|
2025-06-27,63200.0,17596.0,80796.0,10,2025
|
||||||
|
2025-07-04,63200.0,17758.0,80958.0,10,2025
|
||||||
|
2025-07-11,63200.0,18198.0,81398.0,10,2025
|
||||||
|
2025-07-18,64800.0,16488.0,81288.0,10,2025
|
||||||
|
2025-07-25,64800.0,16934.0,81734.0,10,2025
|
||||||
|
2025-08-01,66800.0,15168.0,81968.0,10,2025
|
||||||
|
2025-08-08,66800.0,14908.0,81708.0,10,2025
|
||||||
|
2025-08-15,66800.0,14766.0,81566.0,10,2025
|
||||||
|
2025-08-22,71200.0,11406.0,82606.0,10,2025
|
||||||
|
2025-08-29,71200.0,11004.0,82204.0,10,2025
|
||||||
|
2025-09-05,68400.0,13804.0,82204.0,10,2025
|
||||||
|
2025-09-12,70200.0,12362.0,82562.0,10,2025
|
||||||
|
2025-09-19,68400.0,13864.0,82264.0,10,2025
|
||||||
|
2025-09-26,67400.0,14434.0,81834.0,10,2025
|
||||||
|
2025-10-03,67400.0,14374.0,81774.0,10,2025
|
||||||
|
2025-10-10,65800.0,15922.0,81722.0,10,2025
|
||||||
|
2025-10-17,65800.0,15226.0,81026.0,10,2025
|
||||||
|
2025-10-24,67800.0,13844.0,81644.0,10,2025
|
||||||
|
2025-10-31,67800.0,13968.0,81768.0,10,2025
|
||||||
|
2025-11-07,67800.0,14122.0,81922.0,10,2025
|
||||||
|
2025-11-14,67800.0,14130.0,81930.0,10,2025
|
||||||
|
2025-11-21,66000.0,15954.0,81954.0,10,2025
|
||||||
|
2025-11-28,66000.0,16312.0,82312.0,10,2025
|
||||||
|
2025-12-05,66000.0,15682.0,81682.0,10,2025
|
||||||
|
2025-12-12,66000.0,15122.0,81122.0,10,2025
|
||||||
|
2025-12-19,66000.0,15268.0,81268.0,10,2025
|
||||||
|
2025-12-26,66000.0,15350.0,81350.0,10,2025
|
||||||
|
2026-01-02,67400.0,14250.0,81650.0,10,2026
|
||||||
|
2026-01-09,69200.0,13998.0,83198.0,10,2026
|
||||||
|
2026-01-16,73400.0,10432.0,83832.0,10,2026
|
||||||
|
2026-01-23,73400.0,10660.0,84060.0,10,2026
|
||||||
|
2026-01-30,72400.0,11408.0,83808.0,10,2026
|
||||||
|
2026-02-06,71000.0,12648.0,83648.0,10,2026
|
||||||
|
2026-02-13,71000.0,12800.0,83800.0,10,2026
|
||||||
|
2026-02-20,71000.0,12800.0,83800.0,10,2026
|
||||||
|
2026-02-27,69800.0,13764.0,83564.0,10,2026
|
||||||
|
2026-03-06,68200.0,14498.0,82698.0,10,2026
|
||||||
|
2026-03-13,68200.0,14434.0,82634.0,10,2026
|
||||||
|
2026-03-20,66600.0,15290.0,81890.0,10,2026
|
||||||
|
2026-03-27,63000.0,18856.0,81856.0,10,2026
|
||||||
|
2026-04-03,62200.0,18540.0,80740.0,10,2026
|
||||||
|
2026-04-10,60600.0,21340.0,81940.0,10,2026
|
||||||
|
2026-04-17,60600.0,21260.0,81860.0,10,2026
|
||||||
|
2026-04-24,62200.0,19876.0,82076.0,10,2026
|
||||||
|
@@ -0,0 +1,727 @@
|
|||||||
|
date,cash,holding_market_value,total_asset
|
||||||
|
2023-05-04,28289.86,33283.08,61572.94
|
||||||
|
2023-05-05,29353.86,32311.96,61665.82
|
||||||
|
2023-05-08,29553.86,32215.16,61769.020000000004
|
||||||
|
2023-05-09,27953.86,32879.84,60833.7
|
||||||
|
2023-05-10,26153.86,35374.479999999996,61528.34
|
||||||
|
2023-05-11,29753.86,33225.380000000005,62979.240000000005
|
||||||
|
2023-05-12,24353.86,37200.04,61553.9
|
||||||
|
2023-05-15,25667.86,35345.6,61013.46
|
||||||
|
2023-05-16,20867.86,38643.44,59511.3
|
||||||
|
2023-05-17,17667.86,42281.4,59949.26
|
||||||
|
2023-05-18,17667.86,43420.1,61087.96
|
||||||
|
2023-05-19,19999.86,40128.84,60128.7
|
||||||
|
2023-05-22,19136.34,40407.96,59544.3
|
||||||
|
2023-05-23,19136.34,39661.66,58798.0
|
||||||
|
2023-05-24,19136.34,39738.92,58875.259999999995
|
||||||
|
2023-05-25,17736.34,40531.479999999996,58267.81999999999
|
||||||
|
2023-05-26,17736.34,41098.4,58834.740000000005
|
||||||
|
2023-05-29,14136.34,44736.28,58872.619999999995
|
||||||
|
2023-05-30,14336.34,45939.42,60275.759999999995
|
||||||
|
2023-05-31,14336.34,45642.6,59978.94
|
||||||
|
2023-06-01,20433.92,41484.86,61918.78
|
||||||
|
2023-06-02,22233.92,40253.44,62487.36
|
||||||
|
2023-06-05,20683.899999999998,42477.36,63161.259999999995
|
||||||
|
2023-06-06,24283.899999999998,37810.38,62094.28
|
||||||
|
2023-06-07,22683.899999999998,40119.560000000005,62803.46000000001
|
||||||
|
2023-06-08,21083.899999999998,40887.58,61971.479999999996
|
||||||
|
2023-06-09,23083.899999999998,39432.4,62516.3
|
||||||
|
2023-06-12,23083.899999999998,39468.780000000006,62552.68000000001
|
||||||
|
2023-06-13,23083.899999999998,40270.58,63354.479999999996
|
||||||
|
2023-06-14,24883.899999999998,38792.08,63675.979999999996
|
||||||
|
2023-06-15,26883.899999999998,36531.32000000001,63415.22
|
||||||
|
2023-06-16,27171.899999999998,36662.04,63833.94
|
||||||
|
2023-06-19,30577.359999999997,33518.72,64096.08
|
||||||
|
2023-06-20,28977.359999999997,35500.700000000004,64478.06
|
||||||
|
2023-06-21,27177.359999999997,35306.82,62484.17999999999
|
||||||
|
2023-06-26,25315.46,35218.7,60534.159999999996
|
||||||
|
2023-06-27,20515.46,40464.58,60980.04
|
||||||
|
2023-06-28,20515.46,39961.2,60476.659999999996
|
||||||
|
2023-06-29,20515.46,40167.38,60682.84
|
||||||
|
2023-06-30,20515.46,40323.66,60839.12
|
||||||
|
2023-07-03,28555.76,32259.06,60814.82
|
||||||
|
2023-07-04,28555.76,32140.9,60696.66
|
||||||
|
2023-07-05,28555.76,31760.359999999997,60316.119999999995
|
||||||
|
2023-07-06,28555.76,32029.4,60585.16
|
||||||
|
2023-07-07,27155.76,33151.0,60306.759999999995
|
||||||
|
2023-07-10,30335.76,30607.5,60943.259999999995
|
||||||
|
2023-07-11,30335.76,29987.239999999998,60323.0
|
||||||
|
2023-07-12,27135.76,32715.800000000003,59851.56
|
||||||
|
2023-07-13,28735.76,32062.980000000003,60798.740000000005
|
||||||
|
2023-07-14,29091.76,31705.800000000003,60797.56
|
||||||
|
2023-07-17,27259.08,33179.8,60438.880000000005
|
||||||
|
2023-07-18,27259.08,33110.299999999996,60369.38
|
||||||
|
2023-07-19,27259.08,33071.82,60330.9
|
||||||
|
2023-07-20,27259.08,32716.16,59975.240000000005
|
||||||
|
2023-07-21,27608.82,32509.1,60117.92
|
||||||
|
2023-07-24,29909.840000000004,30081.24,59991.08
|
||||||
|
2023-07-25,29909.840000000004,30276.039999999997,60185.880000000005
|
||||||
|
2023-07-26,31797.840000000004,27988.0,59785.840000000004
|
||||||
|
2023-07-27,31797.840000000004,27916.859999999997,59714.7
|
||||||
|
2023-07-28,30397.840000000004,29960.780000000002,60358.62000000001
|
||||||
|
2023-07-31,31034.120000000003,29691.679999999997,60725.8
|
||||||
|
2023-08-01,31326.78,29226.62,60553.399999999994
|
||||||
|
2023-08-02,33126.78,27590.860000000004,60717.64
|
||||||
|
2023-08-03,31326.78,29124.66,60451.44
|
||||||
|
2023-08-04,31326.78,29340.219999999998,60667.0
|
||||||
|
2023-08-07,28402.46,32404.219999999998,60806.67999999999
|
||||||
|
2023-08-08,28402.46,32277.72,60680.18
|
||||||
|
2023-08-09,28602.46,31887.659999999996,60490.119999999995
|
||||||
|
2023-08-10,28602.46,32160.539999999997,60763.0
|
||||||
|
2023-08-11,28602.46,31713.8,60316.259999999995
|
||||||
|
2023-08-14,30216.02,30586.119999999995,60802.14
|
||||||
|
2023-08-15,30216.02,30597.48,60813.5
|
||||||
|
2023-08-16,32216.02,28753.199999999997,60969.22
|
||||||
|
2023-08-17,30416.02,31062.44,61478.46
|
||||||
|
2023-08-18,33136.700000000004,28257.34,61394.04000000001
|
||||||
|
2023-08-21,31336.700000000004,30055.36,61392.060000000005
|
||||||
|
2023-08-22,29536.700000000004,32326.899999999998,61863.600000000006
|
||||||
|
2023-08-23,29536.700000000004,31548.5,61085.200000000004
|
||||||
|
2023-08-24,29536.700000000004,31743.98,61280.68000000001
|
||||||
|
2023-08-25,26136.700000000004,34300.62,60437.32000000001
|
||||||
|
2023-08-28,29708.780000000006,31279.519999999997,60988.3
|
||||||
|
2023-08-29,30196.780000000006,32665.699999999997,62862.48
|
||||||
|
2023-08-30,32196.780000000006,31247.879999999997,63444.66
|
||||||
|
2023-08-31,34196.780000000006,29057.66,63254.44
|
||||||
|
2023-09-01,33946.780000000006,29311.820000000003,63258.600000000006
|
||||||
|
2023-09-04,34135.520000000004,29560.14,63695.66
|
||||||
|
2023-09-05,34509.780000000006,29185.22,63695.00000000001
|
||||||
|
2023-09-06,32709.780000000006,30561.239999999998,63271.020000000004
|
||||||
|
2023-09-07,33082.920000000006,30092.64,63175.560000000005
|
||||||
|
2023-09-08,29682.920000000006,33243.98,62926.90000000001
|
||||||
|
2023-09-11,28129.100000000006,35377.44,63506.54000000001
|
||||||
|
2023-09-12,30129.100000000006,33934.32,64063.420000000006
|
||||||
|
2023-09-13,31929.100000000006,32089.72,64018.82000000001
|
||||||
|
2023-09-14,31929.100000000006,31721.36,63650.46000000001
|
||||||
|
2023-09-15,33929.100000000006,29926.239999999998,63855.340000000004
|
||||||
|
2023-09-18,35984.3,28165.4,64149.700000000004
|
||||||
|
2023-09-19,36370.3,27593.84,63964.14
|
||||||
|
2023-09-20,36917.340000000004,27446.239999999998,64363.58
|
||||||
|
2023-09-21,35117.340000000004,29112.16,64229.5
|
||||||
|
2023-09-22,35117.340000000004,29932.44,65049.78
|
||||||
|
2023-09-25,35673.340000000004,30188.22,65861.56
|
||||||
|
2023-09-26,33873.340000000004,31843.26,65716.6
|
||||||
|
2023-09-27,33873.340000000004,32595.0,66468.34
|
||||||
|
2023-09-28,39873.340000000004,27709.4,67582.74
|
||||||
|
2023-10-09,41247.340000000004,26008.479999999996,67255.82
|
||||||
|
2023-10-10,43605.08,24212.319999999996,67817.4
|
||||||
|
2023-10-11,43805.08,23993.4,67798.48000000001
|
||||||
|
2023-10-12,44083.08,23915.480000000003,67998.56
|
||||||
|
2023-10-13,44083.08,23683.52,67766.6
|
||||||
|
2023-10-16,43689.08,23793.599999999995,67482.68
|
||||||
|
2023-10-17,42089.08,25216.94,67306.02
|
||||||
|
2023-10-18,38489.08,28344.4,66833.48000000001
|
||||||
|
2023-10-19,36689.08,29789.940000000002,66479.02
|
||||||
|
2023-10-20,33289.08,32655.699999999997,65944.78
|
||||||
|
2023-10-23,31442.520000000004,33523.66,64966.18000000001
|
||||||
|
2023-10-24,29642.520000000004,36040.58,65683.1
|
||||||
|
2023-10-25,33642.520000000004,33017.58,66660.1
|
||||||
|
2023-10-26,34288.520000000004,33149.32,67437.84
|
||||||
|
2023-10-27,36088.520000000004,31940.920000000002,68029.44
|
||||||
|
2023-10-30,37285.62,32011.34,69296.96
|
||||||
|
2023-10-31,39938.9,29558.3,69497.2
|
||||||
|
2023-11-01,38429.700000000004,30972.719999999994,69402.42
|
||||||
|
2023-11-02,40429.700000000004,28891.5,69321.20000000001
|
||||||
|
2023-11-03,40429.700000000004,29377.8,69807.5
|
||||||
|
2023-11-06,41176.26,29848.48,71024.74
|
||||||
|
2023-11-07,45432.26,26553.34,71985.6
|
||||||
|
2023-11-08,45782.26,26698.7,72480.96
|
||||||
|
2023-11-09,48079.740000000005,24910.94,72990.68000000001
|
||||||
|
2023-11-10,46479.740000000005,25900.26,72380.0
|
||||||
|
2023-11-13,48501.3,24292.92,72794.22
|
||||||
|
2023-11-14,46701.3,26683.64,73384.94
|
||||||
|
2023-11-15,46957.340000000004,26097.679999999997,73055.02
|
||||||
|
2023-11-16,47157.340000000004,26279.88,73437.22
|
||||||
|
2023-11-17,49341.340000000004,24523.559999999998,73864.9
|
||||||
|
2023-11-20,51584.740000000005,23124.5,74709.24
|
||||||
|
2023-11-21,51940.740000000005,22778.399999999998,74719.14
|
||||||
|
2023-11-22,49054.740000000005,26431.32,75486.06
|
||||||
|
2023-11-23,50182.740000000005,25830.2,76012.94
|
||||||
|
2023-11-24,49700.740000000005,27047.760000000002,76748.5
|
||||||
|
2023-11-27,52958.340000000004,24999.68,77958.02
|
||||||
|
2023-11-28,46244.340000000004,30826.699999999997,77071.04000000001
|
||||||
|
2023-11-29,41844.340000000004,33468.94,75313.28
|
||||||
|
2023-11-30,37244.340000000004,37343.26,74587.6
|
||||||
|
2023-12-01,33892.340000000004,39456.38,73348.72
|
||||||
|
2023-12-04,29451.340000000004,43211.36,72662.70000000001
|
||||||
|
2023-12-05,36811.340000000004,40589.1,77400.44
|
||||||
|
2023-12-06,43811.340000000004,34448.659999999996,78260.0
|
||||||
|
2023-12-07,49721.340000000004,30807.46,80528.8
|
||||||
|
2023-12-08,50831.340000000004,30194.3,81025.64
|
||||||
|
2023-12-11,41721.94,37976.44,79698.38
|
||||||
|
2023-12-12,41967.94,38844.28,80812.22
|
||||||
|
2023-12-13,41967.94,40205.56,82173.5
|
||||||
|
2023-12-14,48057.94,35801.66,83859.6
|
||||||
|
2023-12-15,52387.94,31884.0,84271.94
|
||||||
|
2023-12-18,54779.94,31022.0,85801.94
|
||||||
|
2023-12-19,56379.94,28964.0,85343.94
|
||||||
|
2023-12-20,58379.94,28260.0,86639.94
|
||||||
|
2023-12-21,60909.94,24672.0,85581.94
|
||||||
|
2023-12-22,52909.94,32946.0,85855.94
|
||||||
|
2023-12-25,54909.94,32782.0,87691.94
|
||||||
|
2023-12-26,54909.94,33254.0,88163.94
|
||||||
|
2023-12-27,57197.94,31222.0,88419.94
|
||||||
|
2023-12-28,55761.94,33084.0,88845.94
|
||||||
|
2023-12-29,56387.94,33956.0,90343.94
|
||||||
|
2024-01-02,62813.94,29938.0,92751.94
|
||||||
|
2024-01-03,65205.94,27608.0,92813.94
|
||||||
|
2024-01-04,63405.94,29272.0,92677.94
|
||||||
|
2024-01-05,56405.94,34602.0,91007.94
|
||||||
|
2024-01-08,58405.94,31830.0,90235.94
|
||||||
|
2024-01-09,48405.94,40204.0,88609.94
|
||||||
|
2024-01-10,43605.94,45832.0,89437.94
|
||||||
|
2024-01-11,45405.94,44096.0,89501.94
|
||||||
|
2024-01-12,42405.94,42438.0,84843.94
|
||||||
|
2024-01-15,35605.94,46922.0,82527.94
|
||||||
|
2024-01-16,34005.94,52684.0,86689.94
|
||||||
|
2024-01-17,37205.94,49538.0,86743.94
|
||||||
|
2024-01-18,39005.94,49904.0,88909.94
|
||||||
|
2024-01-19,44405.94,46458.0,90863.94
|
||||||
|
2024-01-22,45355.94,42936.0,88291.94
|
||||||
|
2024-01-23,45355.94,44114.0,89469.94
|
||||||
|
2024-01-24,47895.94,42334.0,90229.94
|
||||||
|
2024-01-25,46695.94,42518.0,89213.94
|
||||||
|
2024-01-26,43543.94,44782.0,88325.94
|
||||||
|
2024-01-29,39113.94,44472.0,83585.94
|
||||||
|
2024-01-30,33513.94,50754.0,84267.94
|
||||||
|
2024-01-31,40113.94,46114.0,86227.94
|
||||||
|
2024-02-01,38705.94,44274.0,82979.94
|
||||||
|
2024-02-02,27305.940000000002,52690.0,79995.94
|
||||||
|
2024-02-05,22445.940000000002,55230.0,77675.94
|
||||||
|
2024-02-06,27245.940000000002,56198.0,83443.94
|
||||||
|
2024-02-07,36845.94,46666.0,83511.94
|
||||||
|
2024-02-08,31419.940000000002,51926.0,83345.94
|
||||||
|
2024-02-19,36311.94,48834.0,85145.94
|
||||||
|
2024-02-20,39861.28,47910.66,87771.94
|
||||||
|
2024-02-21,39861.28,48018.96,87880.23999999999
|
||||||
|
2024-02-22,41861.28,45818.24,87679.51999999999
|
||||||
|
2024-02-23,41861.28,44551.96,86413.23999999999
|
||||||
|
2024-02-26,45030.24,41690.74,86720.98
|
||||||
|
2024-02-27,48777.32,41789.439999999995,90566.76
|
||||||
|
2024-02-28,49161.3,39334.85999999999,88496.16
|
||||||
|
2024-02-29,45761.3,44599.96,90361.26000000001
|
||||||
|
2024-03-01,49575.3,40919.28,90494.58
|
||||||
|
2024-03-04,52677.94,37579.03999999999,90256.98
|
||||||
|
2024-03-05,51277.94,38087.04,89364.98000000001
|
||||||
|
2024-03-06,53277.94,36919.08,90197.02
|
||||||
|
2024-03-07,53677.94,35949.98,89627.92000000001
|
||||||
|
2024-03-08,53677.94,35737.8,89415.74
|
||||||
|
2024-03-11,57519.94,32557.06,90077.0
|
||||||
|
2024-03-12,59847.920000000006,30947.68,90795.6
|
||||||
|
2024-03-13,59847.920000000006,30429.56,90277.48000000001
|
||||||
|
2024-03-14,60047.920000000006,30268.980000000003,90316.90000000001
|
||||||
|
2024-03-15,60047.920000000006,31026.780000000002,91074.70000000001
|
||||||
|
2024-03-18,62559.920000000006,29269.68,91829.6
|
||||||
|
2024-03-19,62905.560000000005,29223.520000000004,92129.08000000002
|
||||||
|
2024-03-20,65165.560000000005,27477.68,92643.24
|
||||||
|
2024-03-21,65873.84,26703.800000000003,92577.64
|
||||||
|
2024-03-22,65873.84,26076.62,91950.45999999999
|
||||||
|
2024-03-25,61065.36,29863.06,90928.42
|
||||||
|
2024-03-26,59265.36,31568.499999999996,90833.86
|
||||||
|
2024-03-27,55865.36,34504.659999999996,90370.01999999999
|
||||||
|
2024-03-28,60401.36,30693.719999999998,91095.08
|
||||||
|
2024-03-29,60401.36,30781.6,91182.95999999999
|
||||||
|
2024-04-01,66053.02,26153.920000000002,92206.94
|
||||||
|
2024-04-02,66617.02,25795.5,92412.52
|
||||||
|
2024-04-03,65179.58,26641.44,91821.02
|
||||||
|
2024-04-08,63035.58,27350.300000000003,90385.88
|
||||||
|
2024-04-09,61435.58,29304.18,90739.76000000001
|
||||||
|
2024-04-10,56635.58,33710.5,90346.08
|
||||||
|
2024-04-11,56903.58,32962.08,89865.66
|
||||||
|
2024-04-12,56903.58,32507.18,89410.76000000001
|
||||||
|
2024-04-15,45899.58,40168.12,86067.70000000001
|
||||||
|
2024-04-16,36699.58,44627.16,81326.74
|
||||||
|
2024-04-17,33499.58,52978.979999999996,86478.56
|
||||||
|
2024-04-18,33499.58,53139.6,86639.18
|
||||||
|
2024-04-19,33499.58,52126.22,85625.8
|
||||||
|
2024-04-22,35961.58,49286.22,85247.8
|
||||||
|
2024-04-23,39761.58,47887.979999999996,87649.56
|
||||||
|
2024-04-24,41361.58,47785.3,89146.88
|
||||||
|
2024-04-25,41161.58,47469.36,88630.94
|
||||||
|
2024-04-26,42761.58,46693.66,89455.24
|
||||||
|
2024-04-29,46142.72,44846.68,90989.4
|
||||||
|
2024-04-30,51342.72,39697.380000000005,91040.1
|
||||||
|
2024-05-06,59221.060000000005,32499.46,91720.52
|
||||||
|
2024-05-07,59421.060000000005,32774.7,92195.76000000001
|
||||||
|
2024-05-08,59805.060000000005,31778.78,91583.84
|
||||||
|
2024-05-09,58005.060000000005,33495.740000000005,91500.80000000002
|
||||||
|
2024-05-10,56205.060000000005,34632.66,90837.72
|
||||||
|
2024-05-13,54405.060000000005,35785.520000000004,90190.58000000002
|
||||||
|
2024-05-14,56405.060000000005,34677.5,91082.56
|
||||||
|
2024-05-15,56791.060000000005,34668.46,91459.52
|
||||||
|
2024-05-16,58591.060000000005,33183.68,91774.74
|
||||||
|
2024-05-17,60869.060000000005,31647.16,92516.22
|
||||||
|
2024-05-20,60891.060000000005,31465.64,92356.70000000001
|
||||||
|
2024-05-21,61091.060000000005,31509.34,92600.40000000001
|
||||||
|
2024-05-22,61405.060000000005,31812.32,93217.38
|
||||||
|
2024-05-23,61405.060000000005,32031.88,93436.94
|
||||||
|
2024-05-24,59605.060000000005,32880.44,92485.5
|
||||||
|
2024-05-27,59387.50000000001,33578.0,92965.5
|
||||||
|
2024-05-28,63387.50000000001,29672.0,93059.5
|
||||||
|
2024-05-29,61787.50000000001,31334.0,93121.5
|
||||||
|
2024-05-30,64141.5,28916.0,93057.5
|
||||||
|
2024-05-31,64141.5,29130.0,93271.5
|
||||||
|
2024-06-03,59560.399999999994,32518.96,92079.35999999999
|
||||||
|
2024-06-04,57760.399999999994,33912.6,91673.0
|
||||||
|
2024-06-05,56160.399999999994,34984.96,91145.35999999999
|
||||||
|
2024-06-06,47960.399999999994,40566.12,88526.51999999999
|
||||||
|
2024-06-07,47960.399999999994,42130.56,90090.95999999999
|
||||||
|
2024-06-11,50552.399999999994,40529.64,91082.04
|
||||||
|
2024-06-12,54152.399999999994,37975.64,92128.04
|
||||||
|
2024-06-13,56152.399999999994,36528.12,92680.51999999999
|
||||||
|
2024-06-14,56152.399999999994,36035.36,92187.76
|
||||||
|
2024-06-17,58145.759999999995,33718.0,91863.76
|
||||||
|
2024-06-18,60345.759999999995,32524.0,92869.76
|
||||||
|
2024-06-19,64277.759999999995,29616.0,93893.76
|
||||||
|
2024-06-20,59921.759999999995,33686.0,93607.76
|
||||||
|
2024-06-21,62239.759999999995,31984.0,94223.76
|
||||||
|
2024-06-24,56927.759999999995,35350.0,92277.76
|
||||||
|
2024-06-25,53927.759999999995,37340.0,91267.76
|
||||||
|
2024-06-26,52527.759999999995,40366.0,92893.76
|
||||||
|
2024-06-27,52527.759999999995,39326.0,91853.76
|
||||||
|
2024-06-28,52527.759999999995,40070.0,92597.76
|
||||||
|
2024-07-01,57551.759999999995,35500.0,93051.76
|
||||||
|
2024-07-02,57551.759999999995,36244.0,93795.76
|
||||||
|
2024-07-03,57551.759999999995,36146.0,93697.76
|
||||||
|
2024-07-04,55751.759999999995,36268.0,92019.76
|
||||||
|
2024-07-05,52351.759999999995,40466.0,92817.76
|
||||||
|
2024-07-08,53531.759999999995,38242.0,91773.76
|
||||||
|
2024-07-09,52891.759999999995,40390.0,93281.76
|
||||||
|
2024-07-10,51451.719999999994,41648.04,93099.76
|
||||||
|
2024-07-11,51451.719999999994,42945.86,94397.57999999999
|
||||||
|
2024-07-12,51451.719999999994,42662.64,94114.35999999999
|
||||||
|
2024-07-15,52778.87999999999,40392.92,93171.79999999999
|
||||||
|
2024-07-16,52778.87999999999,41218.92,93997.79999999999
|
||||||
|
2024-07-17,57090.87999999999,36641.42,93732.29999999999
|
||||||
|
2024-07-18,55290.87999999999,38284.96,93575.84
|
||||||
|
2024-07-19,55290.87999999999,38665.44,93956.31999999999
|
||||||
|
2024-07-22,60996.87999999999,34089.5,95086.37999999999
|
||||||
|
2024-07-23,60996.87999999999,33558.479999999996,94555.35999999999
|
||||||
|
2024-07-24,62796.87999999999,31740.760000000002,94537.63999999998
|
||||||
|
2024-07-25,61256.87999999999,33841.24,95098.12
|
||||||
|
2024-07-26,61456.87999999999,33980.7,95437.57999999999
|
||||||
|
2024-07-29,59777.73999999999,35923.119999999995,95700.85999999999
|
||||||
|
2024-07-30,61777.73999999999,34529.72,96307.45999999999
|
||||||
|
2024-07-31,64101.73999999999,33334.0,97435.73999999999
|
||||||
|
2024-08-01,65125.73999999999,32018.0,97143.73999999999
|
||||||
|
2024-08-02,61925.73999999999,34468.0,96393.73999999999
|
||||||
|
2024-08-05,62605.73999999999,32892.0,95497.73999999999
|
||||||
|
2024-08-06,62605.73999999999,34194.0,96799.73999999999
|
||||||
|
2024-08-07,64405.73999999999,32874.0,97279.73999999999
|
||||||
|
2024-08-08,64605.73999999999,31938.0,96543.73999999999
|
||||||
|
2024-08-09,63005.73999999999,33154.0,96159.73999999999
|
||||||
|
2024-08-12,61315.73999999999,34852.0,96167.73999999999
|
||||||
|
2024-08-13,59515.73999999999,36410.0,95925.73999999999
|
||||||
|
2024-08-14,59515.73999999999,35968.0,95483.73999999999
|
||||||
|
2024-08-15,58515.73999999999,37140.0,95655.73999999999
|
||||||
|
2024-08-16,56715.73999999999,39058.0,95773.73999999999
|
||||||
|
2024-08-19,56764.19999999999,38072.64,94836.84
|
||||||
|
2024-08-20,56764.19999999999,38040.52,94804.71999999999
|
||||||
|
2024-08-21,55164.19999999999,38808.92,93973.12
|
||||||
|
2024-08-22,53564.19999999999,39192.04,92756.23999999999
|
||||||
|
2024-08-23,55364.19999999999,37672.44,93036.63999999998
|
||||||
|
2024-08-26,56940.25999999999,36384.899999999994,93325.15999999997
|
||||||
|
2024-08-27,57540.25999999999,35327.28,92867.53999999998
|
||||||
|
2024-08-28,52940.25999999999,39837.560000000005,92777.81999999999
|
||||||
|
2024-08-29,51892.25999999999,41458.62,93350.87999999999
|
||||||
|
2024-08-30,52183.51999999999,42771.06,94954.57999999999
|
||||||
|
2024-09-02,56710.33999999999,37626.06,94336.4
|
||||||
|
2024-09-03,58710.33999999999,36281.9,94992.23999999999
|
||||||
|
2024-09-04,58710.33999999999,35629.16,94339.5
|
||||||
|
2024-09-05,58710.33999999999,36421.92,95132.25999999998
|
||||||
|
2024-09-06,58710.33999999999,35878.58,94588.91999999998
|
||||||
|
2024-09-09,58906.33999999999,35322.54000000001,94228.88
|
||||||
|
2024-09-10,57306.33999999999,37236.6,94542.93999999999
|
||||||
|
2024-09-11,57306.33999999999,36978.36,94284.69999999998
|
||||||
|
2024-09-12,62642.33999999999,31378.359999999997,94020.69999999998
|
||||||
|
2024-09-13,62642.33999999999,30741.5,93383.84
|
||||||
|
2024-09-18,58008.74,34825.06,92833.79999999999
|
||||||
|
2024-09-19,58359.32,35364.84,93724.16
|
||||||
|
2024-09-20,58359.32,34882.94,93242.26000000001
|
||||||
|
2024-09-23,59825.32,33529.54,93354.86
|
||||||
|
2024-09-24,59825.32,35009.0,94834.32
|
||||||
|
2024-09-25,62217.32,33031.08,95248.4
|
||||||
|
2024-09-26,62723.32000000001,33992.1,96715.42000000001
|
||||||
|
2024-09-27,69235.32,29121.08,98356.40000000001
|
||||||
|
2024-09-30,80349.32,21369.96,101719.28
|
||||||
|
2024-10-08,85077.28,19476.0,104553.28
|
||||||
|
2024-10-09,77483.28,25692.0,103175.28
|
||||||
|
2024-10-10,65483.28,35852.0,101335.28
|
||||||
|
2024-10-11,56083.28,43112.0,99195.28
|
||||||
|
2024-10-14,54845.28,47550.0,102395.28
|
||||||
|
2024-10-15,62245.28,40032.0,102277.28
|
||||||
|
2024-10-16,64429.28,38264.0,102693.28
|
||||||
|
2024-10-17,71101.28,33832.0,104933.28
|
||||||
|
2024-10-18,71101.28,35670.0,106771.28
|
||||||
|
2024-10-21,81899.28,27310.0,109209.28
|
||||||
|
2024-10-22,78499.28,29890.0,108389.28
|
||||||
|
2024-10-23,79145.28,31210.0,110355.28
|
||||||
|
2024-10-24,83457.28,27304.0,110761.28
|
||||||
|
2024-10-25,87055.28,25324.0,112379.28
|
||||||
|
2024-10-28,82369.28,32322.0,114691.28
|
||||||
|
2024-10-29,94723.28,21330.0,116053.28
|
||||||
|
2024-10-30,93926.66,22730.62,116657.28
|
||||||
|
2024-10-31,92874.66,23630.56,116505.22
|
||||||
|
2024-11-01,90986.66,26929.52,117916.18000000001
|
||||||
|
2024-11-04,88286.66,31429.6,119716.26000000001
|
||||||
|
2024-11-05,99332.66,22827.28,122159.94
|
||||||
|
2024-11-06,100274.82,22557.2,122832.02
|
||||||
|
2024-11-07,97836.82,26869.02,124705.84000000001
|
||||||
|
2024-11-08,100636.82,23667.66,124304.48000000001
|
||||||
|
2024-11-11,97436.82,27560.78,124997.6
|
||||||
|
2024-11-12,97436.82,27386.06,124822.88
|
||||||
|
2024-11-13,95636.82,29058.3,124695.12000000001
|
||||||
|
2024-11-14,97636.82,26820.22,124457.04000000001
|
||||||
|
2024-11-15,96557.20000000001,28499.719999999998,125056.92000000001
|
||||||
|
2024-11-18,99403.46,26804.239999999998,126207.70000000001
|
||||||
|
2024-11-19,87403.46,38321.020000000004,125724.48000000001
|
||||||
|
2024-11-20,91665.46,36476.68,128142.14000000001
|
||||||
|
2024-11-21,97525.46,31670.04,129195.5
|
||||||
|
2024-11-22,102164.48000000001,26932.02,129096.50000000001
|
||||||
|
2024-11-25,100326.48000000001,29415.52,129742.00000000001
|
||||||
|
2024-11-26,98726.48000000001,30748.68,129475.16
|
||||||
|
2024-11-27,97126.48000000001,32619.879999999997,129746.36000000002
|
||||||
|
2024-11-28,101950.1,29081.36,131031.46
|
||||||
|
2024-11-29,101950.1,29600.280000000002,131550.38
|
||||||
|
2024-12-02,108792.78,23990.58,132783.36
|
||||||
|
2024-12-03,105192.78,27452.98,132645.76
|
||||||
|
2024-12-04,103392.78,27856.059999999998,131248.84
|
||||||
|
2024-12-05,103392.78,28402.48,131795.26
|
||||||
|
2024-12-06,103392.78,28411.32,131804.1
|
||||||
|
2024-12-09,97964.12,32072.58,130036.7
|
||||||
|
2024-12-10,96164.12,34057.399999999994,130221.51999999999
|
||||||
|
2024-12-11,94564.12,35279.64,129843.76
|
||||||
|
2024-12-12,94844.12,35000.0,129844.12
|
||||||
|
2024-12-13,96844.12,33198.0,130042.12
|
||||||
|
2024-12-16,92244.12,37306.0,129550.12
|
||||||
|
2024-12-17,90444.12,36824.0,127268.12
|
||||||
|
2024-12-18,85644.12,41096.0,126740.12
|
||||||
|
2024-12-19,82444.12,45412.0,127856.12
|
||||||
|
2024-12-20,82444.12,44764.0,127208.12
|
||||||
|
2024-12-23,86146.12,38902.0,125048.12
|
||||||
|
2024-12-24,88234.12,37016.0,125250.12
|
||||||
|
2024-12-25,83634.12,39390.0,123024.12
|
||||||
|
2024-12-26,85918.12,38396.0,124314.12
|
||||||
|
2024-12-27,89318.12,35982.0,125300.12
|
||||||
|
2024-12-30,84450.12,39132.0,123582.12
|
||||||
|
2024-12-31,86250.12,38410.0,124660.12
|
||||||
|
2025-01-02,85608.12,39822.0,125430.12
|
||||||
|
2025-01-03,87408.12,37578.0,124986.12
|
||||||
|
2025-01-06,87180.12,36526.0,123706.12
|
||||||
|
2025-01-07,85580.12,39196.0,124776.12
|
||||||
|
2025-01-08,85580.12,39276.0,124856.12
|
||||||
|
2025-01-09,85580.12,40108.0,125688.12
|
||||||
|
2025-01-10,85580.12,38096.0,123676.12
|
||||||
|
2025-01-13,86472.12,36632.0,123104.12
|
||||||
|
2025-01-14,90534.12,35542.0,126076.12
|
||||||
|
2025-01-15,92964.12,33396.0,126360.12
|
||||||
|
2025-01-16,92964.12,33474.0,126438.12
|
||||||
|
2025-01-17,92964.12,32718.0,125682.12
|
||||||
|
2025-01-20,92444.12,33334.0,125778.12
|
||||||
|
2025-01-21,90644.12,34562.0,125206.12
|
||||||
|
2025-01-22,88844.12,35200.0,124044.12
|
||||||
|
2025-01-23,92964.12,31104.0,124068.12
|
||||||
|
2025-01-24,92964.12,30942.0,123906.12
|
||||||
|
2025-01-27,93264.12,29920.0,123184.12
|
||||||
|
2025-02-05,95550.12,28902.0,124452.12
|
||||||
|
2025-02-06,95386.12,30334.0,125720.12
|
||||||
|
2025-02-07,99682.12,27098.0,126780.12
|
||||||
|
2025-02-10,101682.12,26282.0,127964.12
|
||||||
|
2025-02-11,102255.36,25700.760000000002,127956.12
|
||||||
|
2025-02-12,106503.36,22042.74,128546.1
|
||||||
|
2025-02-13,106503.36,21696.8,128200.16
|
||||||
|
2025-02-14,106503.36,21418.78,127922.14
|
||||||
|
2025-02-17,106503.36,22392.739999999998,128896.1
|
||||||
|
2025-02-18,106863.36,21594.6,128457.95999999999
|
||||||
|
2025-02-19,107549.72,21399.64,128949.36
|
||||||
|
2025-02-20,105749.72,23389.28,129139.0
|
||||||
|
2025-02-21,105749.72,23782.4,129532.12
|
||||||
|
2025-02-24,107749.72,22060.94,129810.66
|
||||||
|
2025-02-25,106050.72,23940.18,129990.9
|
||||||
|
2025-02-26,106050.72,24078.079999999998,130128.8
|
||||||
|
2025-02-27,104528.72,25574.48,130103.2
|
||||||
|
2025-02-28,102728.72,26542.96,129271.68
|
||||||
|
2025-03-03,99240.72,30762.88,130003.6
|
||||||
|
2025-03-04,104273.36,27485.84,131759.2
|
||||||
|
2025-03-05,106519.36,25571.420000000002,132090.78
|
||||||
|
2025-03-06,103171.36,29194.32,132365.68
|
||||||
|
2025-03-07,105425.36,27040.4,132465.76
|
||||||
|
2025-03-10,105425.36,27684.64,133110.0
|
||||||
|
2025-03-11,105757.36,27476.72,133234.08000000002
|
||||||
|
2025-03-12,107757.36,26166.48,133923.84
|
||||||
|
2025-03-13,110301.82,23848.38,134150.2
|
||||||
|
2025-03-14,115013.82,19782.36,134796.18
|
||||||
|
2025-03-17,115373.82,19713.06,135086.88
|
||||||
|
2025-03-18,116287.82,19132.559999999998,135420.38
|
||||||
|
2025-03-19,112687.82,22446.72,135134.54
|
||||||
|
2025-03-20,111223.82,24020.72,135244.54
|
||||||
|
2025-03-21,109423.82,25211.120000000003,134634.94
|
||||||
|
2025-03-24,102104.94,32562.0,134666.94
|
||||||
|
2025-03-25,104460.1,30420.84,134880.94
|
||||||
|
2025-03-26,106460.1,29221.84,135681.94
|
||||||
|
2025-03-27,106660.1,28888.8,135548.9
|
||||||
|
2025-03-28,103822.1,31484.3,135306.4
|
||||||
|
2025-03-31,100434.62000000001,34067.08,134501.7
|
||||||
|
2025-04-01,101400.62000000001,32980.64,134381.26
|
||||||
|
2025-04-02,101600.62000000001,32563.239999999998,134163.86000000002
|
||||||
|
2025-04-03,99800.62000000001,34518.32,134318.94
|
||||||
|
2025-04-07,87000.62000000001,41837.2,128837.82
|
||||||
|
2025-04-08,80800.62000000001,49853.04,130653.66
|
||||||
|
2025-04-09,88600.62000000001,46010.08,134610.7
|
||||||
|
2025-04-10,98000.62000000001,38655.6,136656.22
|
||||||
|
2025-04-11,99600.62000000001,37063.76,136664.38
|
||||||
|
2025-04-14,106254.62000000001,31782.72,138037.34000000003
|
||||||
|
2025-04-15,104654.62000000001,33317.28,137971.90000000002
|
||||||
|
2025-04-16,106747.54000000001,31677.72,138425.26
|
||||||
|
2025-04-17,108747.54000000001,30180.46,138928.0
|
||||||
|
2025-04-18,109341.54000000001,30561.36,139902.90000000002
|
||||||
|
2025-04-21,107815.54000000001,32425.2,140240.74000000002
|
||||||
|
2025-04-22,107815.54000000001,31336.8,139152.34
|
||||||
|
2025-04-23,109415.54000000001,30456.64,139872.18
|
||||||
|
2025-04-24,109657.54000000001,29465.66,139123.2
|
||||||
|
2025-04-25,106835.54000000001,32281.18,139116.72
|
||||||
|
2025-04-28,106345.54000000001,32082.7,138428.24000000002
|
||||||
|
2025-04-29,102745.54000000001,35483.0,138228.54
|
||||||
|
2025-04-30,102745.54000000001,35759.3,138504.84000000003
|
||||||
|
2025-05-06,104510.84000000001,35040.0,139550.84000000003
|
||||||
|
2025-05-07,107283.84000000001,32649.0,139932.84000000003
|
||||||
|
2025-05-08,104437.84000000001,35792.24,140230.08000000002
|
||||||
|
2025-05-09,102637.84000000001,37585.88,140223.72
|
||||||
|
2025-05-12,103367.84000000001,37894.72,141262.56
|
||||||
|
2025-05-13,103367.84000000001,37302.54,140670.38
|
||||||
|
2025-05-14,103763.84000000001,36662.0,140425.84000000003
|
||||||
|
2025-05-15,101963.84000000001,38680.0,140643.84000000003
|
||||||
|
2025-05-16,101963.84000000001,38900.0,140863.84000000003
|
||||||
|
2025-05-19,101963.84000000001,39840.0,141803.84000000003
|
||||||
|
2025-05-20,105763.84000000001,36696.0,142459.84000000003
|
||||||
|
2025-05-21,107363.84000000001,35314.0,142677.84000000003
|
||||||
|
2025-05-22,109423.84000000001,33250.0,142673.84000000003
|
||||||
|
2025-05-23,109423.84000000001,32758.0,142181.84000000003
|
||||||
|
2025-05-26,110715.84000000001,32416.0,143131.84000000003
|
||||||
|
2025-05-27,114765.84000000001,28756.0,143521.84000000003
|
||||||
|
2025-05-28,118565.84000000001,24386.0,142951.84000000003
|
||||||
|
2025-05-29,118565.84000000001,24558.0,143123.84000000003
|
||||||
|
2025-05-30,115165.84000000001,27606.0,142771.84000000003
|
||||||
|
2025-06-03,113691.88,29479.86,143171.74
|
||||||
|
2025-06-04,117691.88,26375.22,144067.1
|
||||||
|
2025-06-05,118367.88,25461.82,143829.7
|
||||||
|
2025-06-06,118567.88,25523.98,144091.86000000002
|
||||||
|
2025-06-09,118468.06,26145.58,144613.64
|
||||||
|
2025-06-10,119242.06,26013.68,145255.74
|
||||||
|
2025-06-11,115642.06,29543.460000000003,145185.52
|
||||||
|
2025-06-12,119442.06,26731.8,146173.86
|
||||||
|
2025-06-13,121827.45999999999,23271.14,145098.59999999998
|
||||||
|
2025-06-16,118827.45999999999,26558.000000000004,145385.46
|
||||||
|
2025-06-17,119851.45999999999,25974.019999999997,145825.47999999998
|
||||||
|
2025-06-18,118051.45999999999,27032.4,145083.86
|
||||||
|
2025-06-19,112651.45999999999,31251.42,143902.88
|
||||||
|
2025-06-20,109651.45999999999,33717.020000000004,143368.47999999998
|
||||||
|
2025-06-23,108051.45999999999,36004.66,144056.12
|
||||||
|
2025-06-24,108051.45999999999,36450.659999999996,144502.12
|
||||||
|
2025-06-25,108567.45999999999,36190.72,144758.18
|
||||||
|
2025-06-26,108767.45999999999,36286.82,145054.28
|
||||||
|
2025-06-27,108767.45999999999,36246.7,145014.15999999997
|
||||||
|
2025-06-30,108321.45999999999,37252.479999999996,145573.94
|
||||||
|
2025-07-01,115200.18,31736.68,146936.86
|
||||||
|
2025-07-02,117302.28,30046.74,147349.02
|
||||||
|
2025-07-03,117302.28,29892.84,147195.12
|
||||||
|
2025-07-04,117302.28,29383.94,146686.22
|
||||||
|
2025-07-07,117586.28,29861.88,147448.16
|
||||||
|
2025-07-08,117586.28,29913.9,147500.18
|
||||||
|
2025-07-09,117912.28,29733.94,147646.22
|
||||||
|
2025-07-10,118112.28,29558.0,147670.28
|
||||||
|
2025-07-11,116312.28,31418.0,147730.28
|
||||||
|
2025-07-14,114316.28,33108.0,147424.28
|
||||||
|
2025-07-15,112716.28,34186.0,146902.28
|
||||||
|
2025-07-16,114716.28,32426.0,147142.28
|
||||||
|
2025-07-17,114960.2,32414.08,147374.28
|
||||||
|
2025-07-18,113160.2,33946.38,147106.58
|
||||||
|
2025-07-21,116704.2,30680.16,147384.36
|
||||||
|
2025-07-22,116980.59999999999,30577.559999999998,147558.15999999997
|
||||||
|
2025-07-23,116980.59999999999,30195.579999999998,147176.18
|
||||||
|
2025-07-24,115180.59999999999,32133.800000000003,147314.4
|
||||||
|
2025-07-25,115180.59999999999,32153.94,147334.53999999998
|
||||||
|
2025-07-28,115180.59999999999,32243.62,147424.22
|
||||||
|
2025-07-29,115180.59999999999,31921.92,147102.52
|
||||||
|
2025-07-30,115180.59999999999,31987.88,147168.47999999998
|
||||||
|
2025-07-31,113580.59999999999,33084.16,146664.76
|
||||||
|
2025-08-01,113316.59999999999,33357.8,146674.4
|
||||||
|
2025-08-04,116562.59999999999,30359.96,146922.56
|
||||||
|
2025-08-05,114762.59999999999,32258.28,147020.88
|
||||||
|
2025-08-06,114762.59999999999,32168.1,146930.69999999998
|
||||||
|
2025-08-07,114762.59999999999,32379.9,147142.5
|
||||||
|
2025-08-08,119086.59999999999,28390.0,147476.59999999998
|
||||||
|
2025-08-11,119218.59999999999,28752.0,147970.59999999998
|
||||||
|
2025-08-12,117418.59999999999,30502.0,147920.59999999998
|
||||||
|
2025-08-13,121062.59999999999,26722.0,147784.59999999998
|
||||||
|
2025-08-14,117662.59999999999,29492.0,147154.59999999998
|
||||||
|
2025-08-15,119662.59999999999,28798.0,148460.59999999998
|
||||||
|
2025-08-18,123252.59999999999,26458.0,149710.59999999998
|
||||||
|
2025-08-19,122090.59999999999,27948.0,150038.59999999998
|
||||||
|
2025-08-20,120290.59999999999,29984.0,150274.59999999998
|
||||||
|
2025-08-21,126290.59999999999,24298.0,150588.59999999998
|
||||||
|
2025-08-22,126652.59999999999,23932.0,150584.59999999998
|
||||||
|
2025-08-25,126652.59999999999,24162.0,150814.59999999998
|
||||||
|
2025-08-26,126852.59999999999,24104.0,150956.59999999998
|
||||||
|
2025-08-27,123646.59999999999,26804.0,150450.59999999998
|
||||||
|
2025-08-28,118640.59999999999,32296.0,150936.59999999998
|
||||||
|
2025-08-29,116840.59999999999,33900.0,150740.59999999998
|
||||||
|
2025-09-01,122140.01999999999,29354.24,151494.25999999998
|
||||||
|
2025-09-02,120726.01999999999,30304.0,151030.02
|
||||||
|
2025-09-03,117326.01999999999,32868.0,150194.02
|
||||||
|
2025-09-04,114126.01999999999,35582.0,149708.02
|
||||||
|
2025-09-05,114126.01999999999,36400.0,150526.02
|
||||||
|
2025-09-08,114126.01999999999,36670.0,150796.02
|
||||||
|
2025-09-09,114126.01999999999,35764.0,149890.02
|
||||||
|
2025-09-10,114126.01999999999,36264.0,150390.02
|
||||||
|
2025-09-11,116326.01999999999,34852.0,151178.02
|
||||||
|
2025-09-12,116580.01999999999,34638.0,151218.02
|
||||||
|
2025-09-15,116840.01999999999,34224.0,151064.02
|
||||||
|
2025-09-16,116840.01999999999,34266.0,151106.02
|
||||||
|
2025-09-17,116840.01999999999,34016.0,150856.02
|
||||||
|
2025-09-18,115522.01999999999,34918.0,150440.02
|
||||||
|
2025-09-19,115522.01999999999,34548.0,150070.02
|
||||||
|
2025-09-22,116524.01999999999,33574.0,150098.02
|
||||||
|
2025-09-23,111924.01999999999,37476.0,149400.02
|
||||||
|
2025-09-24,112264.01999999999,37360.0,149624.02
|
||||||
|
2025-09-25,112264.01999999999,36962.0,149226.02
|
||||||
|
2025-09-26,112264.01999999999,36744.0,149008.02
|
||||||
|
2025-09-29,116726.01999999999,32582.0,149308.02
|
||||||
|
2025-09-30,116726.01999999999,32560.0,149286.02
|
||||||
|
2025-10-09,118370.01999999999,30188.0,148558.02
|
||||||
|
2025-10-10,116570.01999999999,31992.0,148562.02
|
||||||
|
2025-10-13,112600.01999999999,36462.0,149062.02
|
||||||
|
2025-10-14,112600.01999999999,36110.0,148710.02
|
||||||
|
2025-10-15,112600.01999999999,36570.0,149170.02
|
||||||
|
2025-10-16,114600.01999999999,34026.0,148626.02
|
||||||
|
2025-10-17,113000.01999999999,34894.0,147894.02
|
||||||
|
2025-10-20,112998.01999999999,35922.0,148920.02
|
||||||
|
2025-10-21,114998.01999999999,34854.0,149852.02
|
||||||
|
2025-10-22,114998.01999999999,34606.0,149604.02
|
||||||
|
2025-10-23,113198.01999999999,36532.0,149730.02
|
||||||
|
2025-10-24,113198.01999999999,36844.0,150042.02
|
||||||
|
2025-10-27,116430.01999999999,33332.0,149762.02
|
||||||
|
2025-10-28,116430.01999999999,33280.0,149710.02
|
||||||
|
2025-10-29,116430.01999999999,33934.0,150364.02
|
||||||
|
2025-10-30,116430.01999999999,33806.0,150236.02
|
||||||
|
2025-10-31,116430.01999999999,34148.0,150578.02
|
||||||
|
2025-11-03,121148.01999999999,29008.0,150156.02
|
||||||
|
2025-11-04,120108.01999999999,30110.0,150218.02
|
||||||
|
2025-11-05,120108.01999999999,30552.0,150660.02
|
||||||
|
2025-11-06,118308.01999999999,32286.0,150594.02
|
||||||
|
2025-11-07,118702.01999999999,31342.0,150044.02
|
||||||
|
2025-11-10,118702.01999999999,31688.0,150390.02
|
||||||
|
2025-11-11,123102.01999999999,27790.0,150892.02
|
||||||
|
2025-11-12,118426.01999999999,32408.0,150834.02
|
||||||
|
2025-11-13,119016.01999999999,32752.0,151768.02
|
||||||
|
2025-11-14,119016.01999999999,33214.0,152230.02
|
||||||
|
2025-11-17,123270.01999999999,29114.0,152384.02
|
||||||
|
2025-11-18,119670.01999999999,31876.0,151546.02
|
||||||
|
2025-11-19,118070.01999999999,33268.0,151338.02
|
||||||
|
2025-11-20,116470.01999999999,34108.0,150578.02
|
||||||
|
2025-11-21,109870.01999999999,38562.0,148432.02
|
||||||
|
2025-11-24,109938.01999999999,38512.0,148450.02
|
||||||
|
2025-11-25,111938.01999999999,37762.0,149700.02
|
||||||
|
2025-11-26,111938.01999999999,37170.0,149108.02
|
||||||
|
2025-11-27,111938.01999999999,37172.0,149110.02
|
||||||
|
2025-11-28,111938.01999999999,37834.0,149772.02
|
||||||
|
2025-12-01,115825.68,33942.38,149768.06
|
||||||
|
2025-12-02,115825.68,33994.38,149820.06
|
||||||
|
2025-12-03,117825.68,31736.9,149562.58
|
||||||
|
2025-12-04,112425.68,36213.44,148639.12
|
||||||
|
2025-12-05,109425.68,40135.04,149560.72
|
||||||
|
2025-12-08,112603.68,37374.88,149978.56
|
||||||
|
2025-12-09,116729.68,33392.68,150122.36
|
||||||
|
2025-12-10,117313.68,33230.36,150544.03999999998
|
||||||
|
2025-12-11,115769.68,34872.0,150641.68
|
||||||
|
2025-12-12,115769.68,35290.0,151059.68
|
||||||
|
2025-12-15,118523.68,33424.0,151947.68
|
||||||
|
2025-12-16,118723.68,32706.0,151429.68
|
||||||
|
2025-12-17,111723.68,39646.0,151369.68
|
||||||
|
2025-12-18,111723.68,39308.0,151031.68
|
||||||
|
2025-12-19,110123.68,41948.0,152071.68
|
||||||
|
2025-12-22,116025.68,36470.0,152495.68
|
||||||
|
2025-12-23,116225.68,36004.0,152229.68
|
||||||
|
2025-12-24,115195.68,37596.0,152791.68
|
||||||
|
2025-12-25,115571.68,37138.0,152709.68
|
||||||
|
2025-12-26,115371.68,37724.0,153095.68
|
||||||
|
2025-12-29,118263.68,34576.0,152839.68
|
||||||
|
2025-12-30,116959.68,35302.0,152261.68
|
||||||
|
2025-12-31,115159.68,36376.0,151535.68
|
||||||
|
2026-01-05,114658.76,36630.8,151289.56
|
||||||
|
2026-01-06,113458.76,38219.44,151678.2
|
||||||
|
2026-01-07,113854.76,37808.0,151662.76
|
||||||
|
2026-01-08,112054.76,40598.0,152652.76
|
||||||
|
2026-01-09,113854.76,39384.0,153238.76
|
||||||
|
2026-01-12,114728.76,38852.0,153580.76
|
||||||
|
2026-01-13,120616.76,32848.0,153464.76
|
||||||
|
2026-01-14,121016.76,32938.0,153954.76
|
||||||
|
2026-01-15,121216.76,32146.0,153362.76
|
||||||
|
2026-01-16,117816.76,34904.0,152720.76
|
||||||
|
2026-01-19,118854.76,34260.0,153114.76
|
||||||
|
2026-01-20,117516.76,35734.0,153250.76
|
||||||
|
2026-01-21,120376.76,34212.0,154588.76
|
||||||
|
2026-01-22,120376.76,34816.0,155192.76
|
||||||
|
2026-01-23,120376.76,35548.0,155924.76
|
||||||
|
2026-01-26,119168.76,36470.0,155638.76
|
||||||
|
2026-01-27,119368.76,36470.0,155838.76
|
||||||
|
2026-01-28,119368.76,35508.0,154876.76
|
||||||
|
2026-01-29,117768.76,37012.0,154780.76
|
||||||
|
2026-01-30,118104.76,36686.0,154790.76
|
||||||
|
2026-02-02,123830.76,30870.0,154700.76
|
||||||
|
2026-02-03,123830.76,31876.000000000004,155706.76
|
||||||
|
2026-02-04,123830.76,31492.0,155322.76
|
||||||
|
2026-02-05,123830.76,31724.0,155554.76
|
||||||
|
2026-02-06,123830.76,32184.0,156014.76
|
||||||
|
2026-02-09,124128.76,32940.0,157068.76
|
||||||
|
2026-02-10,130252.76000000001,27476.0,157728.76
|
||||||
|
2026-02-11,128452.76000000001,29178.0,157630.76
|
||||||
|
2026-02-12,130452.76000000001,27600.0,158052.76
|
||||||
|
2026-02-13,130652.76000000001,27454.0,158106.76
|
||||||
|
2026-02-24,129478.76000000001,29166.0,158644.76
|
||||||
|
2026-02-25,129678.76000000001,29674.0,159352.76
|
||||||
|
2026-02-26,129878.76000000001,29368.0,159246.76
|
||||||
|
2026-02-27,128078.76000000001,31448.0,159526.76
|
||||||
|
2026-03-02,126568.76000000001,31928.0,158496.76
|
||||||
|
2026-03-03,123168.76000000001,33314.0,156482.76
|
||||||
|
2026-03-04,121530.76000000001,35378.0,156908.76
|
||||||
|
2026-03-05,123530.76000000001,33996.0,157526.76
|
||||||
|
2026-03-06,121930.76000000001,35786.0,157716.76
|
||||||
|
2026-03-09,120130.76000000001,37128.0,157258.76
|
||||||
|
2026-03-10,122130.76000000001,36208.0,158338.76
|
||||||
|
2026-03-11,122498.76000000001,35428.0,157926.76
|
||||||
|
2026-03-12,122498.76000000001,34782.0,157280.76
|
||||||
|
2026-03-13,122818.76000000001,33916.0,156734.76
|
||||||
|
2026-03-16,119866.98000000001,36893.56,156760.54
|
||||||
|
2026-03-17,120266.98000000001,35880.16,156147.14
|
||||||
|
2026-03-18,120266.98000000001,36240.88,156507.86000000002
|
||||||
|
2026-03-19,115666.98000000001,39534.56,155201.54
|
||||||
|
2026-03-20,114066.98000000001,39770.48,153837.46000000002
|
||||||
|
2026-03-23,112544.98000000001,40740.8,153285.78000000003
|
||||||
|
2026-03-24,111144.98000000001,44227.28,155372.26
|
||||||
|
2026-03-25,113144.98000000001,43183.8,156328.78000000003
|
||||||
|
2026-03-26,114944.98000000001,40874.96,155819.94
|
||||||
|
2026-03-27,114944.98000000001,41492.52,156437.5
|
||||||
|
2026-03-30,118332.98000000001,38077.28,156410.26
|
||||||
|
2026-03-31,118332.98000000001,37634.52,155967.5
|
||||||
|
2026-04-01,121430.98000000001,35512.04,156943.02000000002
|
||||||
|
2026-04-02,121430.98000000001,34988.72,156419.7
|
||||||
|
2026-04-03,118420.98000000001,37343.56,155764.54
|
||||||
|
2026-04-07,117714.98000000001,38003.479999999996,155718.46000000002
|
||||||
|
2026-04-08,117980.98000000001,39241.32,157222.30000000002
|
||||||
|
2026-04-09,117980.98000000001,38689.08,156670.06
|
||||||
|
2026-04-10,118180.98000000001,38458.6,156639.58000000002
|
||||||
|
2026-04-13,116651.58000000002,39420.0,156071.58000000002
|
||||||
|
2026-04-14,115051.58000000002,40588.0,155639.58000000002
|
||||||
|
2026-04-15,114451.58000000002,40568.0,155019.58000000002
|
||||||
|
2026-04-16,113051.58000000002,41612.0,154663.58000000002
|
||||||
|
2026-04-17,111851.58000000002,42540.0,154391.58000000002
|
||||||
|
2026-04-20,111939.58000000002,43314.0,155253.58000000002
|
||||||
|
2026-04-21,111939.58000000002,43070.0,155009.58000000002
|
||||||
|
2026-04-22,111939.58000000002,42160.0,154099.58000000002
|
||||||
|
2026-04-23,110939.58000000002,41660.0,152599.58000000002
|
||||||
|
2026-04-24,109899.58000000002,42320.0,152219.58000000002
|
||||||
|
2026-04-27,113003.58000000002,39480.0,152483.58000000002
|
||||||
|
2026-04-28,112317.58000000002,38134.0,150451.58000000002
|
||||||
|
2026-04-29,113317.58000000002,37898.0,151215.58000000002
|
||||||
|
2026-04-30,111717.58000000002,39436.0,151153.58000000002
|
||||||
|
@@ -0,0 +1,37 @@
|
|||||||
|
date,cash,stock_value,total_value,positions,month
|
||||||
|
2023-05-31,17736.34,41098.4,58834.74,10,2023-05
|
||||||
|
2023-06-30,20515.46,40323.66,60839.12,10,2023-06
|
||||||
|
2023-07-31,30397.84,29960.78,60358.62,9,2023-07
|
||||||
|
2023-08-31,26136.7,34300.62,60437.32,10,2023-08
|
||||||
|
2023-09-30,39873.34,27709.4,67582.74,10,2023-09
|
||||||
|
2023-10-31,36088.52,31940.92,68029.44,10,2023-10
|
||||||
|
2023-11-30,49700.74,27047.76,76748.5,10,2023-11
|
||||||
|
2023-12-31,56387.94,33956.0,90343.94,10,2023-12
|
||||||
|
2024-01-31,43543.94,44782.0,88325.94,10,2024-01
|
||||||
|
2024-02-29,41861.28,44551.96,86413.24,10,2024-02
|
||||||
|
2024-03-31,60401.36,30781.6,91182.96,9,2024-03
|
||||||
|
2024-04-30,51342.72,39697.38,91040.1,10,2024-04
|
||||||
|
2024-05-31,65813.5,29130.0,94943.5,9,2024-05
|
||||||
|
2024-06-30,52527.76,40070.0,92597.76,10,2024-06
|
||||||
|
2024-07-31,61456.88,33980.7,95437.58,10,2024-07
|
||||||
|
2024-08-31,52183.52,42771.06,94954.58,10,2024-08
|
||||||
|
2024-09-30,80349.32,21369.96,101719.28,10,2024-09
|
||||||
|
2024-10-31,87055.28,25324.0,112379.28,10,2024-10
|
||||||
|
2024-11-30,101950.1,29600.28,131550.38,10,2024-11
|
||||||
|
2024-12-31,89318.12,35982.0,125300.12,8,2024-12
|
||||||
|
2025-01-31,95062.12,29920.0,124982.12,9,2025-01
|
||||||
|
2025-02-28,102728.72,26542.96,129271.68,9,2025-02
|
||||||
|
2025-03-31,103822.1,31484.3,135306.4,10,2025-03
|
||||||
|
2025-04-30,102745.54,35759.3,138504.84,10,2025-04
|
||||||
|
2025-05-31,115165.84,27606.0,142771.84,8,2025-05
|
||||||
|
2025-06-30,108767.46,36246.7,145014.16,9,2025-06
|
||||||
|
2025-07-31,115180.6,32153.94,147334.54,10,2025-07
|
||||||
|
2025-08-31,116840.6,33900.0,150740.6,10,2025-08
|
||||||
|
2025-09-30,116726.02,32560.0,149286.02,10,2025-09
|
||||||
|
2025-10-31,116430.02,34148.0,150578.02,10,2025-10
|
||||||
|
2025-11-30,111938.02,37834.0,149772.02,10,2025-11
|
||||||
|
2025-12-31,115159.68,36376.0,151535.68,10,2025-12
|
||||||
|
2026-01-31,118104.76,36686.0,154790.76,10,2026-01
|
||||||
|
2026-02-28,128078.76,31448.0,159526.76,10,2026-02
|
||||||
|
2026-03-31,114944.98,41492.52,156437.5,10,2026-03
|
||||||
|
2026-04-30,111717.58,39436.0,151153.58,10,2026-04
|
||||||
|
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"version": "v6.7r2",
|
||||||
|
"rank_model": "lambdarank (56-dim v6.7)",
|
||||||
|
"backtest_start": "2023-05-01",
|
||||||
|
"backtest_end": "2026-04-30",
|
||||||
|
"n_weeks": 154,
|
||||||
|
"n_stocks_in_pool": 5378,
|
||||||
|
"top_n": 10,
|
||||||
|
"shares_per_grid": 200,
|
||||||
|
"initial_cash": 60000.0,
|
||||||
|
"final_total_value": 151153.58,
|
||||||
|
"total_return_pct": 151.92,
|
||||||
|
"annual_return_pct": 36.1,
|
||||||
|
"annual_sharpe": 1.7404,
|
||||||
|
"max_drawdown_pct": -12.1,
|
||||||
|
"weekly_win_rate_pct": 59.48,
|
||||||
|
"n_trades_buy": 1144,
|
||||||
|
"n_trades_sell": 866,
|
||||||
|
"realized_pnl_total": 95495.58,
|
||||||
|
"n_lifecycles": 431
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
|||||||
|
date,cash,stock_value,total_value,positions,year
|
||||||
|
2023-05-05,29353.86,32311.96,61665.82,10,2023
|
||||||
|
2023-05-12,24353.86,37200.04,61553.9,10,2023
|
||||||
|
2023-05-19,19999.86,40128.84,60128.7,10,2023
|
||||||
|
2023-05-26,17736.34,41098.4,58834.74,10,2023
|
||||||
|
2023-06-02,22233.92,40253.44,62487.36,10,2023
|
||||||
|
2023-06-09,23083.9,39432.4,62516.3,10,2023
|
||||||
|
2023-06-16,27171.9,36662.04,63833.94,10,2023
|
||||||
|
2023-06-21,27177.36,35306.82,62484.18,10,2023
|
||||||
|
2023-06-30,20515.46,40323.66,60839.12,10,2023
|
||||||
|
2023-07-07,27155.76,33151.0,60306.76,10,2023
|
||||||
|
2023-07-14,29091.76,31705.8,60797.56,10,2023
|
||||||
|
2023-07-21,29114.82,32509.1,61623.92,9,2023
|
||||||
|
2023-07-28,30397.84,29960.78,60358.62,9,2023
|
||||||
|
2023-08-04,31326.78,29340.22,60667.0,10,2023
|
||||||
|
2023-08-11,28602.46,31713.8,60316.26,10,2023
|
||||||
|
2023-08-18,33136.7,28257.34,61394.04,10,2023
|
||||||
|
2023-08-25,26136.7,34300.62,60437.32,10,2023
|
||||||
|
2023-09-01,33946.78,29311.82,63258.6,10,2023
|
||||||
|
2023-09-08,29682.92,33243.98,62926.9,10,2023
|
||||||
|
2023-09-15,33929.1,29926.24,63855.34,10,2023
|
||||||
|
2023-09-22,35117.34,29932.44,65049.78,10,2023
|
||||||
|
2023-09-28,39873.34,27709.4,67582.74,10,2023
|
||||||
|
2023-10-13,44083.08,23683.52,67766.6,10,2023
|
||||||
|
2023-10-20,33289.08,32655.7,65944.78,10,2023
|
||||||
|
2023-10-27,36088.52,31940.92,68029.44,10,2023
|
||||||
|
2023-11-03,40429.7,29377.8,69807.5,10,2023
|
||||||
|
2023-11-10,46479.74,25900.26,72380.0,10,2023
|
||||||
|
2023-11-17,49341.34,24523.56,73864.9,10,2023
|
||||||
|
2023-11-24,49700.74,27047.76,76748.5,10,2023
|
||||||
|
2023-12-01,33892.34,39456.38,73348.72,10,2023
|
||||||
|
2023-12-08,50831.34,30194.3,81025.64,10,2023
|
||||||
|
2023-12-15,52387.94,31884.0,84271.94,10,2023
|
||||||
|
2023-12-22,52909.94,32946.0,85855.94,10,2023
|
||||||
|
2023-12-29,56387.94,33956.0,90343.94,10,2023
|
||||||
|
2024-01-05,56405.94,34602.0,91007.94,10,2024
|
||||||
|
2024-01-12,42405.94,42438.0,84843.94,10,2024
|
||||||
|
2024-01-19,44405.94,46458.0,90863.94,10,2024
|
||||||
|
2024-01-26,43543.94,44782.0,88325.94,10,2024
|
||||||
|
2024-02-02,27305.94,52690.0,79995.94,10,2024
|
||||||
|
2024-02-08,31419.94,51926.0,83345.94,10,2024
|
||||||
|
2024-02-23,41861.28,44551.96,86413.24,10,2024
|
||||||
|
2024-03-01,49575.3,40919.28,90494.58,10,2024
|
||||||
|
2024-03-08,53677.94,35737.8,89415.74,10,2024
|
||||||
|
2024-03-15,60047.92,31026.78,91074.7,10,2024
|
||||||
|
2024-03-22,65873.84,26076.62,91950.46,10,2024
|
||||||
|
2024-03-29,60401.36,30781.6,91182.96,9,2024
|
||||||
|
2024-04-03,66965.58,26641.44,93607.02,9,2024
|
||||||
|
2024-04-12,56903.58,32507.18,89410.76,10,2024
|
||||||
|
2024-04-19,33499.58,52126.22,85625.8,10,2024
|
||||||
|
2024-04-26,42761.58,46693.66,89455.24,10,2024
|
||||||
|
2024-04-30,51342.72,39697.38,91040.1,10,2024
|
||||||
|
2024-05-10,56205.06,34632.66,90837.72,10,2024
|
||||||
|
2024-05-17,60869.06,31647.16,92516.22,10,2024
|
||||||
|
2024-05-24,59605.06,32880.44,92485.5,10,2024
|
||||||
|
2024-05-31,65813.5,29130.0,94943.5,9,2024
|
||||||
|
2024-06-07,47960.4,42130.56,90090.96,10,2024
|
||||||
|
2024-06-14,56152.4,36035.36,92187.76,10,2024
|
||||||
|
2024-06-21,62239.76,31984.0,94223.76,10,2024
|
||||||
|
2024-06-28,52527.76,40070.0,92597.76,10,2024
|
||||||
|
2024-07-05,52351.76,40466.0,92817.76,10,2024
|
||||||
|
2024-07-12,51451.72,42662.64,94114.36,10,2024
|
||||||
|
2024-07-19,55290.88,38665.44,93956.32,10,2024
|
||||||
|
2024-07-26,61456.88,33980.7,95437.58,10,2024
|
||||||
|
2024-08-02,61925.74,34468.0,96393.74,10,2024
|
||||||
|
2024-08-09,63005.74,33154.0,96159.74,10,2024
|
||||||
|
2024-08-16,56715.74,39058.0,95773.74,10,2024
|
||||||
|
2024-08-23,55364.2,37672.44,93036.64,10,2024
|
||||||
|
2024-08-30,52183.52,42771.06,94954.58,10,2024
|
||||||
|
2024-09-06,60506.34,35878.58,96384.92,9,2024
|
||||||
|
2024-09-13,62642.34,30741.5,93383.84,8,2024
|
||||||
|
2024-09-20,58359.32,34882.94,93242.26,10,2024
|
||||||
|
2024-09-27,69235.32,29121.08,98356.4,10,2024
|
||||||
|
2024-09-30,80349.32,21369.96,101719.28,10,2024
|
||||||
|
2024-10-11,56083.28,43112.0,99195.28,10,2024
|
||||||
|
2024-10-18,71101.28,35670.0,106771.28,10,2024
|
||||||
|
2024-10-25,87055.28,25324.0,112379.28,10,2024
|
||||||
|
2024-11-01,90986.66,26929.52,117916.18,10,2024
|
||||||
|
2024-11-08,100636.82,23667.66,124304.48,10,2024
|
||||||
|
2024-11-15,96557.2,28499.72,125056.92,10,2024
|
||||||
|
2024-11-22,102164.48,26932.02,129096.5,10,2024
|
||||||
|
2024-11-29,101950.1,29600.28,131550.38,10,2024
|
||||||
|
2024-12-06,103392.78,28411.32,131804.1,10,2024
|
||||||
|
2024-12-13,96844.12,33198.0,130042.12,10,2024
|
||||||
|
2024-12-20,82444.12,44764.0,127208.12,10,2024
|
||||||
|
2024-12-27,89318.12,35982.0,125300.12,8,2024
|
||||||
|
2025-01-03,87408.12,37578.0,124986.12,10,2025
|
||||||
|
2025-01-10,85580.12,38096.0,123676.12,10,2025
|
||||||
|
2025-01-17,92964.12,32718.0,125682.12,9,2025
|
||||||
|
2025-01-24,97680.12,30942.0,128622.12,8,2025
|
||||||
|
2025-01-27,95062.12,29920.0,124982.12,9,2025
|
||||||
|
2025-02-07,99682.12,27098.0,126780.12,9,2025
|
||||||
|
2025-02-14,106503.36,21418.78,127922.14,9,2025
|
||||||
|
2025-02-21,105749.72,23782.4,129532.12,9,2025
|
||||||
|
2025-02-28,102728.72,26542.96,129271.68,9,2025
|
||||||
|
2025-03-07,105425.36,27040.4,132465.76,10,2025
|
||||||
|
2025-03-14,115013.82,19782.36,134796.18,9,2025
|
||||||
|
2025-03-21,109423.82,25211.12,134634.94,9,2025
|
||||||
|
2025-03-28,103822.1,31484.3,135306.4,10,2025
|
||||||
|
2025-04-03,99800.62,34518.32,134318.94,10,2025
|
||||||
|
2025-04-11,99600.62,37063.76,136664.38,10,2025
|
||||||
|
2025-04-18,109341.54,30561.36,139902.9,10,2025
|
||||||
|
2025-04-25,106835.54,32281.18,139116.72,10,2025
|
||||||
|
2025-04-30,102745.54,35759.3,138504.84,10,2025
|
||||||
|
2025-05-09,102637.84,37585.88,140223.72,10,2025
|
||||||
|
2025-05-16,101963.84,38900.0,140863.84,10,2025
|
||||||
|
2025-05-23,109423.84,32758.0,142181.84,9,2025
|
||||||
|
2025-05-30,115165.84,27606.0,142771.84,8,2025
|
||||||
|
2025-06-06,118567.88,25523.98,144091.86,10,2025
|
||||||
|
2025-06-13,121827.46,23271.14,145098.6,9,2025
|
||||||
|
2025-06-20,109651.46,33717.02,143368.48,9,2025
|
||||||
|
2025-06-27,108767.46,36246.7,145014.16,9,2025
|
||||||
|
2025-07-04,117302.28,29383.94,146686.22,10,2025
|
||||||
|
2025-07-11,116312.28,31418.0,147730.28,10,2025
|
||||||
|
2025-07-18,113160.2,33946.38,147106.58,10,2025
|
||||||
|
2025-07-25,115180.6,32153.94,147334.54,10,2025
|
||||||
|
2025-08-01,113316.6,33357.8,146674.4,10,2025
|
||||||
|
2025-08-08,119086.6,28390.0,147476.6,10,2025
|
||||||
|
2025-08-15,119662.6,28798.0,148460.6,9,2025
|
||||||
|
2025-08-22,126652.6,23932.0,150584.6,10,2025
|
||||||
|
2025-08-29,116840.6,33900.0,150740.6,10,2025
|
||||||
|
2025-09-05,114126.02,36400.0,150526.02,10,2025
|
||||||
|
2025-09-12,116580.02,34638.0,151218.02,10,2025
|
||||||
|
2025-09-19,115522.02,34548.0,150070.02,10,2025
|
||||||
|
2025-09-26,115364.02,36744.0,152108.02,9,2025
|
||||||
|
2025-09-30,116726.02,32560.0,149286.02,10,2025
|
||||||
|
2025-10-10,116570.02,31992.0,148562.02,10,2025
|
||||||
|
2025-10-17,113000.02,34894.0,147894.02,10,2025
|
||||||
|
2025-10-24,113198.02,36844.0,150042.02,10,2025
|
||||||
|
2025-10-31,116430.02,34148.0,150578.02,10,2025
|
||||||
|
2025-11-07,118702.02,31342.0,150044.02,10,2025
|
||||||
|
2025-11-14,119016.02,33214.0,152230.02,10,2025
|
||||||
|
2025-11-21,109870.02,38562.0,148432.02,10,2025
|
||||||
|
2025-11-28,111938.02,37834.0,149772.02,10,2025
|
||||||
|
2025-12-05,109425.68,40135.04,149560.72,10,2025
|
||||||
|
2025-12-12,115769.68,35290.0,151059.68,10,2025
|
||||||
|
2025-12-19,110123.68,41948.0,152071.68,10,2025
|
||||||
|
2025-12-26,115371.68,37724.0,153095.68,10,2025
|
||||||
|
2025-12-31,115159.68,36376.0,151535.68,10,2025
|
||||||
|
2026-01-09,113854.76,39384.0,153238.76,10,2026
|
||||||
|
2026-01-16,117816.76,34904.0,152720.76,10,2026
|
||||||
|
2026-01-23,120376.76,35548.0,155924.76,10,2026
|
||||||
|
2026-01-30,118104.76,36686.0,154790.76,10,2026
|
||||||
|
2026-02-06,123830.76,32184.0,156014.76,10,2026
|
||||||
|
2026-02-13,130652.76,27454.0,158106.76,10,2026
|
||||||
|
2026-02-27,128078.76,31448.0,159526.76,10,2026
|
||||||
|
2026-03-06,121930.76,35786.0,157716.76,9,2026
|
||||||
|
2026-03-13,122818.76,33916.0,156734.76,9,2026
|
||||||
|
2026-03-20,114066.98,39770.48,153837.46,10,2026
|
||||||
|
2026-03-27,114944.98,41492.52,156437.5,10,2026
|
||||||
|
2026-04-03,118420.98,37343.56,155764.54,10,2026
|
||||||
|
2026-04-10,118180.98,38458.6,156639.58,10,2026
|
||||||
|
2026-04-17,111851.58,42540.0,154391.58,10,2026
|
||||||
|
2026-04-24,109899.58,42320.0,152219.58,10,2026
|
||||||
|
2026-04-30,111717.58,39436.0,151153.58,10,2026
|
||||||
|
Binary file not shown.
@@ -0,0 +1,53 @@
|
|||||||
|
feature,importance,importance_pct
|
||||||
|
dist_to_grid_upper,1319,4.38
|
||||||
|
dist_to_grid_lower,1282,4.26
|
||||||
|
price_cv,1231,4.09
|
||||||
|
amount_mean_20d,1198,3.98
|
||||||
|
range_compression_20d,1047,3.48
|
||||||
|
rebound_from_low_60d,1019,3.38
|
||||||
|
drawdown_60d,981,3.26
|
||||||
|
ma20_deviation_pct,915,3.04
|
||||||
|
grid_touch_count_60d,907,3.01
|
||||||
|
volume_ratio,903,3.0
|
||||||
|
price_entropy,877,2.91
|
||||||
|
avg_daily_amp,857,2.85
|
||||||
|
atr_pct,781,2.59
|
||||||
|
wick_ratio_20d,780,2.59
|
||||||
|
cross_freq_x_bb,743,2.47
|
||||||
|
trend_slope_60d,743,2.47
|
||||||
|
amount_trend_20d,741,2.46
|
||||||
|
range_position_60d,735,2.44
|
||||||
|
obv_slope,731,2.43
|
||||||
|
volatility_20d,707,2.35
|
||||||
|
volume_cv_20d,697,2.32
|
||||||
|
amplitude_cv,673,2.24
|
||||||
|
intraday_trend_strength,669,2.22
|
||||||
|
amp_cv_x_entropy,661,2.2
|
||||||
|
ma60_deviation_pct,655,2.18
|
||||||
|
ma20_ma60_gap_pct,650,2.16
|
||||||
|
grid_room_balance,630,2.09
|
||||||
|
bb_width,629,2.09
|
||||||
|
amount_cv_20d,598,1.99
|
||||||
|
amp_x_grid_vol,588,1.95
|
||||||
|
trend_slope_20d,568,1.89
|
||||||
|
amp_x_grid,499,1.66
|
||||||
|
trend_abs_slope_20d,448,1.49
|
||||||
|
close_reversal_count_20d,395,1.31
|
||||||
|
near_upper_boundary_risk,366,1.22
|
||||||
|
grid_touch_count_20d,335,1.11
|
||||||
|
near_grid_line_ratio_20d,293,0.97
|
||||||
|
low_volume_days_20d,274,0.91
|
||||||
|
turnover_proxy_20d,250,0.83
|
||||||
|
rolling_grid_ratio_20d,248,0.82
|
||||||
|
cross_freq_20d,206,0.68
|
||||||
|
mv_vol_interact,190,0.63
|
||||||
|
down_days_20d,180,0.6
|
||||||
|
high_amp_days,172,0.57
|
||||||
|
up_days_20d,165,0.55
|
||||||
|
small_cap_premium,164,0.54
|
||||||
|
ln_float_mv,162,0.54
|
||||||
|
near_lower_boundary_risk,114,0.38
|
||||||
|
trend_consistency_20d,103,0.34
|
||||||
|
usable_grid_count_lower,13,0.04
|
||||||
|
usable_grid_count_upper,12,0.04
|
||||||
|
grid_cross_density_20d,0,0.0
|
||||||
|
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"version": "v6.7-rank-lambdarank-mvp",
|
||||||
|
"feature_version": "v3.4",
|
||||||
|
"objective": "lambdarank",
|
||||||
|
"metric": "ndcg@5,10",
|
||||||
|
"ndcg_at_5": 0.5108501630463596,
|
||||||
|
"ndcg_at_10": 0.577072484777789,
|
||||||
|
"spearman": 0.5896180660523218,
|
||||||
|
"spearman_baseline_regression": 0.573016131374212,
|
||||||
|
"spearman_ratio_vs_baseline": 1.0289728923307881,
|
||||||
|
"best_iter": 29,
|
||||||
|
"train_seconds": 1.4682528972625732,
|
||||||
|
"n_train": 254234,
|
||||||
|
"n_val": 73785,
|
||||||
|
"top10_features": [
|
||||||
|
{
|
||||||
|
"name": "dist_to_grid_lower",
|
||||||
|
"gain": 26718.906676471233
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "amp_x_grid",
|
||||||
|
"gain": 5439.311601281166
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cross_freq_x_bb",
|
||||||
|
"gain": 4232.088328957558
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "amp_x_grid_vol",
|
||||||
|
"gain": 3015.4479908943176
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dist_to_grid_upper",
|
||||||
|
"gain": 2406.65438079834
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ln_float_mv",
|
||||||
|
"gain": 1248.8392915129662
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "avg_daily_amp",
|
||||||
|
"gain": 794.0413353443146
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "amount_mean_20d",
|
||||||
|
"gain": 670.1584417819977
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vol_decay_x_dist_lower",
|
||||||
|
"gain": 659.5440436601639
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vol_decay_x_grid_balance",
|
||||||
|
"gain": 653.8379725217819
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# v6.7r3 代码使用说明
|
||||||
|
|
||||||
|
`code/` 目录包含 v6.7r3 策略的**自包含**实现,可在不依赖项目其他模块的情况下独立运行。
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
| 文件 | 大小 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `strategy.py` | ~22 KB | 完整策略实现(模型加载/特征/网格/沉寂/周度淘汰) |
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
strategy.py
|
||||||
|
├── 配置常量 (网格/价格/沉寂/周度)
|
||||||
|
├── 1. 持仓 + 网格交易
|
||||||
|
│ ├── Position dataclass
|
||||||
|
│ ├── compute_initial_position() 初始建仓
|
||||||
|
│ ├── compute_single_position() 单格建仓
|
||||||
|
│ └── simulate_grid_day() 单日网格 (LIFO)
|
||||||
|
├── 2. 5 特征沉寂检测
|
||||||
|
│ └── is_slumbering() 5 特征 ≥ 3 触发
|
||||||
|
├── 3. 模型加载 + 三件套预测
|
||||||
|
│ ├── ModelBundle class
|
||||||
|
│ │ ├── load 3 .pkl
|
||||||
|
│ │ └── predict(X56) → {rank_score, top_prob, stack_prob}
|
||||||
|
├── 4. 特征计算
|
||||||
|
│ ├── compute_52_base_features() 52 维 v3.4 基础
|
||||||
|
│ └── compute_4_v67_new_features() 4 维 v6.7 新增
|
||||||
|
├── 5. 评分池
|
||||||
|
│ └── score_pool() 单日全市场评分
|
||||||
|
├── 6. 主回测入口(精简版)
|
||||||
|
│ └── quick_backtest() 生产级完整版见 tools/backtest_v67r2.py
|
||||||
|
└── 7. 入口示例
|
||||||
|
└── __main__ 加载模型 + 加载行情 + 跑回测
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装依赖
|
||||||
|
pip install pandas numpy lightgbm scipy
|
||||||
|
|
||||||
|
# 2. 准备数据
|
||||||
|
# 方式 A: 用项目内的 dump_market_data_to_parquet.py 拉 Postgres
|
||||||
|
python tools/dump_market_data_to_parquet.py
|
||||||
|
# 方式 B: 直接用现有 parquet (release/v6.7r3 之前已生成 5025 个)
|
||||||
|
|
||||||
|
# 3. 运行
|
||||||
|
cd release/v6.7r3/code
|
||||||
|
python strategy.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心 API 速查
|
||||||
|
|
||||||
|
### 1. 加载模型
|
||||||
|
```python
|
||||||
|
from strategy import ModelBundle
|
||||||
|
|
||||||
|
models = ModelBundle("../models")
|
||||||
|
# 等价: models = ModelBundle("release/v6.7r3/models")
|
||||||
|
print(models.rank_feats[:5]) # ['rolling_grid_ratio_20d', ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 三件套预测
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# 加载单只股 120 日窗口
|
||||||
|
df_window = pd.read_parquet("data/market_data/share/000001.parquet")
|
||||||
|
df_window["date"] = pd.to_datetime(df_window["date"])
|
||||||
|
df_window = df_window.tail(120).reset_index(drop=True)
|
||||||
|
|
||||||
|
# 算 56 维特征 (这里用简化版, 生产建议用项目 core.features)
|
||||||
|
from strategy import compute_52_base_features, compute_4_v67_new_features
|
||||||
|
fd = compute_52_base_features(df_window)
|
||||||
|
fd = compute_4_v67_new_features(df_window, fd)
|
||||||
|
X56 = np.array([fd.get(k, 0.0) for k in models.rank_feats], dtype=np.float64).reshape(1, -1)
|
||||||
|
|
||||||
|
# 三件套预测
|
||||||
|
pred = models.predict(X56)
|
||||||
|
print(f"rank_score: {pred['rank_score'][0]:.3f}")
|
||||||
|
print(f"top_prob: {pred['top_prob'][0]:.3f}")
|
||||||
|
print(f"stack_prob: {pred['stack_prob'][0]:.3f}") # 月末选股用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 沉寂检测
|
||||||
|
```python
|
||||||
|
from strategy import is_slumbering
|
||||||
|
|
||||||
|
df = pd.read_parquet("data/market_data/share/000001.parquet")
|
||||||
|
df["date"] = pd.to_datetime(df["date"])
|
||||||
|
slumbering = is_slumbering(df, min_triggers=3)
|
||||||
|
# True = 5 特征中至少 3 个触发 → 资金离场
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 网格交易(单日)
|
||||||
|
```python
|
||||||
|
from strategy import Position, simulate_grid_day
|
||||||
|
|
||||||
|
pos = Position(code="000001", base=10, queue=[10.0, 9.5], entry_date="2023-05-01")
|
||||||
|
new_base, new_queue, trades = simulate_grid_day(
|
||||||
|
pos.base, pos.queue,
|
||||||
|
open_p=9.4, high_p=10.6, low_p=9.3, close_p=10.5,
|
||||||
|
)
|
||||||
|
# 触发 buy at 9.0 (low 9.3 <= 9.0? no, 9.3 > 9.0 不触发)
|
||||||
|
# 触发 sell at 10.0 (high 10.6 >= 10.0, 卖出 1 格)
|
||||||
|
# 最终: base=11, queue=[9.5] (1 格清仓)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 评分池(每日全市场)
|
||||||
|
```python
|
||||||
|
from strategy import score_pool, ModelBundle
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# 假设 kline_cache 是 dict[code, DataFrame]
|
||||||
|
models = ModelBundle("../models")
|
||||||
|
pool = score_pool(pd.Timestamp("2024-03-15"), kline_cache, models, models.rank_feats)
|
||||||
|
# pool 列: code6, latest_close, rank_score, top_prob, stack_prob
|
||||||
|
# 按 stack_prob 降序, 取 top 10
|
||||||
|
top10 = pool.head(10)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键参数(可调)
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 | 调优方向 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `WEEKLY_ELIM_N` | 2 | 周度淘汰: 连续 N 周不在 top 50 | 1 太频, 3+ 太慢 |
|
||||||
|
| `SLUMBER_TRIGGERS` | 3 | 沉寂检测: >= 3/5 特征触发 | 2 太宽, 4 太严 |
|
||||||
|
| `SLUMBER_DAYS` | 10 | 连续触发多少天清仓 | 5 太短, 20 太长 |
|
||||||
|
| `TOP_N` | 10 | 最大持仓数 | 5-15 视资金量 |
|
||||||
|
| `SHARES_PER_GRID` | 200 | 单格股数 (2 手) | 100 (1 手) 也可 |
|
||||||
|
| `REFILL_PRICE` | 9.0-9.8 | 补仓价格区间 | 紧贴 9-10 网格上限 |
|
||||||
|
|
||||||
|
## 依赖项目其他模块?
|
||||||
|
|
||||||
|
为保持 `code/` 目录**自包含**:
|
||||||
|
- ✅ 不依赖 `core/features.py` (内置 `compute_52_base_features` 简化版)
|
||||||
|
- ✅ 不依赖 `training/dataset_builder.py` (内置 `compute_4_v67_new_features`)
|
||||||
|
- ❌ 需要数据: parquet 行情文件
|
||||||
|
|
||||||
|
**生产部署建议**:用 `core/features.calculate_features` 替换 `compute_52_base_features`,
|
||||||
|
它有完整版 v3.4 52 维特征实现,精度更高。
|
||||||
|
|
||||||
|
## 完整版 vs 精简版
|
||||||
|
|
||||||
|
| 维度 | `code/strategy.py` (精简) | `tools/backtest_v67r2.py` (生产) |
|
||||||
|
|---|---|---|
|
||||||
|
| 特征计算 | 简化版 (10 维示例) | 完整版 (52 维 v3.4 + 4 维 v6.7) |
|
||||||
|
| 数据加载 | dict of DataFrame | parquet 目录 + score cache |
|
||||||
|
| 日志 | 无 | 详细进度打印 |
|
||||||
|
| 输出 | dict 指标 | CSV/JSON/PNG 完整产物 |
|
||||||
|
| 速度 | 慢 (无缓存) | 快 (有 score cache) |
|
||||||
|
| 用途 | 教学/集成 | 完整回测 |
|
||||||
|
|
||||||
|
**生产环境**:用 `tools/backtest_v67r2.py` 跑回测,确保完整功能。
|
||||||
|
**集成到实盘系统**:用 `code/strategy.py` 中的 `ModelBundle` 和 `quick_backtest` 作为模板。
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
"""
|
||||||
|
v6.7r3 策略核心实现 —— 自包含版
|
||||||
|
================================
|
||||||
|
|
||||||
|
包含:
|
||||||
|
1. 模型加载
|
||||||
|
2. 56 维特征计算
|
||||||
|
3. 三件套预测 (rank → top → stacking)
|
||||||
|
4. 网格交易模拟器
|
||||||
|
5. 5 特征沉寂检测
|
||||||
|
6. 周度评分淘汰
|
||||||
|
7. 月末调仓 + 清仓补入
|
||||||
|
|
||||||
|
依赖:
|
||||||
|
pip install pandas numpy lightgbm scipy
|
||||||
|
(内嵌了核心算法, 不依赖项目其他模块, 可独立运行)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import pickle
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 0. 配置
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# 网格
|
||||||
|
INITIAL_CASH = 60_000.0
|
||||||
|
TOP_N = 10
|
||||||
|
TOP_MODEL_N = 50
|
||||||
|
SHARES_PER_GRID = 200
|
||||||
|
MIN_BUY_PRICE = 7.0
|
||||||
|
MAX_BUY_PRICE = 10.0
|
||||||
|
REFILL_MIN_PRICE = 9.0
|
||||||
|
REFILL_MAX_PRICE = 9.8
|
||||||
|
GRID_LOWER, GRID_UPPER = 1, 11
|
||||||
|
|
||||||
|
# 沉寂检测
|
||||||
|
SLUMBER_TRIGGERS = 3 # >= 3/5 特征触发
|
||||||
|
SLUMBER_DAYS = 10 # 连续 10 日触发
|
||||||
|
SLUMBER_LOOKBACK_60 = 60
|
||||||
|
SLUMBER_LOOKBACK_20 = 20
|
||||||
|
|
||||||
|
# 周度淘汰
|
||||||
|
WEEKLY_ELIM_N = 2 # 连续 2 周不在 top 50 → 卖
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. 持仓 + 网格交易
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Position:
|
||||||
|
code: str
|
||||||
|
base: int # 当前基准价 (整数)
|
||||||
|
queue: list = field(default_factory=list) # 持仓队列: 每格成本价
|
||||||
|
entry_date: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def compute_initial_position(close: float) -> Tuple[int, list]:
|
||||||
|
"""初始建仓: base=ceil(close), queue=[10, 9, ..., base] 且 >= close"""
|
||||||
|
base = math.ceil(close)
|
||||||
|
base = max(GRID_LOWER, min(base, GRID_UPPER))
|
||||||
|
queue = [g for g in range(GRID_UPPER, base - 1, -1) if g >= close]
|
||||||
|
return base, queue
|
||||||
|
|
||||||
|
|
||||||
|
def compute_single_position(close: float) -> Tuple[int, list]:
|
||||||
|
"""单格建仓"""
|
||||||
|
base = math.ceil(close)
|
||||||
|
base = max(GRID_LOWER, min(base, GRID_UPPER))
|
||||||
|
return base, [base]
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_grid_day(base, queue, open_p, high_p, low_p, close_p, can_buy=True):
|
||||||
|
"""单日网格交易 (LIFO)"""
|
||||||
|
trades = []
|
||||||
|
new_base, new_queue = base, list(queue)
|
||||||
|
|
||||||
|
# 1) 买
|
||||||
|
buy_price = new_base - 1
|
||||||
|
if low_p <= buy_price and new_base > GRID_LOWER and can_buy:
|
||||||
|
new_base = buy_price
|
||||||
|
new_queue.append(buy_price)
|
||||||
|
trades.append({"direction": "buy", "price": buy_price, "shares": SHARES_PER_GRID, "pnl": 0.0})
|
||||||
|
|
||||||
|
# 2) 卖 (循环)
|
||||||
|
while True:
|
||||||
|
sell_price = new_base + 1
|
||||||
|
if high_p >= sell_price and new_queue:
|
||||||
|
buy_cost = new_queue.pop()
|
||||||
|
pnl = (sell_price - buy_cost) * SHARES_PER_GRID
|
||||||
|
trades.append({"direction": "sell", "price": sell_price, "shares": SHARES_PER_GRID, "pnl": pnl})
|
||||||
|
new_base = sell_price
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return new_base, new_queue, trades
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 2. 5 特征沉寂检测
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def is_slumbering(df: pd.DataFrame, lookback_60=60, lookback_20=20,
|
||||||
|
min_triggers=SLUMBER_TRIGGERS) -> bool:
|
||||||
|
"""检测一只股是否陷入'沉寂' (资金离场后长期低位震荡).
|
||||||
|
5 特征, >= min_triggers 触发.
|
||||||
|
"""
|
||||||
|
if df is None or len(df) < lookback_60:
|
||||||
|
return False
|
||||||
|
sub = df.tail(lookback_60)
|
||||||
|
close = sub["close"].values
|
||||||
|
high = sub["high"].values
|
||||||
|
low = sub["low"].values
|
||||||
|
vol = sub["volume"].values
|
||||||
|
|
||||||
|
# 1. 波动率塌陷
|
||||||
|
log_ret = np.log(close[1:] / close[:-1])
|
||||||
|
if len(log_ret) < lookback_20:
|
||||||
|
return False
|
||||||
|
vol_20d = float(np.std(log_ret[-lookback_20:], ddof=1))
|
||||||
|
vol_60d = float(np.std(log_ret, ddof=1))
|
||||||
|
vol_collapse = (vol_60d > 0) and (vol_20d / vol_60d < 0.6)
|
||||||
|
|
||||||
|
# 2. 振幅萎缩
|
||||||
|
amp_20d = float(np.mean((high[-lookback_20:] - low[-lookback_20:]) / close[-lookback_20:]) * 100)
|
||||||
|
amp_shrink = amp_20d < 2.5
|
||||||
|
|
||||||
|
# 3. 成交量枯竭
|
||||||
|
avg_vol_20 = float(np.mean(vol[-lookback_20:]))
|
||||||
|
avg_vol_60 = float(np.mean(vol))
|
||||||
|
vol_dry = (avg_vol_60 > 0) and (avg_vol_20 / avg_vol_60 < 0.5)
|
||||||
|
|
||||||
|
# 4. 价格弱势
|
||||||
|
price_max_60 = float(np.max(close))
|
||||||
|
price_weak = price_max_60 > 0 and (close[-1] / price_max_60) < 0.85
|
||||||
|
|
||||||
|
# 5. 反弹失败
|
||||||
|
recent_high_30 = float(np.max(high[-30:]))
|
||||||
|
past_high_60 = float(np.max(high))
|
||||||
|
rebound_fail = past_high_60 > 0 and (recent_high_30 / past_high_60) < 0.95
|
||||||
|
|
||||||
|
triggers = [vol_collapse, amp_shrink, vol_dry, price_weak, rebound_fail]
|
||||||
|
return sum(triggers) >= min_triggers
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 3. 模型加载 + 三件套预测
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class ModelBundle:
|
||||||
|
"""v6.7r3 三件套模型封装"""
|
||||||
|
def __init__(self, model_dir: str | Path):
|
||||||
|
model_dir = Path(model_dir)
|
||||||
|
with open(model_dir / "rank_lambdarank.pkl", "rb") as f:
|
||||||
|
rank_b = pickle.load(f)
|
||||||
|
with open(model_dir / "top_v67r2.pkl", "rb") as f:
|
||||||
|
top_b = pickle.load(f)
|
||||||
|
with open(model_dir / "stacking_v67r2.pkl", "rb") as f:
|
||||||
|
stack_b = pickle.load(f)
|
||||||
|
|
||||||
|
self.rank_model = rank_b["model"]
|
||||||
|
self.rank_feats = rank_b["feat_names"] # 56 维
|
||||||
|
self.top_model = top_b["model"]
|
||||||
|
self.top_feats = top_b["feat_names"] # 53 维
|
||||||
|
self.stack_model = stack_b["model"]
|
||||||
|
self.stack_feats = stack_b["feat_names"] # 55 维
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_predict_proba(model, X):
|
||||||
|
if hasattr(model, "predict_proba"):
|
||||||
|
return model.predict_proba(X)[:, 1]
|
||||||
|
return model.predict(X, raw_score=False)
|
||||||
|
|
||||||
|
def predict(self, X56: np.ndarray) -> dict:
|
||||||
|
"""输入 56 维特征矩阵 (n, 56), 返回三件套预测 dict.
|
||||||
|
返回: rank_score (n,), top_prob (n,), stack_prob (n,)
|
||||||
|
"""
|
||||||
|
rank_pred = self.rank_model.predict(X56)
|
||||||
|
|
||||||
|
# 52 维基础特征在 X56 中的索引
|
||||||
|
base_52_idx = [self.rank_feats.index(f) for f in self.top_feats if f != "rank_predicted_rounds"]
|
||||||
|
X53 = np.column_stack([X56[:, base_52_idx], rank_pred])
|
||||||
|
top_prob = self._safe_predict_proba(self.top_model, X53)
|
||||||
|
|
||||||
|
X55 = np.column_stack([X53, top_prob])
|
||||||
|
stack_prob = self._safe_predict_proba(self.stack_model, X55)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rank_score": rank_pred,
|
||||||
|
"top_prob": top_prob,
|
||||||
|
"stack_prob": stack_prob,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 4. 特征计算 (从项目 core/features.py 抽取, 关键函数)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _log_returns(close: np.ndarray) -> np.ndarray:
|
||||||
|
return np.log(close[1:] / close[:-1])
|
||||||
|
|
||||||
|
|
||||||
|
def _ema(arr: np.ndarray, period: int) -> np.ndarray:
|
||||||
|
"""指数移动平均"""
|
||||||
|
alpha = 2.0 / (period + 1)
|
||||||
|
out = np.zeros_like(arr)
|
||||||
|
out[0] = arr[0]
|
||||||
|
for i in range(1, len(arr)):
|
||||||
|
out[i] = alpha * arr[i] + (1 - alpha) * out[i-1]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def compute_52_base_features(df: pd.DataFrame, total_share: Optional[float] = None) -> dict:
|
||||||
|
"""计算 v3.4 的 52 维基础特征 (简化版, 不完全等同于原版).
|
||||||
|
注: 完整版在 core/features.py, 这里用 pandas/numpy 简化.
|
||||||
|
"""
|
||||||
|
n = len(df)
|
||||||
|
if n < 60:
|
||||||
|
return None
|
||||||
|
|
||||||
|
close = df["close"].values
|
||||||
|
high = df["high"].values
|
||||||
|
low = df["low"].values
|
||||||
|
open_ = df["open"].values
|
||||||
|
vol = df["volume"].values
|
||||||
|
|
||||||
|
feats = {}
|
||||||
|
|
||||||
|
# 1. rolling_grid_ratio_20d
|
||||||
|
feats["rolling_grid_ratio_20d"] = float(np.mean((close[-20:] >= 1) & (close[-20:] <= 11)) * 100)
|
||||||
|
|
||||||
|
# 2. cross_freq_20d
|
||||||
|
diff = close[1:] - close[:-1]
|
||||||
|
sign_change = np.sum(np.abs(np.diff(np.sign(diff[-19:]))) > 0)
|
||||||
|
feats["cross_freq_20d"] = float(sign_change / 19 * 100) if 19 > 0 else 0.0
|
||||||
|
|
||||||
|
# 3. avg_daily_amp
|
||||||
|
feats["avg_daily_amp"] = float(np.mean((high - low) / open_) * 100) if n > 0 else 0.0
|
||||||
|
|
||||||
|
# 4. high_amp_days
|
||||||
|
feats["high_amp_days"] = float(np.mean((high - low) / open_ > 0.02) * 100) if n > 0 else 0.0
|
||||||
|
|
||||||
|
# 5. atr_pct
|
||||||
|
tr = np.maximum(high - low, np.maximum(np.abs(high - np.roll(close, 1)),
|
||||||
|
np.abs(low - np.roll(close, 1))))
|
||||||
|
feats["atr_pct"] = float(np.mean(tr[-14:]) / close[-1] * 100) if close[-1] > 0 else 0.0
|
||||||
|
|
||||||
|
# 6. volatility_20d (年化)
|
||||||
|
log_ret = _log_returns(close)
|
||||||
|
feats["volatility_20d"] = float(np.std(log_ret[-20:], ddof=1) * np.sqrt(252) * 100) if len(log_ret) >= 20 else 0.0
|
||||||
|
|
||||||
|
# 7. price_cv
|
||||||
|
feats["price_cv"] = float(np.std(close, ddof=1) / np.mean(close) * 100) if n > 1 and np.mean(close) > 0 else 0.0
|
||||||
|
|
||||||
|
# 8. bb_width (布林带宽)
|
||||||
|
ma20 = np.mean(close[-20:])
|
||||||
|
sd20 = np.std(close[-20:], ddof=1)
|
||||||
|
feats["bb_width"] = float((4 * sd20) / ma20 * 100) if ma20 > 0 else 0.0
|
||||||
|
|
||||||
|
# 9. volume_ratio
|
||||||
|
avg_vol_20 = float(np.mean(vol[-20:])) if n >= 20 else float(np.mean(vol))
|
||||||
|
avg_vol_60 = float(np.mean(vol[-60:])) if n >= 60 else float(np.mean(vol))
|
||||||
|
feats["volume_ratio"] = avg_vol_20 / avg_vol_60 if avg_vol_60 > 0 else 0.0
|
||||||
|
|
||||||
|
# 10. obv_slope
|
||||||
|
direction = np.sign(np.diff(close))
|
||||||
|
direction = np.concatenate([[0], direction])
|
||||||
|
obv = np.cumsum(direction * vol)
|
||||||
|
if len(obv) >= 20:
|
||||||
|
x = np.arange(20)
|
||||||
|
y = obv[-20:]
|
||||||
|
feats["obv_slope"] = float((np.polyfit(x, y, 1)[0]) / (np.mean(np.abs(y)) + 1e-10))
|
||||||
|
else:
|
||||||
|
feats["obv_slope"] = 0.0
|
||||||
|
|
||||||
|
# ... (其他 42 维特征省略, 完整版在 core/features.py)
|
||||||
|
# 这里只展示 10 个核心特征的计算模式
|
||||||
|
# 实际部署时建议直接调用项目 core.features.calculate_features
|
||||||
|
|
||||||
|
return feats
|
||||||
|
|
||||||
|
|
||||||
|
def compute_4_v67_new_features(df: pd.DataFrame, fd: dict) -> dict:
|
||||||
|
"""计算 v6.7 新增的 4 维特征"""
|
||||||
|
n = len(df)
|
||||||
|
if n < 60:
|
||||||
|
return fd
|
||||||
|
|
||||||
|
close = df["close"].values
|
||||||
|
high = df["high"].values
|
||||||
|
low = df["low"].values
|
||||||
|
|
||||||
|
# 53. vol_decay_5d
|
||||||
|
log_ret = np.log(close[1:] / close[:-1])
|
||||||
|
if len(log_ret) >= 20:
|
||||||
|
vol_5d = float(np.std(log_ret[-5:], ddof=1))
|
||||||
|
vol_20d = float(np.std(log_ret[-20:], ddof=1))
|
||||||
|
fd["vol_decay_5d"] = vol_5d / vol_20d if vol_20d > 0 else 0.0
|
||||||
|
else:
|
||||||
|
fd["vol_decay_5d"] = 0.0
|
||||||
|
|
||||||
|
# 54. grid_touch_relative_10d
|
||||||
|
if n >= 60:
|
||||||
|
range_10d = float(np.max(high[-10:]) - np.min(low[-10:]))
|
||||||
|
range_60d = float(np.max(high[-60:]) - np.min(low[-60:]))
|
||||||
|
close_now = float(close[-1])
|
||||||
|
close_60d_mean = float(np.mean(close[-60:]))
|
||||||
|
if close_now > 0 and close_60d_mean > 0 and range_60d > 0:
|
||||||
|
fd["grid_touch_relative_10d"] = (range_10d / close_now) / (range_60d / close_60d_mean)
|
||||||
|
else:
|
||||||
|
fd["grid_touch_relative_10d"] = 0.0
|
||||||
|
else:
|
||||||
|
fd["grid_touch_relative_10d"] = 0.0
|
||||||
|
|
||||||
|
# 55. vol_decay_x_grid_balance
|
||||||
|
fd["vol_decay_x_grid_balance"] = fd.get("vol_decay_5d", 0.0) * fd.get("grid_room_balance", 0.0)
|
||||||
|
|
||||||
|
# 56. vol_decay_x_dist_lower
|
||||||
|
fd["vol_decay_x_dist_lower"] = fd.get("vol_decay_5d", 0.0) * fd.get("dist_to_grid_lower", 0.0)
|
||||||
|
|
||||||
|
return fd
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 5. 评分池(单日)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def score_pool(date: pd.Timestamp,
|
||||||
|
kline_cache: Dict[str, pd.DataFrame],
|
||||||
|
models: ModelBundle,
|
||||||
|
feat_names: list) -> pd.DataFrame:
|
||||||
|
"""对所有有 120 日历史的股算 v6.7 三件套预测.
|
||||||
|
返回 DataFrame: code6, latest_close, rank_score, top_prob, stack_prob
|
||||||
|
"""
|
||||||
|
PRICE_MIN, PRICE_MAX = 1.0, 11.0
|
||||||
|
rows = []
|
||||||
|
codes = []
|
||||||
|
closes = []
|
||||||
|
|
||||||
|
for code, df in kline_cache.items():
|
||||||
|
sub = df[df["date"] <= date]
|
||||||
|
if len(sub) < 120:
|
||||||
|
continue
|
||||||
|
latest = float(sub["close"].iloc[-1])
|
||||||
|
if not (PRICE_MIN <= latest <= PRICE_MAX):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 算 56 维特征
|
||||||
|
obs = sub.tail(120).reset_index(drop=True)
|
||||||
|
fd = compute_52_base_features(obs)
|
||||||
|
if fd is None:
|
||||||
|
continue
|
||||||
|
fd = compute_4_v67_new_features(obs, fd)
|
||||||
|
try:
|
||||||
|
X = np.array([fd.get(k, 0.0) for k in feat_names], dtype=np.float64).reshape(1, -1)
|
||||||
|
pred = models.predict(X)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
rows.append(pred)
|
||||||
|
codes.append(code)
|
||||||
|
closes.append(latest)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return pd.DataFrame(columns=["code6", "latest_close", "rank_score", "top_prob", "stack_prob"])
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
return pd.DataFrame({
|
||||||
|
"code6": codes,
|
||||||
|
"latest_close": closes,
|
||||||
|
"rank_score": [r["rank_score"][0] for r in rows],
|
||||||
|
"top_prob": [r["top_prob"][0] for r in rows],
|
||||||
|
"stack_prob": [r["stack_prob"][0] for r in rows],
|
||||||
|
}).sort_values("stack_prob", ascending=False).reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 6. 主回测入口(精简版, 仅展示核心逻辑)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def quick_backtest(kline_cache: Dict[str, pd.DataFrame],
|
||||||
|
models: ModelBundle,
|
||||||
|
start_date: str = "2023-05-01",
|
||||||
|
end_date: str = "2026-04-30",
|
||||||
|
weekly_elim_n: int = WEEKLY_ELIM_N,
|
||||||
|
slumber_days: int = SLUMBER_DAYS) -> dict:
|
||||||
|
"""精简版 3 年回测 (生产级完整版见 tools/backtest_v67r2.py).
|
||||||
|
|
||||||
|
核心流程:
|
||||||
|
1. 初始建仓
|
||||||
|
2. 每日: 网格交易 + 沉寂检测
|
||||||
|
3. 周五: 周度评分淘汰 + 补仓
|
||||||
|
4. 月末: 清仓补入 (触及 11 元)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 总收益率, 年化夏普, 最大回撤, 周胜率, 终值
|
||||||
|
"""
|
||||||
|
feat_names = models.rank_feats
|
||||||
|
|
||||||
|
# 交易日索引
|
||||||
|
sample = next(iter(kline_cache.values()))
|
||||||
|
all_dates = pd.DatetimeIndex(sorted(sample["date"].unique()))
|
||||||
|
mask = (all_dates >= start_date) & (all_dates <= end_date)
|
||||||
|
backtest_dates = all_dates[mask]
|
||||||
|
|
||||||
|
# 初始评分 (INIT_SELECT_DATE)
|
||||||
|
init_date = pd.Timestamp("2023-04-28")
|
||||||
|
init_pool = score_pool(init_date, kline_cache, models, feat_names)
|
||||||
|
init_pool = init_pool[init_pool["latest_close"].apply(lambda p: MIN_BUY_PRICE <= p <= MAX_BUY_PRICE)]
|
||||||
|
init_pool = init_pool.sort_values("stack_prob", ascending=False).head(TOP_N)
|
||||||
|
|
||||||
|
# 初始建仓
|
||||||
|
positions: Dict[str, Position] = {}
|
||||||
|
cash = INITIAL_CASH
|
||||||
|
for _, row in init_pool.iterrows():
|
||||||
|
code = row["code6"]
|
||||||
|
actual_close = float(row["latest_close"])
|
||||||
|
base, grid_queue = compute_initial_position(actual_close)
|
||||||
|
if not grid_queue:
|
||||||
|
continue
|
||||||
|
positions[code] = Position(code, base, [actual_close] * len(grid_queue), "2023-04-28")
|
||||||
|
for _ in grid_queue:
|
||||||
|
cash -= actual_close * SHARES_PER_GRID
|
||||||
|
|
||||||
|
# 周度淘汰历史
|
||||||
|
top50_history = defaultdict(list)
|
||||||
|
slumber_streak: Dict[str, int] = {}
|
||||||
|
total_asset_history = []
|
||||||
|
weekly_pnl = []
|
||||||
|
|
||||||
|
for i, cur_date in enumerate(backtest_dates):
|
||||||
|
cur_str = cur_date.strftime("%Y-%m-%d")
|
||||||
|
is_week_end = (i == len(backtest_dates) - 1) or (backtest_dates[i + 1].week != cur_date.week)
|
||||||
|
|
||||||
|
# === 网格日间交易 ===
|
||||||
|
for code, pos in list(positions.items()):
|
||||||
|
if not pos.queue:
|
||||||
|
continue
|
||||||
|
df = kline_cache[code]
|
||||||
|
sub = df[df["date"] == cur_date]
|
||||||
|
if sub.empty:
|
||||||
|
continue
|
||||||
|
row = sub.iloc[0]
|
||||||
|
new_base, new_queue, day_trades = simulate_grid_day(
|
||||||
|
pos.base, pos.queue,
|
||||||
|
float(row["open"]), float(row["high"]), float(row["low"]), float(row["close"]),
|
||||||
|
)
|
||||||
|
if day_trades:
|
||||||
|
pos.base, pos.queue = new_base, new_queue
|
||||||
|
for t in day_trades:
|
||||||
|
if t["direction"] == "buy":
|
||||||
|
cash -= t["price"] * t["shares"]
|
||||||
|
else:
|
||||||
|
cash += t["price"] * t["shares"]
|
||||||
|
|
||||||
|
# === 沉寂检测 ===
|
||||||
|
for code, pos in list(positions.items()):
|
||||||
|
if not pos.queue:
|
||||||
|
continue
|
||||||
|
df = kline_cache[code]
|
||||||
|
sub = df[df["date"] <= cur_date]
|
||||||
|
if len(sub) < 60:
|
||||||
|
continue
|
||||||
|
slumber = is_slumbering(sub)
|
||||||
|
slumber_streak[code] = slumber_streak.get(code, 0) + 1 if slumber else 0
|
||||||
|
if slumber_streak[code] >= slumber_days and pos.queue:
|
||||||
|
# 全仓清仓
|
||||||
|
px = float(sub["close"].iloc[-1])
|
||||||
|
cash += px * len(pos.queue) * SHARES_PER_GRID
|
||||||
|
positions[code] = Position(code, 0, [], cur_str)
|
||||||
|
slumber_streak[code] = 0
|
||||||
|
|
||||||
|
# === 资产快照 ===
|
||||||
|
mv = sum((float(kline_cache[c][kline_cache[c]["date"] <= cur_date]["close"].iloc[-1])
|
||||||
|
* len(p.queue) * SHARES_PER_GRID)
|
||||||
|
for c, p in positions.items() if p.queue)
|
||||||
|
total_asset = cash + mv
|
||||||
|
total_asset_history.append(total_asset)
|
||||||
|
|
||||||
|
# === 周度淘汰 + 补仓 ===
|
||||||
|
if is_week_end and i > 0 and weekly_elim_n > 0:
|
||||||
|
pool = score_pool(cur_date, kline_cache, models, feat_names)
|
||||||
|
top50 = set(pool.head(TOP_MODEL_N)["code6"].tolist())
|
||||||
|
for code in list(positions.keys()):
|
||||||
|
top50_history[code].append(code in top50)
|
||||||
|
|
||||||
|
# 连续 N 周不在 top 50 → 卖出
|
||||||
|
inactive = []
|
||||||
|
for code, p in positions.items():
|
||||||
|
if not p.queue:
|
||||||
|
continue
|
||||||
|
hist = top50_history.get(code, [])
|
||||||
|
if len(hist) >= weekly_elim_n and all(x is False for x in hist[-weekly_elim_n:]):
|
||||||
|
inactive.append(code)
|
||||||
|
for code in inactive[:1]: # 每月最多淘汰 1 只 (与 v6.3 一致)
|
||||||
|
pos = positions[code]
|
||||||
|
sub = kline_cache[code][kline_cache[code]["date"] <= cur_date]
|
||||||
|
px = float(sub["close"].iloc[-1])
|
||||||
|
cash += px * len(pos.queue) * SHARES_PER_GRID
|
||||||
|
positions[code] = Position(code, 0, [], cur_str)
|
||||||
|
|
||||||
|
# 补仓
|
||||||
|
positions = {c: p for c, p in positions.items() if p.queue}
|
||||||
|
refill_needed = max(0, TOP_N - len(positions))
|
||||||
|
if refill_needed > 0:
|
||||||
|
ref_pool = pool[pool["latest_close"].apply(lambda p: REFILL_MIN_PRICE < p < REFILL_MAX_PRICE)]
|
||||||
|
ref_pool = ref_pool[~ref_pool["code6"].isin(positions.keys())]
|
||||||
|
ref_pool = ref_pool.head(refill_needed)
|
||||||
|
for _, row in ref_pool.iterrows():
|
||||||
|
code = row["code6"]
|
||||||
|
actual_close = float(row["latest_close"])
|
||||||
|
base, grid_queue = compute_single_position(actual_close)
|
||||||
|
if not grid_queue:
|
||||||
|
continue
|
||||||
|
cost = actual_close * SHARES_PER_GRID * len(grid_queue)
|
||||||
|
if cash < cost:
|
||||||
|
continue
|
||||||
|
positions[code] = Position(code, base, [actual_close] * len(grid_queue), cur_str)
|
||||||
|
cash -= cost
|
||||||
|
|
||||||
|
# 计算指标
|
||||||
|
final_value = total_asset_history[-1] if total_asset_history else INITIAL_CASH
|
||||||
|
total_return = final_value / INITIAL_CASH - 1
|
||||||
|
rets = np.diff(total_asset_history) / total_asset_history[:-1]
|
||||||
|
sharpe = float(rets.mean() / rets.std() * np.sqrt(52)) if len(rets) > 1 and rets.std() > 0 else 0.0
|
||||||
|
cum_max = np.maximum.accumulate(total_asset_history)
|
||||||
|
dd = (np.array(total_asset_history) - cum_max) / cum_max
|
||||||
|
max_dd = float(dd.min())
|
||||||
|
win_rate = float((rets > 0).mean()) if len(rets) > 0 else 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_return_pct": round(total_return * 100, 2),
|
||||||
|
"annual_sharpe": round(sharpe, 4),
|
||||||
|
"max_drawdown_pct": round(max_dd * 100, 2),
|
||||||
|
"weekly_win_rate_pct": round(win_rate * 100, 2),
|
||||||
|
"final_value": round(final_value, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 7. 入口示例
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 1. 加载模型
|
||||||
|
models = ModelBundle("models") # 默认从当前目录的 models/ 加载
|
||||||
|
print(f"✓ 加载模型: rank {len(models.rank_feats)} 维, "
|
||||||
|
f"top {len(models.top_feats)} 维, stack {len(models.stack_feats)} 维")
|
||||||
|
|
||||||
|
# 2. 加载行情 (示例: 从 parquet 目录)
|
||||||
|
# 实际部署时, 从 market_data.kline_stock (Postgres) 或本地 parquet 加载
|
||||||
|
from pathlib import Path
|
||||||
|
parquet_dir = Path("data/market_data/share")
|
||||||
|
kline_cache = {}
|
||||||
|
for p in parquet_dir.glob("*.parquet"):
|
||||||
|
df = pd.read_parquet(p)
|
||||||
|
df["date"] = pd.to_datetime(df["date"])
|
||||||
|
kline_cache[p.stem] = df
|
||||||
|
print(f"✓ 加载行情: {len(kline_cache)} 只股")
|
||||||
|
|
||||||
|
# 3. 跑精简版回测
|
||||||
|
metrics = quick_backtest(kline_cache, models)
|
||||||
|
print("\n=== v6.7r3 三年回测结果 (精简版) ===")
|
||||||
|
for k, v in metrics.items():
|
||||||
|
print(f" {k}: {v}")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
date,code,exit_price,realized_pnl,slumber_streak_days
|
||||||
|
2023-07-21,920870,7.53,-374.0,10
|
||||||
|
2023-07-25,920414,9.44,-1.9999999999999574,10
|
||||||
|
2024-03-27,920641,7.56,-734.0000000000001,10
|
||||||
|
2024-04-03,920001,8.93,-164.00000000000006,10
|
||||||
|
2024-05-31,603825,8.36,-130.00000000000006,10
|
||||||
|
2024-09-06,300462,8.98,-16.000000000000014,10
|
||||||
|
2024-09-11,920641,6.67,-1404.0,10
|
||||||
|
2024-12-23,920090,6.48,-1365.9999999999995,10
|
||||||
|
2024-12-25,920021,5.71,-1352.0,10
|
||||||
|
2025-01-13,920792,8.58,-107.99999999999983,10
|
||||||
|
2025-01-22,920371,6.9,-1199.9999999999998,10
|
||||||
|
2025-01-24,920339,7.86,-571.9999999999997,10
|
||||||
|
2025-01-27,920792,8.99,-85.99999999999994,10
|
||||||
|
2025-02-05,920810,8.18,-217.99999999999997,10
|
||||||
|
2025-03-13,002789,7.35,-942.0,10
|
||||||
|
2025-05-21,300052,10.3,258.00000000000017,10
|
||||||
|
2025-05-26,920639,9.54,185.9999999999996,10
|
||||||
|
2025-05-26,920553,10.17,94.00000000000013,10
|
||||||
|
2025-06-12,300052,9.94,77.99999999999976,10
|
||||||
|
2025-08-12,300798,9.11,-53.90000000000015,10
|
||||||
|
2025-09-26,000679,7.75,-501.99999999999994,10
|
||||||
|
2026-03-03,300086,8.81,-114.00000000000006,10
|
||||||
|
Binary file not shown.
@@ -0,0 +1,55 @@
|
|||||||
|
feature,importance,importance_pct
|
||||||
|
rank_predicted_rounds,1252,20.87
|
||||||
|
top_elite_prob,1163,19.38
|
||||||
|
ma20_deviation_pct,321,5.35
|
||||||
|
atr_pct,296,4.93
|
||||||
|
dist_to_grid_lower,225,3.75
|
||||||
|
price_cv,190,3.17
|
||||||
|
amount_mean_20d,183,3.05
|
||||||
|
drawdown_60d,158,2.63
|
||||||
|
obv_slope,145,2.42
|
||||||
|
dist_to_grid_upper,142,2.37
|
||||||
|
ma20_ma60_gap_pct,141,2.35
|
||||||
|
range_position_60d,124,2.07
|
||||||
|
trend_slope_60d,123,2.05
|
||||||
|
rebound_from_low_60d,114,1.9
|
||||||
|
intraday_trend_strength,98,1.63
|
||||||
|
volume_ratio,98,1.63
|
||||||
|
volume_cv_20d,86,1.43
|
||||||
|
ma60_deviation_pct,81,1.35
|
||||||
|
grid_room_balance,69,1.15
|
||||||
|
range_compression_20d,69,1.15
|
||||||
|
volatility_20d,64,1.07
|
||||||
|
amount_trend_20d,56,0.93
|
||||||
|
near_upper_boundary_risk,54,0.9
|
||||||
|
up_days_20d,50,0.83
|
||||||
|
avg_daily_amp,48,0.8
|
||||||
|
amplitude_cv,48,0.8
|
||||||
|
amount_cv_20d,44,0.73
|
||||||
|
small_cap_premium,42,0.7
|
||||||
|
near_lower_boundary_risk,42,0.7
|
||||||
|
high_amp_days,40,0.67
|
||||||
|
cross_freq_x_bb,37,0.62
|
||||||
|
amp_x_grid,37,0.62
|
||||||
|
bb_width,36,0.6
|
||||||
|
amp_x_grid_vol,36,0.6
|
||||||
|
ln_float_mv,33,0.55
|
||||||
|
price_entropy,28,0.47
|
||||||
|
turnover_proxy_20d,26,0.43
|
||||||
|
trend_slope_20d,25,0.42
|
||||||
|
mv_vol_interact,25,0.42
|
||||||
|
wick_ratio_20d,22,0.37
|
||||||
|
grid_touch_count_60d,21,0.35
|
||||||
|
amp_cv_x_entropy,21,0.35
|
||||||
|
grid_touch_count_20d,20,0.33
|
||||||
|
rolling_grid_ratio_20d,14,0.23
|
||||||
|
near_grid_line_ratio_20d,12,0.2
|
||||||
|
usable_grid_count_upper,9,0.15
|
||||||
|
down_days_20d,9,0.15
|
||||||
|
trend_abs_slope_20d,7,0.12
|
||||||
|
cross_freq_20d,6,0.1
|
||||||
|
usable_grid_count_lower,5,0.08
|
||||||
|
low_volume_days_20d,3,0.05
|
||||||
|
close_reversal_count_20d,1,0.02
|
||||||
|
trend_consistency_20d,1,0.02
|
||||||
|
grid_cross_density_20d,0,0.0
|
||||||
|
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"rank": {
|
||||||
|
"v6.6": {
|
||||||
|
"spearman_on_val": 0.5741312551267312
|
||||||
|
},
|
||||||
|
"v6.7r2": {
|
||||||
|
"spearman_on_val": 0.5896180660523218
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"top": {
|
||||||
|
"v6.6": {
|
||||||
|
"pr_auc_val": 0.6881080916474673
|
||||||
|
},
|
||||||
|
"v6.7r2": {
|
||||||
|
"pr_auc_val": 0.6953262363660502
|
||||||
|
},
|
||||||
|
"delta": 0.007218144718582842
|
||||||
|
},
|
||||||
|
"stacking": {
|
||||||
|
"v6.6": {
|
||||||
|
"pr_auc_val": 0.6474384440874665,
|
||||||
|
"optimal_threshold": 0.32116277663299964,
|
||||||
|
"f1_at_thr": 0.6333791329260092
|
||||||
|
},
|
||||||
|
"v6.7r2": {
|
||||||
|
"pr_auc_val": 0.6640333379409579,
|
||||||
|
"optimal_threshold": 0.3311218467281351,
|
||||||
|
"f1_at_thr": 0.6402777365656805
|
||||||
|
},
|
||||||
|
"delta_pr_auc": 0.016594893853491333
|
||||||
|
},
|
||||||
|
"n_train": 254234,
|
||||||
|
"n_val": 73785,
|
||||||
|
"elite_rate_train": 0.2665890478850193,
|
||||||
|
"elite_rate_val": 0.19013349596801518
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,54 @@
|
|||||||
|
feature,importance,importance_pct
|
||||||
|
rank_predicted_rounds,2126,6.86
|
||||||
|
amount_mean_20d,1476,4.76
|
||||||
|
price_cv,1376,4.44
|
||||||
|
range_compression_20d,1165,3.76
|
||||||
|
atr_pct,1105,3.56
|
||||||
|
drawdown_60d,1090,3.52
|
||||||
|
volume_ratio,1030,3.32
|
||||||
|
amount_trend_20d,998,3.22
|
||||||
|
price_entropy,888,2.86
|
||||||
|
rebound_from_low_60d,872,2.81
|
||||||
|
ma20_deviation_pct,868,2.8
|
||||||
|
obv_slope,842,2.72
|
||||||
|
trend_slope_60d,827,2.67
|
||||||
|
wick_ratio_20d,800,2.58
|
||||||
|
ma20_ma60_gap_pct,768,2.48
|
||||||
|
amplitude_cv,767,2.47
|
||||||
|
dist_to_grid_lower,757,2.44
|
||||||
|
range_position_60d,747,2.41
|
||||||
|
amp_cv_x_entropy,714,2.3
|
||||||
|
bb_width,699,2.25
|
||||||
|
ma60_deviation_pct,691,2.23
|
||||||
|
volatility_20d,680,2.19
|
||||||
|
volume_cv_20d,657,2.12
|
||||||
|
intraday_trend_strength,650,2.1
|
||||||
|
avg_daily_amp,643,2.07
|
||||||
|
dist_to_grid_upper,629,2.03
|
||||||
|
trend_slope_20d,626,2.02
|
||||||
|
amp_x_grid_vol,551,1.78
|
||||||
|
amount_cv_20d,550,1.77
|
||||||
|
grid_room_balance,537,1.73
|
||||||
|
trend_abs_slope_20d,504,1.63
|
||||||
|
grid_touch_count_60d,480,1.55
|
||||||
|
cross_freq_x_bb,399,1.29
|
||||||
|
amp_x_grid,383,1.24
|
||||||
|
near_grid_line_ratio_20d,277,0.89
|
||||||
|
close_reversal_count_20d,267,0.86
|
||||||
|
high_amp_days,250,0.81
|
||||||
|
turnover_proxy_20d,243,0.78
|
||||||
|
ln_float_mv,235,0.76
|
||||||
|
low_volume_days_20d,228,0.74
|
||||||
|
mv_vol_interact,223,0.72
|
||||||
|
small_cap_premium,218,0.7
|
||||||
|
grid_touch_count_20d,218,0.7
|
||||||
|
up_days_20d,218,0.7
|
||||||
|
down_days_20d,209,0.67
|
||||||
|
near_upper_boundary_risk,165,0.53
|
||||||
|
trend_consistency_20d,105,0.34
|
||||||
|
near_lower_boundary_risk,87,0.28
|
||||||
|
cross_freq_20d,86,0.28
|
||||||
|
rolling_grid_ratio_20d,64,0.21
|
||||||
|
usable_grid_count_lower,6,0.02
|
||||||
|
usable_grid_count_upper,6,0.02
|
||||||
|
grid_cross_density_20d,0,0.0
|
||||||
|
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"version": "v6.7r3",
|
||||||
|
"feature_version": "v3.4",
|
||||||
|
"architecture": "stacking_calibrated",
|
||||||
|
"data_source": "mysql://100.121.118.116:3306/grid_seeker_model_base",
|
||||||
|
"training_date": "2026-06-24T11:00:00.000000",
|
||||||
|
"n_stocks_total": 5378,
|
||||||
|
"n_stocks_after_filter": 1533,
|
||||||
|
"n_training_samples": 254234,
|
||||||
|
"window_days": 120,
|
||||||
|
"future_days": 60,
|
||||||
|
"step_days": 20,
|
||||||
|
"y_rounds_mean": 0.1998384357465777,
|
||||||
|
"y_rounds_median": 0.0,
|
||||||
|
"y_rounds_zero_rate": 0.7108340511263267,
|
||||||
|
"elite_rate": 26.66,
|
||||||
|
"rank": {
|
||||||
|
"cv_mae": 0.2053,
|
||||||
|
"cv_r2": 0.2258,
|
||||||
|
"spearman": 0.5896,
|
||||||
|
"best_params": {
|
||||||
|
"num_leaves": 63,
|
||||||
|
"min_child_samples": 30,
|
||||||
|
"max_depth": 7
|
||||||
|
},
|
||||||
|
"n_features": 56
|
||||||
|
},
|
||||||
|
"top": {
|
||||||
|
"cv_pr_auc": 0.6953,
|
||||||
|
"best_params": {
|
||||||
|
"num_leaves": 63,
|
||||||
|
"min_child_samples": 30,
|
||||||
|
"max_depth": -1
|
||||||
|
},
|
||||||
|
"n_features": 53
|
||||||
|
},
|
||||||
|
"stacking": {
|
||||||
|
"cv_pr_auc": 0.6640,
|
||||||
|
"best_params": {
|
||||||
|
"num_leaves": 31,
|
||||||
|
"min_child_samples": 20,
|
||||||
|
"max_depth": -1
|
||||||
|
},
|
||||||
|
"n_features": 55,
|
||||||
|
"optimal_threshold": 0.33
|
||||||
|
},
|
||||||
|
"backtest": {
|
||||||
|
"start": "2023-05-04",
|
||||||
|
"end": "2026-04-30",
|
||||||
|
"total_return_pct": 151.92,
|
||||||
|
"annual_return_pct": 36.46,
|
||||||
|
"annual_sharpe": 1.7404,
|
||||||
|
"max_drawdown_pct": -12.10,
|
||||||
|
"weekly_win_rate_pct": 59.48,
|
||||||
|
"final_value": 151154,
|
||||||
|
"rebalancing_frequency": "weekly",
|
||||||
|
"elimination_window": "2_weeks"
|
||||||
|
},
|
||||||
|
"previous_version": "v6.6",
|
||||||
|
"previous_version_backup": "models_backup_20260624_110913"
|
||||||
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
with open('core/ui/flet/app_v2.py', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Patch 1: Add lock alongside _score_refreshing init
|
||||||
|
old_init = " self._score_refreshing = False # 刷新锁"
|
||||||
|
new_init = " self._score_refreshing = False\n self._score_lock = threading.Lock()"
|
||||||
|
content = content.replace(old_init, new_init, 1)
|
||||||
|
print('Patch 1 (init):', 'OK' if old_init not in content else 'NOT FOUND')
|
||||||
|
|
||||||
|
# Patch 2: _prev_day - use lock instead of flag guard
|
||||||
|
old_prev = ''' def _prev_day(_):
|
||||||
|
if self._score_refreshing:
|
||||||
|
return
|
||||||
|
self._score_cur_date -= timedelta(days=1)
|
||||||
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
|
self._score_refreshing = True
|
||||||
|
self._score_nav_btns[0].disabled = True
|
||||||
|
self._score_nav_btns[1].disabled = True
|
||||||
|
self._score_nav_btns[2].disabled = True
|
||||||
|
self._refresh_scoring()'''
|
||||||
|
new_prev = ''' def _prev_day(_):
|
||||||
|
if not self._score_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
self._score_cur_date -= timedelta(days=1)
|
||||||
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
|
self._score_date_label.update()
|
||||||
|
self._score_refreshing = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()
|
||||||
|
self._refresh_scoring()'''
|
||||||
|
content = content.replace(old_prev, new_prev, 1)
|
||||||
|
print('Patch 2 (_prev_day):', 'OK' if old_prev not in content else 'NOT FOUND')
|
||||||
|
|
||||||
|
# Patch 3: _next_day - use lock
|
||||||
|
old_next = ''' def _next_day(_):
|
||||||
|
if self._score_refreshing:
|
||||||
|
return
|
||||||
|
if self._score_cur_date >= self._score_max_date:
|
||||||
|
return
|
||||||
|
self._score_cur_date += timedelta(days=1)
|
||||||
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
|
self._score_refreshing = True
|
||||||
|
self._score_nav_btns[0].disabled = True
|
||||||
|
self._score_nav_btns[1].disabled = True
|
||||||
|
self._score_nav_btns[2].disabled = True
|
||||||
|
self._refresh_scoring()'''
|
||||||
|
new_next = ''' def _next_day(_):
|
||||||
|
if not self._score_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
if self._score_cur_date >= self._score_max_date:
|
||||||
|
self._score_lock.release()
|
||||||
|
return
|
||||||
|
self._score_cur_date += timedelta(days=1)
|
||||||
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
|
self._score_date_label.update()
|
||||||
|
self._score_refreshing = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()
|
||||||
|
self._refresh_scoring()'''
|
||||||
|
content = content.replace(old_next, new_next, 1)
|
||||||
|
print('Patch 3 (_next_day):', 'OK' if old_next not in content else 'NOT FOUND')
|
||||||
|
|
||||||
|
# Patch 4: _today - use lock
|
||||||
|
old_today = ''' def _today(_):
|
||||||
|
if self._score_refreshing:
|
||||||
|
return
|
||||||
|
if self._score_cur_date >= self._score_max_date:
|
||||||
|
return
|
||||||
|
self._score_cur_date = self._score_max_date
|
||||||
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
|
self._score_refreshing = True
|
||||||
|
self._score_nav_btns[0].disabled = True
|
||||||
|
self._score_nav_btns[1].disabled = True
|
||||||
|
self._score_nav_btns[2].disabled = True
|
||||||
|
self._refresh_scoring()'''
|
||||||
|
new_today = ''' def _today(_):
|
||||||
|
if not self._score_lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
if self._score_cur_date >= self._score_max_date:
|
||||||
|
self._score_lock.release()
|
||||||
|
return
|
||||||
|
self._score_cur_date = self._score_max_date
|
||||||
|
self._score_date_label.value = str(self._score_cur_date)
|
||||||
|
self._score_date_label.update()
|
||||||
|
self._score_refreshing = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = True
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()
|
||||||
|
self._refresh_scoring()'''
|
||||||
|
content = content.replace(old_today, new_today, 1)
|
||||||
|
print('Patch 4 (_today):', 'OK' if old_today not in content else 'NOT FOUND')
|
||||||
|
|
||||||
|
# Patch 5: _revert_nav_btns - release lock at end
|
||||||
|
old_revert = ''' def _revert_nav_btns(self):
|
||||||
|
"""重新启用导航按钮并解除刷新锁;已达上限日期时禁用'明天'和'今天'按钮"""
|
||||||
|
self._score_refreshing = False
|
||||||
|
at_max = self._score_cur_date >= self._score_max_date
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = False
|
||||||
|
if at_max:
|
||||||
|
self._score_nav_btns[1].disabled = True # next
|
||||||
|
self._score_nav_btns[2].disabled = True # today
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()'''
|
||||||
|
new_revert = ''' def _revert_nav_btns(self):
|
||||||
|
"""重新启用导航按钮并解除刷新锁;已达上限日期时禁用'明天'和'今天'按钮"""
|
||||||
|
self._score_refreshing = False
|
||||||
|
at_max = self._score_cur_date >= self._score_max_date
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.disabled = False
|
||||||
|
if at_max:
|
||||||
|
self._score_nav_btns[1].disabled = True # next
|
||||||
|
self._score_nav_btns[2].disabled = True # today
|
||||||
|
for btn in self._score_nav_btns:
|
||||||
|
btn.update()
|
||||||
|
self._score_lock.release()'''
|
||||||
|
content = content.replace(old_revert, new_revert, 1)
|
||||||
|
print('Patch 5 (_revert_nav_btns):', 'OK' if old_revert not in content else 'NOT FOUND')
|
||||||
|
|
||||||
|
with open('core/ui/flet/app_v2.py', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
print('Done writing')
|
||||||
+50
-10
@@ -1,12 +1,52 @@
|
|||||||
# coding:utf-8
|
# coding:utf-8
|
||||||
import tkinter as tk
|
"""
|
||||||
from core.main_entry import MainEntry
|
启动入口 — Flet UI
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import ssl
|
||||||
|
import traceback
|
||||||
|
|
||||||
# 这是应用的启动入口程序,负责初始化并启动主窗口。
|
# 修复 Windows 上 flet_desktop 子进程弹出控制台窗口的问题
|
||||||
# 它创建一个Tkinter根窗口,实例化主窗口类MainBoardWindow,
|
_original_popen = subprocess.Popen
|
||||||
# 并调用其run方法启动主事件循环。
|
|
||||||
if __name__ == "__main__":
|
class Popen(_original_popen):
|
||||||
import tkinter as tk
|
def __init__(self, *args, **kwargs):
|
||||||
root = tk.Tk()
|
if sys.platform == "win32" and "creationflags" not in kwargs:
|
||||||
app = MainEntry(root)
|
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
app.run()
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
subprocess.Popen = Popen
|
||||||
|
|
||||||
|
# PyInstaller 打包后,设置 FLET_VIEW_PATH 指向打包内的 Flet 客户端
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
base_path = sys._MEIPASS
|
||||||
|
flet_client_path = os.path.join(base_path, '.flet', 'client', 'flet-desktop-full-0.85.3')
|
||||||
|
flet_exe = os.path.join(flet_client_path, 'flet', 'flet.exe')
|
||||||
|
|
||||||
|
log_file = os.path.join(os.path.dirname(sys.executable), 'startup_log.txt')
|
||||||
|
with open(log_file, 'w') as f:
|
||||||
|
f.write(f'base_path: {base_path}\n')
|
||||||
|
f.write(f'flet_client_path: {flet_client_path}\n')
|
||||||
|
f.write(f'flet_exe: {flet_exe}\n')
|
||||||
|
f.write(f'exists: {os.path.exists(flet_exe)}\n')
|
||||||
|
if os.path.exists(flet_exe):
|
||||||
|
f.write('Setting FLET_VIEW_PATH\n')
|
||||||
|
os.environ['FLET_VIEW_PATH'] = flet_client_path
|
||||||
|
f.write(f'FLET_VIEW_PATH: {os.environ.get("FLET_VIEW_PATH")}\n')
|
||||||
|
|
||||||
|
if hasattr(ssl, '_create_unverified_context'):
|
||||||
|
ssl._create_default_https_context = ssl._create_unverified_context
|
||||||
|
|
||||||
|
def excepthook(type, value, tb):
|
||||||
|
log_file = os.path.join(os.path.dirname(sys.executable), 'error_log.txt')
|
||||||
|
with open(log_file, 'w') as f:
|
||||||
|
f.write(''.join(traceback.format_exception(type, value, tb)))
|
||||||
|
sys.__excepthook__(type, value, tb)
|
||||||
|
|
||||||
|
sys.excepthook = excepthook
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
from core.ui.flet.app_v2 import run
|
||||||
|
run()
|
||||||
|
|||||||
+4
-4
@@ -4,7 +4,7 @@ a = Analysis(
|
|||||||
['starter.py'],
|
['starter.py'],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[('config.ini', '.'), ('xtquant/xtdata.ini', 'xtquant')], # 明确包含配置文件和xtdata.ini
|
datas=[('xtquant/xtdata.ini', 'xtquant'), ('flet_desktop/app', 'flet_desktop/app')], # xtdata 依赖的配置文件
|
||||||
hiddenimports=['brotli', 'brotli.encoding'],
|
hiddenimports=['brotli', 'brotli.encoding'],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
@@ -24,8 +24,8 @@ exe = EXE(
|
|||||||
name='神之一手',
|
name='神之一手',
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=True, # 去除调试符号
|
strip=False,
|
||||||
upx=True,
|
upx=False,
|
||||||
upx_exclude=[],
|
upx_exclude=[],
|
||||||
runtime_tmpdir=None,
|
runtime_tmpdir=None,
|
||||||
console=False,
|
console=False,
|
||||||
@@ -34,5 +34,5 @@ exe = EXE(
|
|||||||
target_arch=None,
|
target_arch=None,
|
||||||
codesign_identity=None,
|
codesign_identity=None,
|
||||||
entitlements_file=None,
|
entitlements_file=None,
|
||||||
icon='logo.png' # 添加图标文件
|
icon='logo.ico',
|
||||||
)
|
)
|
||||||
Reference in New Issue
Block a user