Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b916b5c44 | |||
| 5a26f5f7b3 | |||
| 66768cb359 | |||
| 988947aa1a | |||
| b435f12c49 | |||
| c59d29d52e |
@@ -6,6 +6,3 @@ starter.dist/starter.dll
|
||||
build/
|
||||
.vscode/
|
||||
example.db.bak
|
||||
venv/
|
||||
flet_desktop/
|
||||
.flet/
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
# 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(每格利润空间)
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
[config]
|
||||
miniqmtpath = D:/Programs/DTQMT/userdata_mini
|
||||
account_no = 99082560
|
||||
log_level = INFO
|
||||
|
||||
@@ -1,25 +1,64 @@
|
||||
"""
|
||||
运行时配置 — 端口、路径、账号由自动探测设置,无需配置文件。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
# ---- 自动探测的配置项(默认值仅占位,启动时自动修正) ----
|
||||
miniQMTPath: str = ''
|
||||
account_no: str = ''
|
||||
log_level: str = 'INFO'
|
||||
console_log: bool = True
|
||||
use_simulated_qmt: bool = False
|
||||
miniQMTPath = r'D:\\Programs\\DTQMT\\userdata_mini' # miniQMT软件的安装路径
|
||||
# miniQMTPath = ''
|
||||
account_no:str = '99082560'
|
||||
console_log = True
|
||||
log_level = "INFO"
|
||||
|
||||
config : Any
|
||||
|
||||
def app_dir() -> Path:
|
||||
"""应用根目录(兼容开发环境与打包后的 exe)"""
|
||||
def get_config_path() -> Path:
|
||||
"""获取配置文件的正确路径(兼容开发环境和打包后的可执行文件)"""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return Path(sys.executable).parent
|
||||
return Path(__file__).resolve().parent
|
||||
# 打包后的可执行文件环境
|
||||
# sys._MEIPASS是PyInstaller解压临时文件的目录
|
||||
# 配置文件应该放在可执行文件同目录下
|
||||
base_path = Path(sys.executable).parent
|
||||
else:
|
||||
# 开发环境
|
||||
base_path = Path(__file__).resolve().parent
|
||||
|
||||
return base_path / 'config.ini'
|
||||
|
||||
def log_file_path() -> Path:
|
||||
"""日志文件路径"""
|
||||
return app_dir() / 'sfgrid.log'
|
||||
def get_config(section:str, key:str):
|
||||
pass
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# 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
|
||||
@@ -0,0 +1,11 @@
|
||||
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,4 +2,5 @@ import xtquant.xtconstant as xtconstant
|
||||
|
||||
OrderTypeBuy = f'{xtconstant.STOCK_BUY}' # 买
|
||||
OrderTypeSell = f'{xtconstant.STOCK_SELL}' # 卖
|
||||
OrderTypeInit = "0" # 建仓
|
||||
OrderTypeNone = "None"
|
||||
+1
-2
@@ -1,10 +1,9 @@
|
||||
from peewee import SqliteDatabase, Model
|
||||
from core.logger import LogLevel, PrintLog
|
||||
|
||||
# 连接到SQLite数据库
|
||||
db: SqliteDatabase = SqliteDatabase('example.db')
|
||||
db.connect()
|
||||
PrintLog(LogLevel.INFO, '- [成功]数据库连接')
|
||||
print("Database connected")
|
||||
|
||||
# 定义基础模型类
|
||||
class BaseModel(Model):
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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'})
|
||||
@@ -0,0 +1,7 @@
|
||||
from .eventbus import EventBus
|
||||
|
||||
# Pring Log
|
||||
EventPrintLog = "print_log" # 打印日志
|
||||
|
||||
# 创建事件总线实例
|
||||
loggerEBus = EventBus()
|
||||
@@ -0,0 +1,10 @@
|
||||
from eventbus import EventBus
|
||||
|
||||
# 市场数据监听控制事件
|
||||
EventMarketActiveSwitch = "market_active_switch" # 市场数据状态变更
|
||||
MarketDataUpdate = "market_data_update" # 市价更新
|
||||
MarketOrderCreated = "market_order_created" # 市价单创建
|
||||
MarketOrderTraded = "market_order_traded" # 市价单成交
|
||||
|
||||
# 创建事件总线实例
|
||||
marketDataEventBus = EventBus()
|
||||
@@ -1,47 +0,0 @@
|
||||
|
||||
# 市场数据监听控制事件
|
||||
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()
|
||||
+7
-30
@@ -1,9 +1,7 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
import threading
|
||||
|
||||
from core.eventbus import EventPrintLog, event_bus
|
||||
import config
|
||||
from core.ebus.logger_ebus import EventPrintLog, loggerEBus
|
||||
from core.config import config as config
|
||||
|
||||
|
||||
class LogLevel(Enum):
|
||||
@@ -16,34 +14,13 @@ class LogLevel(Enum):
|
||||
def __le__(self, other):
|
||||
return self.value <= other.value
|
||||
|
||||
|
||||
class LogData:
|
||||
def __init__(self, level: LogLevel, message: str):
|
||||
def __init__(self, level:LogLevel, message:str):
|
||||
self.level = level
|
||||
self.message = message
|
||||
|
||||
|
||||
_log_lock = threading.Lock()
|
||||
|
||||
|
||||
def _log_file_path():
|
||||
"""日志文件路径"""
|
||||
return str(config.log_file_path())
|
||||
|
||||
|
||||
def PrintLog(level: LogLevel, message: str):
|
||||
def PrintLog(level:LogLevel, message:str):
|
||||
data = LogData(level, message)
|
||||
event_bus.publish(EventPrintLog, data)
|
||||
|
||||
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 # 写文件失败不阻塞主流程
|
||||
loggerEBus.publish(EventPrintLog, data)
|
||||
if config.getConsoleLog():
|
||||
print(f'{level.name} {message}')
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# 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
@@ -0,0 +1,262 @@
|
||||
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()
|
||||
@@ -0,0 +1,8 @@
|
||||
from qmt import QmtV
|
||||
from eventbus import marketDataEventBus
|
||||
|
||||
qmtv:QmtV = None
|
||||
|
||||
def init_qmtv():
|
||||
global qmtv
|
||||
qmtv = QmtV()
|
||||
@@ -0,0 +1,209 @@
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
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
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,314 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,621 +0,0 @@
|
||||
"""
|
||||
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 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()
|
||||
@@ -0,0 +1,11 @@
|
||||
# 软件介绍
|
||||
软件名称:神之一手交易系统
|
||||
软件介绍:面向个人的交易管理系统,提供交易记录、复盘工具、持仓管理、资产监控、策略交易等功能。
|
||||
|
||||
# 模块介绍
|
||||
1. /core/daily_review: 每日复盘模块目录
|
||||
2. /core/market_data: 市场数据模块目录
|
||||
3. /core/quick_trade: 快速交易模块目录
|
||||
4. /core/strategy/builder: 策略构建模块目录
|
||||
5. /core/strategy/trade: 策略交易模块目录
|
||||
6. /core: 应用核心程序目录
|
||||
@@ -1 +0,0 @@
|
||||
# grid_seeker v6.4 评分模块
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,54 +0,0 @@
|
||||
"""
|
||||
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 = 8 # 最低收盘价
|
||||
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 选股阈值
|
||||
STACKING_THRESHOLD = 0.35
|
||||
@@ -1,3 +0,0 @@
|
||||
# 特征工程子包
|
||||
from core.scoring.features.validator import load_candidates, DataContext
|
||||
from core.scoring.features.pipeline import FeaturePipeline
|
||||
@@ -1,147 +0,0 @@
|
||||
"""
|
||||
情绪弹性特征 (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
|
||||
@@ -1,97 +0,0 @@
|
||||
"""
|
||||
大盘独立性特征 (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')
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
负向指标 (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')
|
||||
@@ -1,106 +0,0 @@
|
||||
"""
|
||||
特征工程编排器 — 串联全部特征组,输出完整特征 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
|
||||
@@ -1,88 +0,0 @@
|
||||
"""
|
||||
行业独立性特征 (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')
|
||||
@@ -1,203 +0,0 @@
|
||||
"""
|
||||
基础特征 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
|
||||
@@ -1,130 +0,0 @@
|
||||
"""
|
||||
扩展特征 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
|
||||
@@ -1,109 +0,0 @@
|
||||
"""
|
||||
扩展特征 v3.4 (16维)
|
||||
"""
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from core.scoring.features.v3_2_features import _ols_slope
|
||||
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
|
||||
|
||||
features[code] = feat
|
||||
|
||||
return pd.DataFrame.from_dict(features, orient='index')
|
||||
@@ -1,197 +0,0 @@
|
||||
"""
|
||||
候选股过滤与数据加载
|
||||
"""
|
||||
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)} 排除')
|
||||
|
||||
# 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
|
||||
@@ -1,2 +0,0 @@
|
||||
# 模型推理子包
|
||||
from core.scoring.inference.scorer import GridSeekerPipeline
|
||||
@@ -1,217 +0,0 @@
|
||||
"""
|
||||
grid_seeker v6.6 三级模型推理管道
|
||||
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 模型输入特征 (52维, v3.4, 直接从模型文件的 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):
|
||||
sf = obj.get('selected_features', [])
|
||||
if sf:
|
||||
return sf
|
||||
raise RuntimeError("无法从 rank.pkl 读取 selected_features")
|
||||
|
||||
RANK_FEATURE_COLS = _get_rank_features()
|
||||
|
||||
|
||||
class GridSeekerPipeline:
|
||||
"""
|
||||
grid_seeker v6.6 三级模型评分管道。
|
||||
|
||||
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.6 评分开始 ({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_predicted_rounds)
|
||||
PrintLog(LogLevel.INFO, '[scorer] Stage 2/3: Top 模型...')
|
||||
top_cols = RANK_FEATURE_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 (54维 = 52 + rank + top)
|
||||
PrintLog(LogLevel.INFO, '[scorer] Stage 3/3: Stacking 模型...')
|
||||
stk_cols = RANK_FEATURE_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]
|
||||
@@ -1,132 +0,0 @@
|
||||
"""
|
||||
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')
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 建表
|
||||
# ============================================================
|
||||
ALL_SCORING_TABLES = [
|
||||
KlineStock, StockInfo, IndustryMapping, KlineIndex,
|
||||
MarketRegimeDaily, SectorFeaturesDaily, ScoringResult,
|
||||
]
|
||||
|
||||
db.create_tables(ALL_SCORING_TABLES)
|
||||
@@ -1,7 +0,0 @@
|
||||
# 数据同步子包
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
"""
|
||||
数据同步抽象基类
|
||||
"""
|
||||
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):
|
||||
"""将数据写入数据库。子类实现。"""
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
行业映射同步 — 从 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]
|
||||
@@ -1,222 +0,0 @@
|
||||
"""
|
||||
K线数据同步 — 个股日K + 指数日K
|
||||
数据源: QMT xtdata
|
||||
增量同步: 只拉 max(trade_date) 之后的增量数据
|
||||
线程锁: KlineStockSync / KlineIndexSync 各自内部锁
|
||||
"""
|
||||
import pandas as pd
|
||||
from datetime import date, 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()
|
||||
if latest is not None and latest >= today:
|
||||
PrintLog(LogLevel.INFO, f'[sync] KlineStock: 已最新 ({latest}),跳过')
|
||||
self.stats['skipped'] = 0
|
||||
return self.stats
|
||||
|
||||
# 增量起点
|
||||
start_date = (latest + timedelta(days=1)) if latest else None
|
||||
start_str = start_date.strftime('%Y%m%d') if start_date else ""
|
||||
PrintLog(LogLevel.INFO,
|
||||
f'[sync] KlineStock: 增量同步,起点={start_str or "全部"}')
|
||||
|
||||
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)
|
||||
except Exception:
|
||||
self.stats['errors'] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
result = xtdata.get_market_data(
|
||||
field_list=field_list, stock_list=[code], period='1d',
|
||||
count=self.count, dividend_type='none', fill_data=False)
|
||||
inserted += self._upsert_incremental(code, result, start_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) -> 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 = []
|
||||
for td in close_df.columns:
|
||||
td_date = td.date() if hasattr(td, 'date') else td
|
||||
if start_date is not None and td_date <= start_date:
|
||||
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': _safe_get(result.get('volume'), full_code, td),
|
||||
})
|
||||
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()
|
||||
if latest is not None and latest >= today:
|
||||
PrintLog(LogLevel.INFO, f'[sync] KlineIndex: 已最新 ({latest}),跳过')
|
||||
return {}
|
||||
|
||||
start_date = (latest + timedelta(days=1)) if latest else None
|
||||
start_str = start_date.strftime('%Y%m%d') if start_date else ""
|
||||
PrintLog(LogLevel.INFO,
|
||||
f'[sync] KlineIndex: 增量同步,起点={start_str or "全部"}')
|
||||
|
||||
for code in index_codes:
|
||||
try:
|
||||
xtdata.download_history_data(code, period='1d', start_time=start_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',
|
||||
count=self.count, dividend_type='none', fill_data=False)
|
||||
return result or {}
|
||||
|
||||
def _upsert(self, data):
|
||||
if not data:
|
||||
return
|
||||
latest = _latest_date(KlineIndex)
|
||||
records = []
|
||||
close_df = data.get('close')
|
||||
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:
|
||||
td_date = td.date() if hasattr(td, 'date') else td
|
||||
if latest is not None and td_date <= latest:
|
||||
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': _safe_get(data.get('volume'), full_code, td),
|
||||
})
|
||||
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]
|
||||
@@ -1,98 +0,0 @@
|
||||
"""
|
||||
市场状态计算 — 从 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]
|
||||
@@ -1,129 +0,0 @@
|
||||
"""
|
||||
行业聚合指数计算 — 从 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 线数据
|
||||
kline_rows = (KlineStock
|
||||
.select(
|
||||
KlineStock.stock_code,
|
||||
KlineStock.trade_date,
|
||||
KlineStock.open,
|
||||
KlineStock.high,
|
||||
KlineStock.low,
|
||||
KlineStock.close,
|
||||
)
|
||||
.order_by(KlineStock.stock_code, KlineStock.trade_date)
|
||||
.dicts())
|
||||
|
||||
if not kline_rows:
|
||||
PrintLog(LogLevel.WARNING, '[sync] SectorFeatures: KlineStock 表为空')
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(kline_rows)
|
||||
df['trade_date'] = pd.to_datetime(df['trade_date'])
|
||||
PrintLog(LogLevel.INFO, f'[sync] SectorFeatures: {len(df)} 条K线数据')
|
||||
|
||||
# 3. 映射行业
|
||||
df['sector_name'] = df['stock_code'].map(code_to_industry)
|
||||
df = df.dropna(subset=['sector_name'])
|
||||
|
||||
# 4. 逐股计算日收益率和振幅
|
||||
df = df.sort_values(['stock_code', 'trade_date'])
|
||||
df['prev_close'] = df.groupby('stock_code')['close'].shift(1)
|
||||
df['pct_chg'] = (df['close'] - df['prev_close']) / df['prev_close'] * 100
|
||||
df['amplitude'] = (df['high'] - df['low']) / df['open'] * 100
|
||||
|
||||
# 清理无效值
|
||||
df = df.dropna(subset=['pct_chg', 'amplitude'])
|
||||
|
||||
# 5. 按行业+日期聚合
|
||||
agg = (df.groupby(['trade_date', 'sector_name'])
|
||||
.agg(
|
||||
sector_ret=('pct_chg', 'mean'),
|
||||
sector_amplitude=('amplitude', 'mean'),
|
||||
)
|
||||
.reset_index())
|
||||
|
||||
# 6. 构建行业指数 (基值=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
|
||||
|
||||
# 7. 计算 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()))
|
||||
|
||||
# 8. 趋势评分: 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]
|
||||
@@ -1,100 +0,0 @@
|
||||
"""
|
||||
股票基础信息同步 — 从 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]
|
||||
@@ -1,7 +0,0 @@
|
||||
# 删除交易标的事件
|
||||
EventTradeTargetUpdate = "trade_target_update"
|
||||
EventTradeTargetDeleted = "trade_target_deleted"
|
||||
|
||||
# 评分系统事件
|
||||
EventScoringCompleted = "scoring_completed" # 评分完成, data: {'date', 'count'}
|
||||
EventSyncProgress = "sync_progress" # 同步进度, data: {'source', 'status', 'stats'}
|
||||
@@ -1,537 +0,0 @@
|
||||
"""
|
||||
网格交易策略控制器
|
||||
|
||||
核心逻辑:在预设的价格网格上低买高卖,每个网格节点同时挂一对买卖单,
|
||||
成交后自动切换到相邻网格并刷新订单。
|
||||
|
||||
网格结构示意(以 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 和 tradeTarget 的并发访问
|
||||
# QMT 回调在独立线程中触发,必须在可能触发回调的操作之前创建
|
||||
# 注意:这个锁必须在订阅事件之前创建,防止事件在初始化期间触发
|
||||
self.dataUpdateLock = threading.Lock()
|
||||
|
||||
# 订阅事件总线:监听订单创建、成交、失败三种事件
|
||||
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: 网格索引 → 订单编号(seq 或 order_id)的映射
|
||||
# seq 是 xtquant 返回的下单序号(下单瞬间),order_id 是交易所返回的正式订单号(异步回调后更新)
|
||||
self.orderGrid = {} # {grid_index: order_seq | order_id}
|
||||
|
||||
# 加载券商侧已存在的未成交订单,恢复到 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 的卖单(避免重复挂单)
|
||||
if not any(o.order_remark == sell_remark for o in orders):
|
||||
# 卖单价格超过涨停价 → 今日无法成交,跳过下单
|
||||
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 的买单(避免重复挂单)
|
||||
if not any(o.order_remark == buy_remark for o in orders):
|
||||
# 买单价格低于跌停价 → 今日无法成交,跳过下单
|
||||
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
|
||||
|
||||
# ── 网格方向判断 ──
|
||||
# 比较成交单的网格索引 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
|
||||
@@ -0,0 +1,3 @@
|
||||
# 删除交易标的事件
|
||||
EventTradeTargetUpdate = "trade_target_update"
|
||||
EventTradeTargetDeleted = "trade_target_deleted"
|
||||
@@ -2,10 +2,6 @@ from peewee import CharField, IntegerField, FloatField, BooleanField
|
||||
|
||||
from core.database import BaseModel, db
|
||||
|
||||
# 策略类型常量
|
||||
STRATEGY_TYPE_UNCLASSIFIED = 0 # 未分类持仓
|
||||
STRATEGY_TYPE_GRID = 1 # 网格策略
|
||||
|
||||
|
||||
# 定义Target类,对应targets表
|
||||
class SFGridTradeTarget(BaseModel):
|
||||
@@ -16,13 +12,12 @@ class SFGridTradeTarget(BaseModel):
|
||||
init_price = FloatField(null=True) # 建仓成本
|
||||
grid_match_count = IntegerField(default=0)
|
||||
grid_total_profit = FloatField(default=0.0)
|
||||
status = IntegerField(default=0) # 已废弃,改用 strategy_type + grid_index
|
||||
status = IntegerField(default=0) # -1表示新标的,未完成交易配置,0表示新标的,已完成交易配置,1表示已建初始仓,正常交易中
|
||||
enabled = BooleanField(default=False) # 是否启动交易线程
|
||||
strategy_type = IntegerField(default=0) # 0=未分类, 1=网格策略
|
||||
|
||||
grid_start_price = FloatField(default=10.0) # 基线价格
|
||||
grid_size = FloatField(default=1.0) # 网格价位差
|
||||
grid_volume = IntegerField(default=200) # 网格交易量
|
||||
grid_size = FloatField(default=0.1) # 网格价位差
|
||||
grid_volume = IntegerField(default=100) # 网格交易量
|
||||
grid_upper_count = IntegerField(default=1) # 基线价格上方网格数
|
||||
grid_lower_count = IntegerField(default=10) # 基线价格下方网格数
|
||||
|
||||
@@ -47,14 +42,3 @@ class SFGridTradeTarget(BaseModel):
|
||||
|
||||
|
||||
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,223 @@
|
||||
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
|
||||
@@ -0,0 +1,985 @@
|
||||
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("错误", "请输入有效的数字")
|
||||
@@ -0,0 +1,149 @@
|
||||
# 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()
|
||||
@@ -1,836 +0,0 @@
|
||||
"""
|
||||
Flet UI — 完整对齐 Tkinter 版布局、数据流、刷新机制。
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
import threading
|
||||
import flet as ft
|
||||
|
||||
from core.qmt_real import RealQmtV, qmtv
|
||||
from core.logger import LogLevel, PrintLog
|
||||
from core.sfgrid.model import SFGridTradeTarget, STRATEGY_TYPE_GRID, STRATEGY_TYPE_UNCLASSIFIED
|
||||
from core.sfgrid.sfgrid_strategy import SFGridStrategy
|
||||
from core.eventbus import event_bus, MarketDataUpdate, EventMarketActiveSwitch
|
||||
from core.sfgrid.bus_events import EventTradeTargetUpdate
|
||||
|
||||
|
||||
# ── 委托状态 / 方向映射 ──
|
||||
_ORDER_STATUS = {48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
|
||||
53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单'}
|
||||
|
||||
|
||||
def _fmt_time(t) -> str:
|
||||
"""格式化 QMT 时间为 HH:MM:SS(北京时间,Unix timestamp → 本地时间)"""
|
||||
if not t:
|
||||
return ''
|
||||
import datetime
|
||||
try:
|
||||
ts = int(t)
|
||||
if ts > 1e12: # 毫秒级
|
||||
ts //= 1000
|
||||
return datetime.datetime.fromtimestamp(ts).strftime('%H:%M:%S')
|
||||
except (ValueError, OSError):
|
||||
return str(t)
|
||||
|
||||
|
||||
def _direction(ot: int) -> str:
|
||||
return '买' if ot == 23 else '卖' if ot == 24 else str(ot)
|
||||
|
||||
|
||||
def _plain(code: str) -> str:
|
||||
return code.split('.')[0] if '.' in code else code
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# QmtApp
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class QmtApp:
|
||||
"""Flet 版 QMT 交易界面,布局、数据流对齐 core/ui/tkinter/sfgrid_view.py"""
|
||||
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self.page.title = "神之一手"
|
||||
self.page.window.width = 1400
|
||||
self.page.window.height = 800
|
||||
self.page.padding = 0
|
||||
|
||||
# ── 状态(对齐 Tkinter TradeTargetUI) ──
|
||||
self.tradeTargetData: dict[int, SFGridTradeTarget] = {}
|
||||
self.stockCodeIdMap: dict[str, int] = {}
|
||||
self.strategy_ctrl: dict[int, SFGridStrategy] = {}
|
||||
self.targetMarketPrice: dict[int, float] = {}
|
||||
self.targetPreClose: dict[int, float] = {} # 昨收
|
||||
self.targetAvgPrice: dict[int, float] = {}
|
||||
self.marketData: dict[str, dict] = {} # stock_code → {stock_name, last_price, time}
|
||||
self.listening_stock: list = []
|
||||
self.monitor_price: float = 10.0
|
||||
self._market_active: bool = qmtv.isMarketActive
|
||||
self._refresh_cycle: int = 0
|
||||
self._drawer_open: bool = False
|
||||
self._selected_target = None
|
||||
self._prices_loaded: bool = False
|
||||
self._orders: list = []
|
||||
self._trades: list = []
|
||||
|
||||
self._run_startup()
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 启动流程(对齐 tkinter/splash.py)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _run_startup(self):
|
||||
"""启动进度 — 先渲染 splash,再异步执行启动步骤"""
|
||||
bar = ft.ProgressBar(width=340, value=0, color='#0078d4')
|
||||
self._splash_status = ft.Text("正在初始化...", size=13)
|
||||
self._splash_bar = bar
|
||||
splash = ft.Container(
|
||||
ft.Column([
|
||||
ft.Text("神之一手", size=22, weight=ft.FontWeight.BOLD, color='#0078d4'),
|
||||
ft.Text("交易系统", size=14, color='#666666'),
|
||||
ft.Container(height=20),
|
||||
self._splash_status,
|
||||
ft.Container(height=8),
|
||||
bar,
|
||||
], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
width=380, height=200,
|
||||
bgcolor=ft.Colors.SURFACE,
|
||||
border_radius=12,
|
||||
shadow=ft.BoxShadow(blur_radius=20, color='#20000000'),
|
||||
alignment=ft.Alignment.CENTER,
|
||||
)
|
||||
self.page.add(ft.Container(
|
||||
content=splash,
|
||||
alignment=ft.Alignment.CENTER, expand=True,
|
||||
bgcolor='#F5F5F5',
|
||||
))
|
||||
self.page.update()
|
||||
|
||||
# 异步执行启动,确保 splash 先渲染
|
||||
asyncio.ensure_future(self._do_startup())
|
||||
|
||||
async def _do_startup(self):
|
||||
"""异步启动流程 — splash 已渲染,逐步执行并更新进度"""
|
||||
# 给渲染一帧的时间
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
steps = [
|
||||
("正在检查 QMT 环境...", 0.10, lambda: RealQmtV._discover_qmt_port() or True),
|
||||
("正在初始化交易器...", 0.35, lambda: qmtv.init_qmtv()),
|
||||
("正在连接 QMT...", 0.55, lambda: qmtv.connect() or True),
|
||||
("正在加载持仓数据...", 0.75, lambda: self._init_data()),
|
||||
("正在构建界面...", 0.85, lambda: None),
|
||||
("正在初始化策略...", 0.92, lambda: self._init_strategies()),
|
||||
]
|
||||
for text, pct, action in steps:
|
||||
self._splash_status.value = text
|
||||
self._splash_bar.value = pct
|
||||
self.page.update()
|
||||
try:
|
||||
result = action()
|
||||
if result is False:
|
||||
self._show_error(f"启动失败: {text}")
|
||||
return
|
||||
except Exception as e:
|
||||
self._show_error(f"启动异常: {text}\n{e}")
|
||||
return
|
||||
|
||||
self._splash_status.value = "启动完成"
|
||||
self._splash_bar.value = 1.0
|
||||
self.page.update()
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
self.page.clean()
|
||||
self._build_main_ui()
|
||||
self.page.update()
|
||||
|
||||
# 主动拉取市价(不等行情推送)
|
||||
self._pull_prices()
|
||||
# 加载委托/成交数据
|
||||
self._refresh_orders()
|
||||
self._refresh_trades()
|
||||
self._rebuild_tables()
|
||||
self.page.update()
|
||||
|
||||
# 订阅事件 + 后台刷新
|
||||
event_bus.subscribe(MarketDataUpdate, self._on_market_data)
|
||||
event_bus.subscribe(EventMarketActiveSwitch, self._on_market_active_switch)
|
||||
event_bus.subscribe(EventTradeTargetUpdate, self._on_strategy_update)
|
||||
threading.Thread(target=self._refresh_loop, daemon=True).start()
|
||||
|
||||
def _show_error(self, msg: str):
|
||||
self.page.clean()
|
||||
self.page.add(ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Icon(ft.Icons.ERROR_OUTLINE, size=48, color=ft.Colors.RED),
|
||||
ft.Text(msg, size=16),
|
||||
ft.ElevatedButton("重试", on_click=lambda e: self._retry()),
|
||||
], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
alignment=ft.Alignment.CENTER, expand=True,
|
||||
))
|
||||
self.page.update()
|
||||
|
||||
def _retry(self):
|
||||
self.page.clean()
|
||||
self._run_startup()
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 数据初始化(对齐 Tkinter init_trade_target_pool)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _init_data(self):
|
||||
positions = qmtv.getAllPositions()
|
||||
PrintLog(LogLevel.INFO, f'[Flet] 持仓: {len(positions)} 个')
|
||||
|
||||
for code, pos in positions.items():
|
||||
existing = SFGridTradeTarget.get_or_none(SFGridTradeTarget.stock_code == code)
|
||||
if existing is None:
|
||||
name = getattr(pos, 'instrument_name', '') or qmtv.getInstrumentName(code)
|
||||
SFGridTradeTarget.create(
|
||||
stock_code=code, stock_name=name,
|
||||
current_position=int(pos.volume),
|
||||
init_price=float(getattr(pos, 'avg_price', 0) or 0),
|
||||
grid_index=0, enabled=False,
|
||||
grid_start_price=float(getattr(pos, 'avg_price', 0) or 0) or 10.0,
|
||||
grid_size=1.0, grid_volume=200, grid_upper_count=1, grid_lower_count=10,
|
||||
)
|
||||
|
||||
# 获取昨收价(需要带后缀的完整代码)
|
||||
try:
|
||||
from xtquant import xtdata
|
||||
for stock_code, pos in positions.items():
|
||||
full_code = stock_code
|
||||
if '.' not in stock_code:
|
||||
c = stock_code
|
||||
full_code = f'{c}.SH' if c.startswith(('6', '5', '9')) else f'{c}.SZ'
|
||||
detail = xtdata.get_instrument_detail(full_code)
|
||||
if detail:
|
||||
pre_close = detail.get('PreClose', 0) if isinstance(detail, dict) else getattr(detail, 'PreClose', 0)
|
||||
if pre_close > 0:
|
||||
self.targetPreClose[stock_code] = float(pre_close)
|
||||
PrintLog(LogLevel.INFO, f'[Flet] 已获取 {len(self.targetPreClose)} 个标的昨收价')
|
||||
except Exception as e:
|
||||
PrintLog(LogLevel.DEBUG, f'[Flet] 昨收价获取异常: {e}')
|
||||
|
||||
results = list(SFGridTradeTarget.select())
|
||||
for t in results:
|
||||
pos = positions.get(t.stock_code)
|
||||
t.current_position = 0 if pos is None else int(pos.volume)
|
||||
tid = t.get_id()
|
||||
self.tradeTargetData[tid] = t
|
||||
self.stockCodeIdMap[t.stock_code] = tid
|
||||
if pos is not None:
|
||||
self.targetAvgPrice[tid] = float(getattr(pos, 'avg_price', 0) or 0)
|
||||
|
||||
def _init_strategies(self):
|
||||
from core.sfgrid.model import STRATEGY_TYPE_GRID
|
||||
for tid, t in self.tradeTargetData.items():
|
||||
if t.strategy_type == STRATEGY_TYPE_GRID and t.enabled:
|
||||
self.strategy_ctrl[tid] = SFGridStrategy(t)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 主界面构建(对齐 Tkinter create_tables_area)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _build_main_ui(self):
|
||||
# ── 右侧面板内容 ──
|
||||
self._tab_orders = ft.Tab(label="当前委托")
|
||||
self._tab_trades = ft.Tab(label="当日成交")
|
||||
right_bar = ft.TabBar(tabs=[
|
||||
ft.Tab(label="实时价格监控"),
|
||||
self._tab_orders,
|
||||
self._tab_trades,
|
||||
ft.Tab(label="未分类持仓"),
|
||||
])
|
||||
self._uncl_list = ft.ListView([self._build_unclassified_table()], expand=True)
|
||||
self._right_view = ft.TabBarView(controls=[
|
||||
self._build_market_view(),
|
||||
self._build_order_view(),
|
||||
self._build_trade_view(),
|
||||
self._uncl_list,
|
||||
], expand=True)
|
||||
panel_content = ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Container(ft.Text("监控面板", size=14, weight=ft.FontWeight.BOLD), padding=ft.Padding(10, 10, 10, 5)),
|
||||
ft.Tabs(ft.Column([right_bar, self._right_view], expand=True), length=4, expand=True),
|
||||
], expand=True),
|
||||
width=700, bgcolor=ft.Colors.SURFACE,
|
||||
)
|
||||
|
||||
# ── 遮罩层(点击关闭) ──
|
||||
backdrop = ft.Container(
|
||||
bgcolor='#44000000', expand=True,
|
||||
on_click=lambda e: self._hide_overlay(),
|
||||
)
|
||||
|
||||
# ── overlay 行:遮罩 + 面板 ──
|
||||
self._overlay = ft.Container(
|
||||
ft.Row([backdrop, panel_content], spacing=0),
|
||||
visible=False, expand=True,
|
||||
)
|
||||
|
||||
# ── 标题栏(始终可见,选中行后显示操作按钮) ──
|
||||
self._sidebar_icon = _PanelIcon('sidebar', active=False, on_click=lambda e: self._toggle_overlay())
|
||||
self._sel_actions = ft.Row([], spacing=4) # 动态操作按钮
|
||||
self._sel_info = ft.Text("", size=12, color='#666666')
|
||||
grid_title = ft.Container(
|
||||
ft.Row([
|
||||
ft.Row([
|
||||
ft.Text("网格策略持仓", size=13, weight=ft.FontWeight.BOLD),
|
||||
self._sel_info,
|
||||
self._sel_actions,
|
||||
]),
|
||||
ft.Row([
|
||||
ft.IconButton(ft.Icons.REFRESH, tooltip="刷新", icon_size=18,
|
||||
on_click=lambda e: self._manual_refresh()),
|
||||
self._sidebar_icon,
|
||||
], spacing=0),
|
||||
], alignment=ft.MainAxisAlignment.SPACE_BETWEEN),
|
||||
padding=ft.Padding(10, 10, 10, 5),
|
||||
)
|
||||
|
||||
# ── 表格(Stack 内,可被 overlay 覆盖) ──
|
||||
self._grid_list = self._build_grid_table() # 回到 DataTable
|
||||
grid_body = ft.Container(
|
||||
content=self._grid_list, expand=True,
|
||||
padding=ft.Padding(10, 0, 10, 10),
|
||||
)
|
||||
|
||||
self.page.add(ft.Column([
|
||||
grid_title,
|
||||
ft.Stack([grid_body, self._overlay], expand=True),
|
||||
], expand=True))
|
||||
|
||||
# ── 表格工具 ──
|
||||
|
||||
def _dt(self, cols: list[str], rows: list[list[str]], col_widths: list = None) -> ft.Control:
|
||||
"""构建 DataTable"""
|
||||
data_cols = [ft.DataColumn(ft.Text(h)) for h in cols]
|
||||
data_rows = []
|
||||
for r in rows:
|
||||
cells = []
|
||||
for i, c in enumerate(r):
|
||||
w = col_widths[i] if col_widths and i < len(col_widths) else None
|
||||
cells.append(ft.DataCell(ft.Text(str(c), overflow=ft.TextOverflow.ELLIPSIS,
|
||||
max_lines=1, width=w)))
|
||||
data_rows.append(ft.DataRow(cells=cells))
|
||||
if not data_rows:
|
||||
data_rows.append(ft.DataRow(cells=[ft.DataCell(ft.Text("")) for _ in cols]))
|
||||
return ft.ListView([ft.DataTable(
|
||||
columns=data_cols, rows=data_rows,
|
||||
width=float('inf'),
|
||||
heading_row_height=36, data_row_min_height=32,
|
||||
)], expand=True)
|
||||
|
||||
# ── 各表格 ──
|
||||
|
||||
def _pending_tags(self, stock_code: str) -> list:
|
||||
"""返回该标的下挂单的方向标签列表:'多'(买单) / '空'(卖单)"""
|
||||
tags = []
|
||||
_TERMINAL = {54, 56, 57}
|
||||
for o in self._orders:
|
||||
if _plain(getattr(o, 'stock_code', '')) != stock_code:
|
||||
continue
|
||||
if getattr(o, 'order_status', 0) in _TERMINAL:
|
||||
continue
|
||||
ot = getattr(o, 'order_type', 0)
|
||||
if ot == 23 and '多' not in tags:
|
||||
tags.append('多')
|
||||
elif ot == 24 and '空' not in tags:
|
||||
tags.append('空')
|
||||
return tags
|
||||
|
||||
def _tag_badge(self, text: str, color: str) -> ft.Container:
|
||||
return ft.Container(
|
||||
ft.Text(text, size=10, color='white', weight=ft.FontWeight.BOLD),
|
||||
bgcolor=color, border_radius=4, padding=ft.Padding(3, 1, 3, 1),
|
||||
)
|
||||
|
||||
def _on_grid_row_select(self, target):
|
||||
"""DataRow 选中回调 — 在标题栏显示操作按钮"""
|
||||
self._selected_target = target
|
||||
name = f'{target.stock_code} {target.stock_name}'
|
||||
self._sel_info.value = f" | 已选: {name}"
|
||||
actions = []
|
||||
if target.enabled:
|
||||
actions.append(ft.ElevatedButton("⏸ 暂停", on_click=lambda e, t=target: self._on_stop_trade(t), height=28))
|
||||
else:
|
||||
actions.append(ft.ElevatedButton("▶ 启动", on_click=lambda e, t=target: self._on_start_trade(t), height=28))
|
||||
actions.append(ft.ElevatedButton("⚙ 设置", on_click=lambda e, t=target: self._open_grid_config(t), height=28))
|
||||
self._sel_actions.controls = actions
|
||||
self.page.update()
|
||||
|
||||
def _build_grid_table(self) -> ft.Control:
|
||||
"""网格表格 — DataTable + on_select_change"""
|
||||
cols = ["ID", "股票", "市场价", "持仓", "成本", "网格基准", "状态"]
|
||||
data_cols = [ft.DataColumn(ft.Text(h)) for h in cols]
|
||||
data_rows = []
|
||||
is_sel = self._selected_target is not None
|
||||
sel_id = self._selected_target.get_id() if self._selected_target else -1
|
||||
for tid, t in self.tradeTargetData.items():
|
||||
if t.strategy_type != 1:
|
||||
continue
|
||||
pg = t.getPriceGrid()
|
||||
idx = t.grid_index
|
||||
grid_base = pg[idx] if 0 <= idx < len(pg) else 0
|
||||
mp = self.targetMarketPrice.get(tid, 0) or 0
|
||||
pre_close = self.targetPreClose.get(t.stock_code, 0) or 0
|
||||
up = mp > pre_close and pre_close > 0
|
||||
down = mp < pre_close and mp > 0 and pre_close > 0
|
||||
pcolor = '#CC0000' if up else '#009900' if down else None
|
||||
gtext = ft.Text(f'{grid_base:.2f}', weight=ft.FontWeight.BOLD)
|
||||
gparts = [gtext]
|
||||
if mp > grid_base > 0:
|
||||
gparts.append(ft.Text(' ▲', color='#CC0000', weight=ft.FontWeight.BOLD))
|
||||
elif 0 < mp < grid_base:
|
||||
gparts.append(ft.Text(' ▼', color='#009900', weight=ft.FontWeight.BOLD))
|
||||
for tag in self._pending_tags(t.stock_code):
|
||||
gparts.append(self._tag_badge(tag, '#E67E22' if tag == '多' else '#3498DB'))
|
||||
gcell = ft.Row(gparts, spacing=3) if len(gparts) > 1 else gtext
|
||||
dr = ft.DataRow(cells=[
|
||||
ft.DataCell(ft.Text(str(tid))),
|
||||
ft.DataCell(ft.Text(f'{t.stock_code} {t.stock_name}')),
|
||||
ft.DataCell(ft.Text(f'{mp:.3f}', color=pcolor, weight=ft.FontWeight.BOLD)),
|
||||
ft.DataCell(ft.Text(str(t.current_position))),
|
||||
ft.DataCell(ft.Text(f'{self.targetAvgPrice.get(tid, 0):.3f}')),
|
||||
ft.DataCell(gcell),
|
||||
ft.DataCell(ft.Text('▶运行中' if t.enabled else '⏸已暂停')),
|
||||
], selected=(is_sel and tid == sel_id))
|
||||
dr.on_select_change = lambda e, t=t: self._on_grid_row_select(t)
|
||||
data_rows.append(dr)
|
||||
if not data_rows:
|
||||
data_rows.append(ft.DataRow(cells=[ft.DataCell(ft.Text("")) for _ in cols]))
|
||||
return ft.ListView([ft.DataTable(columns=data_cols, rows=data_rows,
|
||||
width=float('inf'),
|
||||
heading_row_height=36, data_row_min_height=32)], expand=True)
|
||||
|
||||
def _build_unclassified_table(self) -> ft.Control:
|
||||
cols = ["ID", "股票", "市场价", "当前持仓", "平均成本"]
|
||||
rows = []
|
||||
for tid, t in self.tradeTargetData.items():
|
||||
if t.strategy_type == STRATEGY_TYPE_GRID:
|
||||
continue
|
||||
mp = self.targetMarketPrice.get(tid, 0) or 0
|
||||
rows.append([
|
||||
str(tid),
|
||||
f'{t.stock_code} {t.stock_name}',
|
||||
f'{mp:.3f}',
|
||||
str(t.current_position),
|
||||
f'{self.targetAvgPrice.get(tid, 0):.3f}元',
|
||||
])
|
||||
return self._dt(cols, rows)
|
||||
|
||||
def _build_market_view(self) -> ft.Control:
|
||||
"""实时价格监控 — 监控配置 + 表格"""
|
||||
price_input = ft.TextField(value=str(self.monitor_price), width=80, height=32,
|
||||
text_size=13, content_padding=ft.Padding(4, 0, 4, 0))
|
||||
confirm_btn = ft.ElevatedButton("确认", on_click=lambda e: self._set_monitor_price(price_input.value), height=32)
|
||||
|
||||
self._market_table = self._dt(["时间", "股票名称", "最新价格"], [])
|
||||
return ft.Column([
|
||||
ft.Row([
|
||||
ft.Text("监控配置", size=13), ft.Text("价格", size=13),
|
||||
price_input, confirm_btn,
|
||||
]),
|
||||
ft.Container(content=self._market_table, expand=True),
|
||||
], expand=True)
|
||||
|
||||
def _build_order_view(self) -> ft.Control:
|
||||
self._order_table = self._dt(
|
||||
["时间", "代码", "名称", "方向", "委托价", "委托量", "已成交", "均价", "状态"], [],
|
||||
col_widths=[65, 55, 70, 35, 60, 80, 55, 50])
|
||||
return self._order_table
|
||||
|
||||
def _build_trade_view(self) -> ft.Control:
|
||||
self._trade_table = self._dt(
|
||||
["时间", "代码", "名称", "方向", "成交价", "成交量", "成交金额", "手续费"], [],
|
||||
col_widths=[65, 55, 70, 35, 65, 60, 70, 55])
|
||||
return self._trade_table
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 事件回调(对齐 Tkinter onMarketDataUpdated)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _on_market_data(self, data: dict):
|
||||
"""行情数据回调 — 来自 QMT 推送"""
|
||||
need_rebuild = not self._prices_loaded
|
||||
updated_count = 0
|
||||
for stock_code, tick in data.items():
|
||||
plain = _plain(stock_code)
|
||||
tid = self.stockCodeIdMap.get(plain)
|
||||
lp = tick.get('lastPrice', 0)
|
||||
|
||||
if tid is not None and tid in self.tradeTargetData:
|
||||
self.targetMarketPrice[tid] = lp
|
||||
self.tradeTargetData[tid].market_price = lp
|
||||
updated_count += 1
|
||||
else:
|
||||
# 非目标标的:监控价格触发时记录
|
||||
if lp == self.monitor_price or stock_code in self.listening_stock:
|
||||
if stock_code not in self.listening_stock:
|
||||
self.listening_stock.append(stock_code)
|
||||
t_str = time.strftime("%H:%M:%S")
|
||||
name = qmtv.getInstrumentName(stock_code)
|
||||
self.marketData[stock_code] = {'stock_name': name, 'last_price': lp, 'time': t_str}
|
||||
|
||||
if need_rebuild and not self._prices_loaded and updated_count > 0:
|
||||
self._prices_loaded = True
|
||||
self._rebuild_tables()
|
||||
self.page.update()
|
||||
|
||||
def _on_market_active_switch(self, is_active: bool):
|
||||
self._market_active = is_active
|
||||
|
||||
def _on_strategy_update(self, target):
|
||||
"""策略数据变更 — 成交后立即刷新表格"""
|
||||
self._rebuild_tables()
|
||||
self.page.update()
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 刷新循环(对齐 Tkinter refresh_loop)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _pull_prices(self):
|
||||
"""主动拉取缺失的市价(对齐 Tkinter refresh_loop)"""
|
||||
for tid, t in self.tradeTargetData.items():
|
||||
if tid not in self.targetMarketPrice or self.targetMarketPrice[tid] == 0:
|
||||
price = qmtv.getLastPrice(t.stock_code)
|
||||
if price > 0:
|
||||
self.targetMarketPrice[tid] = price
|
||||
t.market_price = price
|
||||
|
||||
def _manual_refresh(self):
|
||||
self._pull_prices()
|
||||
self._refresh_positions()
|
||||
self._refresh_orders()
|
||||
self._refresh_trades()
|
||||
self._rebuild_tables()
|
||||
self.page.update()
|
||||
|
||||
def _refresh_positions(self):
|
||||
positions = qmtv.getAllPositions()
|
||||
for t in self.tradeTargetData.values():
|
||||
pos = positions.get(t.stock_code)
|
||||
t.current_position = 0 if pos is None else int(pos.volume)
|
||||
|
||||
def _refresh_orders(self):
|
||||
try:
|
||||
self._orders = list(qmtv.queryTodayOrders())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _refresh_trades(self):
|
||||
try:
|
||||
self._trades = list(qmtv.queryTodayTrades())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _rebuild_tables(self):
|
||||
"""重建所有表格数据"""
|
||||
self._grid_list.controls = [self._build_grid_table()]
|
||||
if not self._selected_target:
|
||||
self._sel_info.value = ""
|
||||
self._sel_actions.controls = []
|
||||
self._uncl_list.controls = [self._build_unclassified_table()]
|
||||
|
||||
# 委托 — 过滤已终结订单(已撤/已成/废单),按 order_id 去重
|
||||
_TERMINAL = {54, 56, 57}
|
||||
o_map = {} # order_id → latest order
|
||||
for o in self._orders:
|
||||
oid = str(getattr(o, 'order_id', ''))
|
||||
if not oid:
|
||||
continue
|
||||
o_map[oid] = o # 后面的覆盖前面的
|
||||
o_rows = []
|
||||
for o in o_map.values():
|
||||
st = getattr(o, 'order_status', 0)
|
||||
if st in _TERMINAL:
|
||||
continue
|
||||
tv = getattr(o, 'traded_volume', 0) or 0
|
||||
ov = getattr(o, 'order_volume', 0) or 0
|
||||
o_rows.append([
|
||||
_fmt_time(getattr(o, 'order_time', 0)),
|
||||
_plain(getattr(o, 'stock_code', '')),
|
||||
getattr(o, 'instrument_name', '') or '',
|
||||
_direction(getattr(o, 'order_type', 0)),
|
||||
f"{getattr(o, 'price', 0):.3f}",
|
||||
f"{tv}/{ov}",
|
||||
f"{getattr(o, 'traded_price', 0):.3f}" if getattr(o, 'traded_price', 0) > 0 else '-',
|
||||
_ORDER_STATUS.get(st, '未知'),
|
||||
])
|
||||
self._tab_orders.label = f"当前委托 ({len(o_rows)})" if o_rows else "当前委托"
|
||||
self._order_table.controls = [self._dt(
|
||||
["时间", "代码", "名称", "方向", "委托价", "已成交/委托量", "均价", "状态"], o_rows,
|
||||
col_widths=[65, 55, 70, 35, 60, 80, 55, 50])]
|
||||
|
||||
# 成交 — 按 traded_id 去重(保留最后一条)
|
||||
t_map = {}
|
||||
for t in self._trades:
|
||||
tid = str(getattr(t, 'traded_id', ''))
|
||||
if not tid:
|
||||
continue
|
||||
t_map[tid] = t
|
||||
t_rows = []
|
||||
for t in t_map.values():
|
||||
t_rows.append([
|
||||
_fmt_time(getattr(t, 'traded_time', 0)),
|
||||
_plain(getattr(t, 'stock_code', '')),
|
||||
getattr(t, 'instrument_name', '') or '',
|
||||
_direction(getattr(t, 'order_type', 0)),
|
||||
f"{getattr(t, 'traded_price', 0):.3f}",
|
||||
str(getattr(t, 'traded_volume', 0)),
|
||||
f"{getattr(t, 'traded_amount', 0):.2f}",
|
||||
f"{getattr(t, 'commission', 0):.2f}",
|
||||
])
|
||||
self._tab_trades.label = f"当日成交 ({len(t_rows)})" if t_rows else "当日成交"
|
||||
self._trade_table.controls = [self._dt(
|
||||
["时间", "代码", "名称", "方向", "成交价", "成交量", "成交金额", "手续费"], t_rows,
|
||||
col_widths=[65, 55, 70, 35, 65, 60, 70, 55])]
|
||||
|
||||
# 市场监控
|
||||
m_rows = []
|
||||
for sc, d in self.marketData.items():
|
||||
m_rows.append([d['time'], f"{d['stock_name']}-{sc}", f"{d['last_price']:.3f}"])
|
||||
self._market_table.controls = [self._dt(["时间", "股票名称", "最新价格"], m_rows)]
|
||||
|
||||
def _refresh_loop(self):
|
||||
"""后台定时刷新 — 对齐 Tkinter: 5s 拉价 + 30s 委托/成交"""
|
||||
while True:
|
||||
time.sleep(5)
|
||||
self._refresh_cycle += 1
|
||||
try:
|
||||
self._pull_prices()
|
||||
self._refresh_positions()
|
||||
if self._refresh_cycle % 6 == 0:
|
||||
self._refresh_orders()
|
||||
self._refresh_trades()
|
||||
self._rebuild_tables()
|
||||
self.page.update()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 工具栏按钮
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def _on_start_trade(self, target):
|
||||
PrintLog(LogLevel.INFO, f'[Flet-按钮] 启动按钮被点击: {target.stock_code}')
|
||||
if target.enabled:
|
||||
self._show_toast("该标的正运行中")
|
||||
return
|
||||
name = f'{target.stock_code} {target.stock_name}'
|
||||
dlg = ft.AlertDialog(
|
||||
title=ft.Text("确认启动"),
|
||||
content=ft.Text(f"确定要启动交易吗?\n\n{name}"),
|
||||
actions=[
|
||||
ft.TextButton("取消", on_click=lambda e: self._close_dialog(dlg)),
|
||||
ft.TextButton("确定", on_click=lambda e, t=target: self._do_start(t)),
|
||||
],
|
||||
)
|
||||
self.page.show_dialog(dlg)
|
||||
|
||||
def _do_start(self, target):
|
||||
self.page.pop_dialog()
|
||||
target.enabled = True
|
||||
target.save()
|
||||
from core.sfgrid.sfgrid_strategy import SFGridStrategy
|
||||
self.strategy_ctrl[target.get_id()] = SFGridStrategy(target)
|
||||
self._rebuild_tables()
|
||||
self.page.update()
|
||||
PrintLog(LogLevel.INFO, f'[Flet] 启动交易: {target.targetName()}')
|
||||
|
||||
def _on_stop_trade(self, target):
|
||||
PrintLog(LogLevel.INFO, f'[Flet-按钮] 暂停按钮被点击: {target.stock_code}')
|
||||
if not target.enabled:
|
||||
self._show_toast("该标的已暂停")
|
||||
return
|
||||
name = f'{target.stock_code} {target.stock_name}'
|
||||
dlg = ft.AlertDialog(
|
||||
title=ft.Text("确认暂停"),
|
||||
content=ft.Text(f"确定要暂停交易吗?\n\n{name}"),
|
||||
actions=[
|
||||
ft.TextButton("取消", on_click=lambda e: self._close_dialog(dlg)),
|
||||
ft.TextButton("确定", on_click=lambda e, t=target: self._do_stop(t)),
|
||||
],
|
||||
)
|
||||
self.page.show_dialog(dlg)
|
||||
|
||||
def _do_stop(self, target):
|
||||
self.page.pop_dialog()
|
||||
target.enabled = False
|
||||
target.save()
|
||||
ctrl = self.strategy_ctrl.pop(target.get_id(), None)
|
||||
if ctrl:
|
||||
ctrl.enabledTrading(False)
|
||||
self._rebuild_tables()
|
||||
self.page.update()
|
||||
PrintLog(LogLevel.INFO, f'[Flet] 暂停交易: {target.targetName()}')
|
||||
|
||||
def _open_grid_config(self, target):
|
||||
"""网格配置对话框 — 对齐 Tkinter create_grid_config_window"""
|
||||
PrintLog(LogLevel.INFO, f'[Flet-按钮] 设置按钮被点击: {target.stock_code}')
|
||||
base = ft.TextField(label="基准价格", value=str(target.grid_start_price), width=120, text_size=13)
|
||||
gsize = ft.TextField(label="网格大小", value=str(target.grid_size), width=120, text_size=13)
|
||||
gvol = ft.TextField(label="网格交易量(手)", value=str(target.grid_volume), width=120, text_size=13)
|
||||
gupper = ft.TextField(label="上方网格数", value=str(target.grid_upper_count), width=120, text_size=13)
|
||||
glower = ft.TextField(label="下方网格数", value=str(target.grid_lower_count), width=120, text_size=13)
|
||||
gidx = ft.TextField(label="当前网格层级", value=str(target.grid_index), width=120, text_size=13)
|
||||
|
||||
col1 = ft.Column([base, gsize, gvol], spacing=8)
|
||||
col2 = ft.Column([gupper, glower, gidx], spacing=8)
|
||||
grid_preview = ft.Text("", size=11, italic=True)
|
||||
|
||||
def _preview(e):
|
||||
try:
|
||||
bp = float(base.value)
|
||||
gs = float(gsize.value)
|
||||
up = int(gupper.value)
|
||||
lo = int(glower.value)
|
||||
prices = []
|
||||
for i in range(up, 0, -1):
|
||||
prices.append(f"{bp + gs * i:.2f}(卖{up - i + 1})")
|
||||
prices.append(f"→{bp:.2f}←(基准)")
|
||||
for i in range(1, lo + 1):
|
||||
p = bp - gs * i
|
||||
if p > 0:
|
||||
prices.append(f"{p:.2f}(买{i})")
|
||||
grid_preview.value = " ".join(prices)
|
||||
grid_preview.update()
|
||||
except ValueError:
|
||||
grid_preview.value = "请输入有效数字"
|
||||
grid_preview.update()
|
||||
|
||||
def _save(e):
|
||||
try:
|
||||
target.grid_start_price = float(base.value)
|
||||
target.grid_size = float(gsize.value)
|
||||
target.grid_volume = int(gvol.value)
|
||||
target.grid_upper_count = int(gupper.value)
|
||||
target.grid_lower_count = int(glower.value)
|
||||
target.grid_index = int(gidx.value)
|
||||
target.save()
|
||||
self._close_dialog()
|
||||
self._rebuild_tables()
|
||||
self.page.update()
|
||||
PrintLog(LogLevel.INFO, f'[Flet] 网格配置已保存: {target.targetName()}')
|
||||
except ValueError:
|
||||
self._show_toast("请输入有效的数值")
|
||||
|
||||
dlg = ft.AlertDialog(
|
||||
title=ft.Text(f"网格配置 - {target.stock_code} {target.stock_name}"),
|
||||
content=ft.Column([
|
||||
ft.Row([col1, col2], spacing=20),
|
||||
ft.ElevatedButton("预览网格序列", on_click=_preview),
|
||||
grid_preview,
|
||||
], spacing=10, tight=True, height=320),
|
||||
actions=[
|
||||
ft.TextButton("取消", on_click=lambda e: self._close_dialog(dlg)),
|
||||
ft.ElevatedButton("保存", on_click=_save),
|
||||
],
|
||||
)
|
||||
self.page.show_dialog(dlg)
|
||||
|
||||
def _show_toast(self, msg: str):
|
||||
dlg = ft.AlertDialog(title=ft.Text("提示"), content=ft.Text(msg),
|
||||
actions=[ft.TextButton("确定", on_click=lambda e: self._close_dialog(dlg))])
|
||||
self.page.show_dialog(dlg)
|
||||
|
||||
def _close_dialog(self, dlg=None):
|
||||
self.page.pop_dialog()
|
||||
self.page.update()
|
||||
|
||||
def _toggle_overlay(self):
|
||||
self._drawer_open = not self._drawer_open
|
||||
self._overlay.visible = self._drawer_open
|
||||
self._sidebar_icon.set_active(self._drawer_open)
|
||||
self.page.update()
|
||||
|
||||
def _hide_overlay(self):
|
||||
self._drawer_open = False
|
||||
self._overlay.visible = False
|
||||
self._sidebar_icon.set_active(False)
|
||||
self.page.update()
|
||||
|
||||
def _set_monitor_price(self, val: str):
|
||||
try:
|
||||
self.monitor_price = float(val)
|
||||
self.marketData.clear()
|
||||
self.listening_stock.clear()
|
||||
self._rebuild_tables()
|
||||
self.page.update()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# PanelIcon — 对齐 Tkinter 版 Canvas 手绘图标
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _PanelIcon(ft.Container):
|
||||
"""VSCode 风格面板切换图标 — 两个色块拼成的分栏图标"""
|
||||
|
||||
_SIZE = 22
|
||||
_M = 3
|
||||
_COLORS = {
|
||||
'light': {'bg': '#f0f0f0', 'hover': '#d4d4d4', 'off': '#b0b0b0', 'on': '#808080', 'active': '#0078d4'},
|
||||
'dark': {'bg': '#3c3c3c', 'hover': '#505050', 'off': '#6a6a6a', 'on': '#a0a0a0', 'active': '#ffffff'},
|
||||
}
|
||||
|
||||
def __init__(self, kind: str, active: bool = True, on_click=None):
|
||||
self._kind = kind
|
||||
self._active = active
|
||||
c = self._COLORS['light'] # 默认亮色,后续可扩展暗色检测
|
||||
self._bg = c['bg']
|
||||
self._hover_bg = c['hover']
|
||||
self._off = c['off']
|
||||
self._on = c['on']
|
||||
self._active_color = c['active']
|
||||
|
||||
rects = self._build_rects()
|
||||
super().__init__(
|
||||
content=rects,
|
||||
width=self._SIZE, height=self._SIZE,
|
||||
bgcolor=self._bg, border_radius=3,
|
||||
ink=True, on_click=on_click,
|
||||
padding=ft.Padding(self._M, self._M, self._M, self._M),
|
||||
)
|
||||
|
||||
def _build_rects(self):
|
||||
off, on, act = self._off, self._on, self._active_color
|
||||
bar_w, bar_h = 6, self._SIZE - self._M * 2 - 2
|
||||
|
||||
if self._kind == 'sidebar':
|
||||
c1 = on if self._active else off
|
||||
c2 = act if self._active else off
|
||||
return ft.Row([
|
||||
ft.Container(width=bar_w, height=bar_h, bgcolor=c1, border_radius=1),
|
||||
ft.Container(width=2), # gap
|
||||
ft.Container(width=bar_w, height=bar_h, bgcolor=c2, border_radius=1),
|
||||
], spacing=0)
|
||||
else:
|
||||
c1 = on if self._active else off
|
||||
c2 = act if self._active else off
|
||||
return ft.Column([
|
||||
ft.Container(width=bar_h, height=bar_w, bgcolor=c1, border_radius=1),
|
||||
ft.Container(height=2), # gap
|
||||
ft.Container(width=bar_h, height=bar_w, bgcolor=c2, border_radius=1),
|
||||
], spacing=0)
|
||||
|
||||
def set_active(self, active: bool):
|
||||
self._active = active
|
||||
self.content = self._build_rects()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# 入口
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def main(page: ft.Page):
|
||||
QmtApp(page)
|
||||
|
||||
|
||||
def run():
|
||||
ft.app(target=main)
|
||||
|
||||
def run_web():
|
||||
ft.app(target=main, view=ft.AppView.WEB_BROWSER, port=8550)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,142 +0,0 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from core.logger import LogLevel, LogData, PrintLog
|
||||
from core.ui.tkinter.sfgrid_view import TradeTargetUI
|
||||
|
||||
# 检测运行环境,决定使用真实或模拟 QMT
|
||||
def get_qmt_module():
|
||||
try:
|
||||
# 尝试导入真实 QMT,如果失败则使用模拟
|
||||
from core.qmt import qmtv
|
||||
return qmtv
|
||||
except ImportError:
|
||||
from core.qmt_dummy import qmtv
|
||||
return qmtv
|
||||
|
||||
qmtv = get_qmt_module()
|
||||
|
||||
from core.eventbus import EventPrintLog
|
||||
from core.eventbus import event_bus as eBus
|
||||
|
||||
|
||||
class MainWindow:
|
||||
def __init__(self, configLogLevel:str, progress=None):
|
||||
self.root = tk.Tk()
|
||||
self.root.title("神之一手 - 交易系统")
|
||||
self.root.geometry("1400x700")
|
||||
|
||||
self.logLevel = LogLevel[configLogLevel]
|
||||
PrintLog(LogLevel.DEBUG, f"系统启动成功 {self.logLevel.name}")
|
||||
# 存储各个Frame的引用
|
||||
self.strategy_frames = {}
|
||||
# 日志面板可见性标志
|
||||
self.log_visible = False
|
||||
self.create_ui(progress)
|
||||
|
||||
eBus.subscribe(EventPrintLog, self.on_log_event)
|
||||
|
||||
|
||||
def create_ui(self, progress=None):
|
||||
"""创建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)
|
||||
|
||||
# 右侧内容区域容器
|
||||
self.content_container = ttk.Frame(content_area)
|
||||
self.content_container.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
# 创建策略Frame
|
||||
strategy_names = ["网格"]
|
||||
self.create_strategy_frames(strategy_names, progress)
|
||||
|
||||
# 创建全局日志面板(默认隐藏)
|
||||
self.create_global_log_panel(main_container)
|
||||
|
||||
# 默认显示第一个策略
|
||||
self.show_strategy_frame(0)
|
||||
|
||||
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, progress=None):
|
||||
"""创建各个策略的Frame"""
|
||||
frame = TradeTargetUI(self.content_container, progress=progress)
|
||||
self.strategy_frames[0] = frame
|
||||
|
||||
def show_strategy_frame(self, index):
|
||||
"""显示策略Frame"""
|
||||
if index in self.strategy_frames:
|
||||
self.strategy_frames[index].pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
def toggle_log_panel(self):
|
||||
"""切换日志面板的显示/隐藏"""
|
||||
if self.log_visible:
|
||||
self.log_frame.pack_forget()
|
||||
self.log_visible = False
|
||||
else:
|
||||
self.log_frame.pack(side=tk.BOTTOM, fill=tk.X, pady=(5, 0))
|
||||
self.log_visible = True
|
||||
|
||||
def on_exit(self):
|
||||
"""退出程序"""
|
||||
from tkinter import messagebox
|
||||
result = messagebox.askyesno("确认退出", "确定要退出系统吗?")
|
||||
if result:
|
||||
self.root.destroy()
|
||||
|
||||
def run(self):
|
||||
"""运行程序"""
|
||||
self.root.mainloop()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,112 +0,0 @@
|
||||
"""
|
||||
启动进度窗口 — 无边框小窗口,负责整个初始化流程。
|
||||
"""
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
|
||||
|
||||
class SplashWindow:
|
||||
"""初始化进度窗口,所有者启动逻辑"""
|
||||
|
||||
def __init__(self):
|
||||
self.root = tk.Tk()
|
||||
self.root.title("神之一手")
|
||||
self.root.geometry("380x120")
|
||||
self.root.resizable(False, False)
|
||||
self.root.overrideredirect(True)
|
||||
|
||||
self.root.update_idletasks()
|
||||
sw = self.root.winfo_screenwidth()
|
||||
sh = self.root.winfo_screenheight()
|
||||
w, h = 380, 120
|
||||
self.root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
|
||||
|
||||
frame = ttk.Frame(self.root, padding=20)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
ttk.Label(frame, text="神之一手", font=('Microsoft YaHei', 14, 'bold')).pack(pady=(0, 5))
|
||||
self._status = ttk.Label(frame, text="正在初始化...", font=('Microsoft YaHei', 9))
|
||||
self._status.pack(pady=(0, 10))
|
||||
|
||||
self._bar = ttk.Progressbar(frame, mode='determinate', length=340)
|
||||
self._bar.pack()
|
||||
self.root.update()
|
||||
|
||||
def progress(self, text: str, pct: float):
|
||||
self._status.configure(text=text)
|
||||
self._bar.configure(value=pct)
|
||||
self.root.update()
|
||||
|
||||
def _destroy(self):
|
||||
self.root.destroy()
|
||||
|
||||
def run(self):
|
||||
"""执行完整启动流程,成功返回主窗口,失败返回 None"""
|
||||
from core.qmt_real import RealQmtV, qmtv as selected_qmtv
|
||||
|
||||
while True:
|
||||
_t_total = time.time()
|
||||
|
||||
# 步骤1: 探测 QMT 环境
|
||||
self.progress("正在检查 QMT 环境...", 10)
|
||||
_t = time.time()
|
||||
try:
|
||||
discovered = RealQmtV._discover_qmt_port()
|
||||
except Exception:
|
||||
discovered = 0
|
||||
print(f'[计时] 步骤1-探测QMT环境: {time.time() - _t:.2f}s')
|
||||
|
||||
if not discovered:
|
||||
self._destroy()
|
||||
messagebox.showerror(
|
||||
"启动失败",
|
||||
"未能自动探测到 QMT 环境。\n\n"
|
||||
"请确认:\n"
|
||||
"1. 极简QMT(GJQMT)已启动并登录\n"
|
||||
"2. XtMiniQmt.exe 和 miniquote.exe 进程在运行"
|
||||
)
|
||||
return None
|
||||
|
||||
# 步骤2: 初始化交易器
|
||||
self.progress("正在初始化交易器...", 35)
|
||||
_t = time.time()
|
||||
selected_qmtv.init_qmtv()
|
||||
print(f'[计时] 步骤2-初始化交易器: {time.time() - _t:.2f}s')
|
||||
|
||||
# 步骤3: 连接 QMT
|
||||
self.progress("正在连接 QMT...", 55)
|
||||
_t = time.time()
|
||||
connected = selected_qmtv.connect()
|
||||
print(f'[计时] 步骤3-连接QMT: {time.time() - _t:.2f}s')
|
||||
|
||||
if not connected:
|
||||
self._destroy()
|
||||
option = messagebox.askokcancel(
|
||||
"连接失败",
|
||||
"QMT 连接失败。\n\n"
|
||||
"请确认极简QMT 已启动并登录交易账号。\n"
|
||||
"点击「确定」重试,或「取消」退出。"
|
||||
)
|
||||
if not option:
|
||||
return None
|
||||
# 重试:重新创建进度窗口
|
||||
self.__init__()
|
||||
continue
|
||||
|
||||
# 步骤4: 加载主界面
|
||||
self.progress("正在加载持仓与策略...", 75)
|
||||
_t = time.time()
|
||||
from core.ui.tkinter.main_window import MainWindow
|
||||
window = MainWindow('INFO', progress=lambda t, p: self.progress(t, 75 + p * 0.2))
|
||||
print(f'[计时] 步骤4-主界面加载: {time.time() - _t:.2f}s')
|
||||
|
||||
window.root.update()
|
||||
|
||||
# 步骤5: 完成
|
||||
self.progress("启动完成", 100)
|
||||
self.root.update()
|
||||
self.root.after(300, self._destroy)
|
||||
print(f'[计时] 总启动耗时: {time.time() - _t_total:.2f}s')
|
||||
|
||||
return window
|
||||
@@ -1,98 +0,0 @@
|
||||
# 统一网格逻辑
|
||||
|
||||
## 核心规则
|
||||
|
||||
对任意 `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 |
|
||||
@@ -1,197 +0,0 @@
|
||||
# v6.6 模型发布说明
|
||||
|
||||
**版本**: v6.6
|
||||
**发布日期**: 2026-06-16
|
||||
**状态**: 生产就绪
|
||||
|
||||
---
|
||||
|
||||
## 一、模型架构
|
||||
|
||||
v6.6 采用 **Stacking Calibrated** 三层融合架构:
|
||||
|
||||
```
|
||||
输入特征 (52维 v3.4)
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Rank 模型 │ │ Top 模型 │
|
||||
│ LGBMRegressor │ │ LGBMClassifier │
|
||||
│ 预测网格轮回次数 │ │ 分类精英股票 │
|
||||
│ CV MAE: 0.2053 │ │ CV PR-AUC: 0.53│
|
||||
│ CV R²: 0.2258 │ │ │
|
||||
└────────┬────────┘ └────────┬────────┘
|
||||
│ │
|
||||
└──────────┬───────────┘
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Stacking 模型 │
|
||||
│ LGBMClassifier │
|
||||
│ CV PR-AUC: 0.8207 │
|
||||
│ 最优阈值: 0.35 │
|
||||
└──────────┬──────────┘
|
||||
▼
|
||||
最终 top 概率
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、训练数据
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 特征版本 | v3.4 (52维) |
|
||||
| 原始股票数 | 4,358 |
|
||||
| 过滤后股票数 | 1,533 |
|
||||
| 训练样本数 | 205,491 |
|
||||
| 观察窗口 | 120天 |
|
||||
| 预测窗口 | 60天 |
|
||||
| 零触碰率 | 71.1% |
|
||||
| Elite率 | 20.5% |
|
||||
|
||||
---
|
||||
|
||||
## 三、模型性能
|
||||
|
||||
### Rank模型 (回归)
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| CV MAE | 0.2053 |
|
||||
| CV R² | 0.2258 |
|
||||
| 最优参数 | num_leaves=63, min_child_samples=30, max_depth=7 |
|
||||
|
||||
### Top模型 (分类)
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| CV PR-AUC | 0.5333 |
|
||||
| 最优参数 | num_leaves=63, min_child_samples=30, max_depth=-1 |
|
||||
|
||||
### Stacking融合模型
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| CV PR-AUC | **0.8207** |
|
||||
| 最优阈值 | 0.35 |
|
||||
| 最优参数 | num_leaves=31, min_child_samples=20, max_depth=-1 |
|
||||
|
||||
---
|
||||
|
||||
## 四、Top 10 重要特征
|
||||
|
||||
### Rank模型
|
||||
| 排名 | 特征 | 重要性% |
|
||||
|------|------|---------|
|
||||
| 1 | dist_to_grid_upper | 4.38% |
|
||||
| 2 | dist_to_grid_lower | 4.26% |
|
||||
| 3 | price_cv | 4.09% |
|
||||
| 4 | amount_mean_20d | 3.98% |
|
||||
| 5 | range_compression_20d | 3.48% |
|
||||
|
||||
### Stacking融合
|
||||
| 排名 | 特征 | 重要性% |
|
||||
|------|------|---------|
|
||||
| 1 | rank_predicted_rounds | 20.87% |
|
||||
| 2 | top_elite_prob | 19.38% |
|
||||
| 3 | ma20_deviation_pct | 5.35% |
|
||||
| 4 | atr_pct | 4.93% |
|
||||
| 5 | dist_to_grid_lower | 3.75% |
|
||||
|
||||
---
|
||||
|
||||
## 五、周评分回测结果 (2023-04 ~ 2026-04)
|
||||
|
||||
| 指标 | v6.6 数值 |
|
||||
|------|-----------|
|
||||
| **总收益率** | **+36.79%** |
|
||||
| 年化夏普 | 0.8494 |
|
||||
| 最大回撤 | -12.70% |
|
||||
| 胜率(周) | 48.7% |
|
||||
| 交易次数 | 277 (147买, 130卖) |
|
||||
| 最大持仓 | 10只 |
|
||||
|
||||
### 年度收益
|
||||
| 年度 | 收益 |
|
||||
|------|------|
|
||||
| 2023 | +2.09% |
|
||||
| 2024 | +28.34% |
|
||||
| 2025 | +7.32% |
|
||||
| 2026 | +0.52% |
|
||||
|
||||
---
|
||||
|
||||
## 六、与v6.5对比
|
||||
|
||||
| 指标 | v6.5 | v6.6 | 变化 |
|
||||
|------|------|------|------|
|
||||
| Stacking PR-AUC | 0.5234 | **0.8207** | +57% |
|
||||
| 特征数 | 37维 | **52维** | +15维 |
|
||||
| Top PR-AUC | 0.3923 | 0.5333 | +36% |
|
||||
| 回测收益率 | 59.82% | 36.79%* | - |
|
||||
| 回测最大回撤 | -33.47% | **-12.70%** | -62% |
|
||||
|
||||
\* v6.6使用周评分回测(157周),v6.5使用日评分回测(723天),粒度不同
|
||||
|
||||
---
|
||||
|
||||
## 七、模型文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `rank.pkl` | Rank模型 (预测网格轮回次数) |
|
||||
| `top.pkl` | Top模型 (分类精英股票) |
|
||||
| `stacking.pkl` | Stacking融合模型 |
|
||||
| `rank_feature_importance.csv` | Rank特征重要性 |
|
||||
| `top_feature_importance.csv` | Top特征重要性 |
|
||||
| `stacking_feature_importance.csv` | Stacking特征重要性 |
|
||||
| `training_summary.json` | 训练摘要 |
|
||||
|
||||
---
|
||||
|
||||
## 八、使用方式
|
||||
|
||||
```python
|
||||
import pickle
|
||||
import numpy as np
|
||||
|
||||
# 加载模型
|
||||
with open("rank.pkl", "rb") as f:
|
||||
rank_md = pickle.load(f)
|
||||
with open("top.pkl", "rb") as f:
|
||||
top_md = pickle.load(f)
|
||||
with open("stacking.pkl", "rb") as f:
|
||||
stacking_md = pickle.load(f)
|
||||
|
||||
# 预测
|
||||
rank_pred = rank_md["model"].predict(features)
|
||||
top_prob = top_md["model"].predict_proba(features)[:, 1]
|
||||
stacking_prob = stacking_md["model"].predict_proba(features)[:, 1]
|
||||
|
||||
# 选股
|
||||
threshold = 0.35 # stacking最优阈值
|
||||
top_picks = scores[scores["stacking_prob"] >= threshold]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、Registry配置
|
||||
|
||||
```yaml
|
||||
# grid_seeker/registry.yaml
|
||||
production:
|
||||
version: v6.6
|
||||
architecture: stacking_calibrated
|
||||
models:
|
||||
stacking: versions/6.6/output/stacking.pkl
|
||||
rank: versions/6.6/output/rank.pkl
|
||||
top: versions/6.6/output/top.pkl
|
||||
feature_version: v3.4
|
||||
training_samples: 205491
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、注意事项
|
||||
|
||||
1. **特征版本**: 必须使用 v3.4 特征(52维),与v6.5/v6.4不兼容
|
||||
2. **Stacking阈值**: 推荐使用 0.35 作为选股阈值
|
||||
3. **持股上限**: 建议不超过10只
|
||||
4. **价格区间**: 适合7-10元区间股票
|
||||
@@ -1,38 +0,0 @@
|
||||
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
|
||||
|
@@ -1 +0,0 @@
|
||||
|
||||
|
|
@@ -1,158 +0,0 @@
|
||||
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
|
||||
|
Binary file not shown.
@@ -1,53 +0,0 @@
|
||||
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
|
||||
|
Binary file not shown.
@@ -1,55 +0,0 @@
|
||||
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
|
||||
|
Binary file not shown.
@@ -1,54 +0,0 @@
|
||||
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
|
||||
|
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"version": "v6.6",
|
||||
"feature_version": "v3.4",
|
||||
"architecture": "stacking_calibrated",
|
||||
"data_source": "mysql://100.121.118.116:3306/grid_seeker_model_base",
|
||||
"training_date": "2026-05-28T14:23:23.662118",
|
||||
"n_stocks_total": 4358,
|
||||
"n_stocks_after_filter": 1533,
|
||||
"n_training_samples": 205491,
|
||||
"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": 20.52109338121864,
|
||||
"rank": {
|
||||
"cv_mae": 0.2053,
|
||||
"cv_r2": 0.2258,
|
||||
"best_params": {
|
||||
"num_leaves": 63,
|
||||
"min_child_samples": 30,
|
||||
"max_depth": 7
|
||||
},
|
||||
"n_features": 52
|
||||
},
|
||||
"top": {
|
||||
"cv_pr_auc": 0.5333,
|
||||
"best_params": {
|
||||
"num_leaves": 63,
|
||||
"min_child_samples": 30,
|
||||
"max_depth": -1
|
||||
},
|
||||
"n_features": 53
|
||||
},
|
||||
"stacking": {
|
||||
"cv_pr_auc": 0.8207,
|
||||
"best_params": {
|
||||
"num_leaves": 31,
|
||||
"min_child_samples": 20,
|
||||
"max_depth": -1
|
||||
},
|
||||
"n_features": 54,
|
||||
"optimal_threshold": 0.35
|
||||
}
|
||||
}
|
||||
+10
-67
@@ -1,69 +1,12 @@
|
||||
# coding:utf-8
|
||||
"""
|
||||
启动入口 — 默认使用 Flet2 UI。
|
||||
使用 --tk 参数切换到 Tkinter UI。
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import ssl
|
||||
import traceback
|
||||
import tkinter as tk
|
||||
from core.main_entry import MainEntry
|
||||
|
||||
# 修复 Windows 上 flet_desktop 子进程弹出控制台窗口的问题
|
||||
# 原始 Popen 不带 CREATE_NO_WINDOW 标志,会为每个子进程创建控制台窗口
|
||||
_original_popen = subprocess.Popen
|
||||
|
||||
class Popen(_original_popen):
|
||||
def __init__(self, *args, **kwargs):
|
||||
if sys.platform == "win32" and "creationflags" not in kwargs:
|
||||
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
subprocess.Popen = Popen
|
||||
|
||||
# PyInstaller 打包后,设置 FLET_VIEW_PATH 指向打包内的 Flet 客户端
|
||||
# 避免从 GitHub 下载
|
||||
if getattr(sys, 'frozen', False):
|
||||
# 运行在打包后的 exe 中
|
||||
base_path = sys._MEIPASS # PyInstaller 解压到的临时目录
|
||||
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')
|
||||
|
||||
# 禁用 SSL 验证,避免证书问题
|
||||
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__':
|
||||
if '--tk' in sys.argv:
|
||||
from core.ui.tkinter.splash import SplashWindow
|
||||
from tkinter import messagebox
|
||||
try:
|
||||
window = SplashWindow().run()
|
||||
if window:
|
||||
window.run()
|
||||
except Exception as e:
|
||||
messagebox.showerror("错误", f"系统初始化失败: {str(e)}")
|
||||
else:
|
||||
from core.ui.flet.app_v2 import run
|
||||
run()
|
||||
# 这是应用的启动入口程序,负责初始化并启动主窗口。
|
||||
# 它创建一个Tkinter根窗口,实例化主窗口类MainBoardWindow,
|
||||
# 并调用其run方法启动主事件循环。
|
||||
if __name__ == "__main__":
|
||||
import tkinter as tk
|
||||
root = tk.Tk()
|
||||
app = MainEntry(root)
|
||||
app.run()
|
||||
+4
-4
@@ -4,7 +4,7 @@ a = Analysis(
|
||||
['starter.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('xtquant/xtdata.ini', 'xtquant'), ('flet_desktop/app', 'flet_desktop/app')], # xtdata 依赖的配置文件
|
||||
datas=[('config.ini', '.'), ('xtquant/xtdata.ini', 'xtquant')], # 明确包含配置文件和xtdata.ini
|
||||
hiddenimports=['brotli', 'brotli.encoding'],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
@@ -24,8 +24,8 @@ exe = EXE(
|
||||
name='神之一手',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
strip=True, # 去除调试符号
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
@@ -34,5 +34,5 @@ exe = EXE(
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon='logo.ico',
|
||||
icon='logo.png' # 添加图标文件
|
||||
)
|
||||
Reference in New Issue
Block a user