6 Commits

Author SHA1 Message Date
kyugao 0b916b5c44 Merge branch 'new_structure' of ssh://git.gogao.top:2222/sfgrid into new_structure
# Conflicts:
#	config.py
2026-01-04 17:48:33 +08:00
kyugao 5a26f5f7b3 update 2026-01-04 17:46:48 +08:00
kyugao 66768cb359 update config 2025-12-08 18:08:43 +08:00
kyugao 988947aa1a 适配macos中对pytray支持不好的情况。使用系统菜单。 2025-12-06 00:12:44 +08:00
kyugao b435f12c49 update 2025-12-05 18:06:39 +08:00
kyugao c59d29d52e init new structure 2025-12-05 17:43:13 +08:00
79 changed files with 2265 additions and 9354 deletions
-3
View File
@@ -6,6 +6,3 @@ starter.dist/starter.dll
build/ build/
.vscode/ .vscode/
example.db.bak example.db.bak
venv/
flet_desktop/
.flet/
-233
View File
@@ -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(每格利润空间)
```
+5
View File
@@ -0,0 +1,5 @@
[config]
miniqmtpath = D:/Programs/DTQMT/userdata_mini
account_no = 99082560
log_level = INFO
+57 -18
View File
@@ -1,25 +1,64 @@
""" import configparser
运行时配置 — 端口、路径、账号由自动探测设置,无需配置文件。
"""
import os
import sys
from pathlib import Path from pathlib import Path
import sys
from typing import Any
# ---- 自动探测的配置项(默认值仅占位,启动时自动修正) ---- miniQMTPath = r'D:\\Programs\\DTQMT\\userdata_mini' # miniQMT软件的安装路径
miniQMTPath: str = '' # miniQMTPath = ''
account_no: str = '' account_no:str = '99082560'
log_level: str = 'INFO' console_log = True
console_log: bool = True log_level = "INFO"
use_simulated_qmt: bool = False
config : Any
def app_dir() -> Path: def get_config_path() -> Path:
"""应用根目录(兼容开发环境打包后的 exe""" """获取配置文件的正确路径(兼容开发环境打包后的可执行文件"""
if getattr(sys, 'frozen', False): 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: def get_config(section:str, key:str):
"""日志文件路径""" pass
return app_dir() / 'sfgrid.log'
def save_config(miniQmtPath:str, account_no:str):
"""创建默认配置文件"""
config = configparser.ConfigParser()
config['config'] = {
'miniQMTPath': miniQmtPath,
'account_no': account_no
}
config_path = get_config_path()
with open(config_path, 'w') as configfile:
config.write(configfile)
print(f'已创建默认配置文件: {config_path}')
def exist_config() -> bool:
"""检查配置文件是否存在"""
config_path = get_config_path()
return config_path.exists()
def initConfig() -> bool:
global miniQMTPath, account_no, log_level
# 获取配置文件路径
config_path = get_config_path()
config = configparser.ConfigParser()
config.read(config_path, encoding='utf-8')
miniQMTPath = config.get('config','miniQMTPath')
account_no = config.get('config','account_no')
log_level = config.get('config','log_level')
# 判断miniQMTPath是否为空,并且目录是否存在
if not miniQMTPath or not Path(miniQMTPath).exists():
print('请先配置miniQMTPath')
return False
else:
return True
+94
View File
@@ -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
+11
View File
@@ -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)
+1
View File
@@ -2,4 +2,5 @@ import xtquant.xtconstant as xtconstant
OrderTypeBuy = f'{xtconstant.STOCK_BUY}' # 买 OrderTypeBuy = f'{xtconstant.STOCK_BUY}' # 买
OrderTypeSell = f'{xtconstant.STOCK_SELL}' # 卖 OrderTypeSell = f'{xtconstant.STOCK_SELL}' # 卖
OrderTypeInit = "0" # 建仓
OrderTypeNone = "None" OrderTypeNone = "None"
+1 -2
View File
@@ -1,10 +1,9 @@
from peewee import SqliteDatabase, Model from peewee import SqliteDatabase, Model
from core.logger import LogLevel, PrintLog
# 连接到SQLite数据库 # 连接到SQLite数据库
db: SqliteDatabase = SqliteDatabase('example.db') db: SqliteDatabase = SqliteDatabase('example.db')
db.connect() db.connect()
PrintLog(LogLevel.INFO, '- [成功]数据库连接') print("Database connected")
# 定义基础模型类 # 定义基础模型类
class BaseModel(Model): class BaseModel(Model):
+17
View File
@@ -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'})
+7
View File
@@ -0,0 +1,7 @@
from .eventbus import EventBus
# Pring Log
EventPrintLog = "print_log" # 打印日志
# 创建事件总线实例
loggerEBus = EventBus()
+10
View File
@@ -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()
-47
View File
@@ -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()
+5 -28
View File
@@ -1,9 +1,7 @@
from datetime import datetime
from enum import Enum from enum import Enum
import threading
from core.eventbus import EventPrintLog, event_bus from core.ebus.logger_ebus import EventPrintLog, loggerEBus
import config from core.config import config as config
class LogLevel(Enum): class LogLevel(Enum):
@@ -16,34 +14,13 @@ class LogLevel(Enum):
def __le__(self, other): def __le__(self, other):
return self.value <= other.value return self.value <= other.value
class LogData: class LogData:
def __init__(self, level:LogLevel, message:str): def __init__(self, level:LogLevel, message:str):
self.level = level self.level = level
self.message = message self.message = message
_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) data = LogData(level, message)
event_bus.publish(EventPrintLog, data) loggerEBus.publish(EventPrintLog, data)
if config.getConsoleLog():
line = f'{datetime.now().strftime("%Y-%m-%d %H:%M:%S")} [{level.name}] {message}' print(f'{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 # 写文件失败不阻塞主流程
+179
View File
@@ -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
View File
@@ -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()
+8
View File
@@ -0,0 +1,8 @@
from qmt import QmtV
from eventbus import marketDataEventBus
qmtv:QmtV = None
def init_qmtv():
global qmtv
qmtv = QmtV()
+209
View File
@@ -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)
+7
View File
@@ -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
View File
@@ -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()
-314
View File
@@ -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()
-621
View File
@@ -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()
+11
View File
@@ -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
View File
@@ -1 +0,0 @@
# grid_seeker v6.4 评分模块
-165
View File
@@ -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()
-54
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
# 特征工程子包
from core.scoring.features.validator import load_candidates, DataContext
from core.scoring.features.pipeline import FeaturePipeline
-147
View File
@@ -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')
-106
View File
@@ -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
-88
View File
@@ -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')
-203
View File
@@ -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
-130
View File
@@ -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
-109
View File
@@ -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')
-197
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
# 模型推理子包
from core.scoring.inference.scorer import GridSeekerPipeline
-217
View File
@@ -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]
-132
View File
@@ -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)
-7
View File
@@ -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
-41
View File
@@ -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):
"""将数据写入数据库。子类实现。"""
-84
View File
@@ -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]
-222
View File
@@ -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]
-98
View File
@@ -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]
-129
View File
@@ -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]
-100
View File
@@ -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]
-7
View File
@@ -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'}
-537
View File
@@ -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
+3
View File
@@ -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 from core.database import BaseModel, db
# 策略类型常量
STRATEGY_TYPE_UNCLASSIFIED = 0 # 未分类持仓
STRATEGY_TYPE_GRID = 1 # 网格策略
# 定义Target类,对应targets表 # 定义Target类,对应targets表
class SFGridTradeTarget(BaseModel): class SFGridTradeTarget(BaseModel):
@@ -16,13 +12,12 @@ class SFGridTradeTarget(BaseModel):
init_price = FloatField(null=True) # 建仓成本 init_price = FloatField(null=True) # 建仓成本
grid_match_count = IntegerField(default=0) grid_match_count = IntegerField(default=0)
grid_total_profit = FloatField(default=0.0) grid_total_profit = FloatField(default=0.0)
status = IntegerField(default=0) # 已废弃,改用 strategy_type + grid_index status = IntegerField(default=0) # -1表示新标的,未完成交易配置,0表示新标的,已完成交易配置,1表示已建初始仓,正常交易中
enabled = BooleanField(default=False) # 是否启动交易线程 enabled = BooleanField(default=False) # 是否启动交易线程
strategy_type = IntegerField(default=0) # 0=未分类, 1=网格策略
grid_start_price = FloatField(default=10.0) # 基线价格 grid_start_price = FloatField(default=10.0) # 基线价格
grid_size = FloatField(default=1.0) # 网格价位差 grid_size = FloatField(default=0.1) # 网格价位差
grid_volume = IntegerField(default=200) # 网格交易量 grid_volume = IntegerField(default=100) # 网格交易量
grid_upper_count = IntegerField(default=1) # 基线价格上方网格数 grid_upper_count = IntegerField(default=1) # 基线价格上方网格数
grid_lower_count = IntegerField(default=10) # 基线价格下方网格数 grid_lower_count = IntegerField(default=10) # 基线价格下方网格数
@@ -47,14 +42,3 @@ class SFGridTradeTarget(BaseModel):
db.create_tables([SFGridTradeTarget]) db.create_tables([SFGridTradeTarget])
# 数据库迁移: 为已有表添加 strategy_type 字段(如果不存在)
try:
from playhouse.migrate import migrate, SqliteMigrator
migrator = SqliteMigrator(db)
migrate(
migrator.add_column('sfgridtradetarget', 'strategy_type', SFGridTradeTarget.strategy_type),
)
except Exception:
# 字段已存在或迁移失败 — 静默跳过
pass
+223
View File
@@ -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
+985
View File
@@ -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("错误", "请输入有效的数字")
+149
View File
@@ -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()
View File
View File
-836
View File
@@ -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
View File
-142
View File
@@ -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
-112
View File
@@ -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
-98
View File
@@ -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 |
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File
-197
View File
@@ -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元区间股票
-38
View File
@@ -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 date cash stock_value total_value positions total_shares month
2 2023-05-31 60000.0 0.0 60000.0 0 0 2023-05
3 2023-06-30 60000.0 0.0 60000.0 0 0 2023-06
4 2023-07-31 60000.0 0.0 60000.0 0 0 2023-07
5 2023-08-31 60000.0 0.0 60000.0 0 0 2023-08
6 2023-09-30 60000.0 0.0 60000.0 0 0 2023-09
7 2023-10-31 60000.0 0.0 60000.0 0 0 2023-10
8 2023-11-30 60000.0 0.0 60000.0 0 0 2023-11
9 2023-12-31 60000.0 0.0 60000.0 0 0 2023-12
10 2024-01-31 60000.0 0.0 60000.0 0 0 2024-01
11 2024-02-29 60000.0 0.0 60000.0 0 0 2024-02
12 2024-03-31 60000.0 0.0 60000.0 0 0 2024-03
13 2024-04-30 60000.0 0.0 60000.0 0 0 2024-04
14 2024-05-31 60000.0 0.0 60000.0 0 0 2024-05
15 2024-06-30 60000.0 0.0 60000.0 0 0 2024-06
16 2024-07-31 60000.0 0.0 60000.0 0 0 2024-07
17 2024-08-31 60000.0 0.0 60000.0 0 0 2024-08
18 2024-09-30 60000.0 0.0 60000.0 0 0 2024-09
19 2024-10-31 60000.0 0.0 60000.0 0 0 2024-10
20 2024-11-30 60000.0 0.0 60000.0 0 0 2024-11
21 2024-12-31 60000.0 0.0 60000.0 0 0 2024-12
22 2025-01-31 60000.0 0.0 60000.0 0 0 2025-01
23 2025-02-28 60000.0 0.0 60000.0 0 0 2025-02
24 2025-03-31 60000.0 0.0 60000.0 0 0 2025-03
25 2025-04-30 60000.0 0.0 60000.0 0 0 2025-04
26 2025-05-31 60000.0 0.0 60000.0 0 0 2025-05
27 2025-06-30 60000.0 0.0 60000.0 0 0 2025-06
28 2025-07-31 60000.0 0.0 60000.0 0 0 2025-07
29 2025-08-31 60000.0 0.0 60000.0 0 0 2025-08
30 2025-09-30 60000.0 0.0 60000.0 0 0 2025-09
31 2025-10-31 60000.0 0.0 60000.0 0 0 2025-10
32 2025-11-30 60000.0 0.0 60000.0 0 0 2025-11
33 2025-12-31 60000.0 0.0 60000.0 0 0 2025-12
34 2026-01-31 60000.0 0.0 60000.0 0 0 2026-01
35 2026-02-28 60000.0 0.0 60000.0 0 0 2026-02
36 2026-03-31 60000.0 0.0 60000.0 0 0 2026-03
37 2026-04-30 60000.0 0.0 60000.0 0 0 2026-04
38 2026-05-27 60000.0 0.0 60000.0 0 0 2026-05
-1
View File
@@ -1 +0,0 @@
-158
View File
@@ -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
1 date cash stock_value total_value positions year
2 2023-04-28 60000.0 0.0 60000.0 10 2023
3 2023-05-05 58400.0 1618.0 60018.0 10 2023
4 2023-05-12 55400.0 4570.0 59970.0 10 2023
5 2023-05-19 52200.0 7428.0 59628.0 10 2023
6 2023-05-26 45200.0 14102.0 59302.0 10 2023
7 2023-06-02 49800.0 11204.0 61004.0 10 2023
8 2023-06-09 55600.0 5996.0 61596.0 10 2023
9 2023-06-16 55600.0 5988.0 61588.0 10 2023
10 2023-06-23 53800.0 7522.0 61322.0 10 2023
11 2023-06-30 49200.0 11602.0 60802.0 10 2023
12 2023-07-07 51200.0 9542.0 60742.0 10 2023
13 2023-07-14 48400.0 12576.0 60976.0 10 2023
14 2023-07-21 50200.0 10406.0 60606.0 10 2023
15 2023-07-28 50200.0 10268.0 60468.0 10 2023
16 2023-08-04 50200.0 10622.0 60822.0 10 2023
17 2023-08-11 50200.0 10078.0 60278.0 10 2023
18 2023-08-18 47400.0 12614.0 60014.0 10 2023
19 2023-08-25 46200.0 13572.0 59772.0 10 2023
20 2023-09-01 46200.0 14042.0 60242.0 10 2023
21 2023-09-08 46800.0 12942.0 59742.0 10 2023
22 2023-09-15 45200.0 14398.0 59598.0 10 2023
23 2023-09-22 42600.0 17276.0 59876.0 10 2023
24 2023-09-29 42600.0 17430.0 60030.0 10 2023
25 2023-10-06 42600.0 17430.0 60030.0 10 2023
26 2023-10-13 42600.0 16750.0 59350.0 10 2023
27 2023-10-20 41000.0 17366.0 58366.0 10 2023
28 2023-10-27 39600.0 18870.0 58470.0 10 2023
29 2023-11-03 42600.0 17234.0 59834.0 10 2023
30 2023-11-10 45400.0 15998.0 61398.0 10 2023
31 2023-11-17 45400.0 16098.0 61498.0 10 2023
32 2023-11-24 47000.0 14652.0 61652.0 10 2023
33 2023-12-01 45800.0 16434.0 62234.0 10 2023
34 2023-12-08 48800.0 13898.0 62698.0 10 2023
35 2023-12-15 50800.0 12312.0 63112.0 10 2023
36 2023-12-22 45400.0 15928.0 61328.0 10 2023
37 2023-12-29 41000.0 20256.0 61256.0 10 2023
38 2024-01-05 42800.0 17966.0 60766.0 10 2024
39 2024-01-12 40000.0 20254.0 60254.0 10 2024
40 2024-01-19 37600.0 22196.0 59796.0 10 2024
41 2024-01-26 35000.0 25716.0 60716.0 10 2024
42 2024-02-02 25600.0 31044.0 56644.0 10 2024
43 2024-02-09 19400.0 36784.0 56184.0 10 2024
44 2024-02-16 19400.0 36784.0 56184.0 10 2024
45 2024-02-23 29400.0 33418.0 62818.0 10 2024
46 2024-03-01 35200.0 28470.0 63670.0 10 2024
47 2024-03-08 36400.0 26802.0 63202.0 10 2024
48 2024-03-15 40800.0 23672.0 64472.0 10 2024
49 2024-03-22 48600.0 19494.0 68094.0 10 2024
50 2024-03-29 48800.0 17936.0 66736.0 10 2024
51 2024-04-05 44800.0 21374.0 66174.0 10 2024
52 2024-04-12 42200.0 22450.0 64650.0 10 2024
53 2024-04-19 35000.0 27966.0 62966.0 10 2024
54 2024-04-26 36600.0 28114.0 64714.0 10 2024
55 2024-05-03 39000.0 27576.0 66576.0 10 2024
56 2024-05-10 42200.0 24400.0 66600.0 10 2024
57 2024-05-17 39800.0 26702.0 66502.0 10 2024
58 2024-05-24 38200.0 27704.0 65904.0 10 2024
59 2024-05-31 38400.0 26486.0 64886.0 10 2024
60 2024-06-07 32200.0 30776.0 62976.0 10 2024
61 2024-06-14 32200.0 31802.0 64002.0 10 2024
62 2024-06-21 31000.0 31084.0 62084.0 10 2024
63 2024-06-28 29000.0 32180.0 61180.0 10 2024
64 2024-07-05 29000.0 32292.0 61292.0 10 2024
65 2024-07-12 27200.0 33578.0 60778.0 10 2024
66 2024-07-19 27200.0 33172.0 60372.0 10 2024
67 2024-07-26 24200.0 35970.0 60170.0 10 2024
68 2024-08-02 24200.0 36992.0 61192.0 10 2024
69 2024-08-09 26000.0 34954.0 60954.0 10 2024
70 2024-08-16 26000.0 34780.0 60780.0 10 2024
71 2024-08-23 24400.0 35044.0 59444.0 10 2024
72 2024-08-30 23400.0 37774.0 61174.0 10 2024
73 2024-09-06 23400.0 36744.0 60144.0 10 2024
74 2024-09-13 23400.0 36430.0 59830.0 10 2024
75 2024-09-20 25200.0 35478.0 60678.0 10 2024
76 2024-09-27 30200.0 35280.0 65480.0 10 2024
77 2024-10-04 37000.0 32120.0 69120.0 10 2024
78 2024-10-11 40600.0 26824.0 67424.0 10 2024
79 2024-10-18 40600.0 28430.0 69030.0 10 2024
80 2024-10-25 43600.0 27342.0 70942.0 10 2024
81 2024-11-01 49600.0 23190.0 72790.0 10 2024
82 2024-11-08 53200.0 21184.0 74384.0 10 2024
83 2024-11-15 56000.0 19820.0 75820.0 10 2024
84 2024-11-22 59400.0 16790.0 76190.0 10 2024
85 2024-11-29 63600.0 14610.0 78210.0 10 2024
86 2024-12-06 63800.0 15148.0 78948.0 10 2024
87 2024-12-13 70600.0 9146.0 79746.0 10 2024
88 2024-12-20 67800.0 11830.0 79630.0 10 2024
89 2024-12-27 63400.0 14588.0 77988.0 10 2024
90 2025-01-03 55800.0 20000.0 75800.0 10 2025
91 2025-01-10 54400.0 21344.0 75744.0 10 2025
92 2025-01-17 57400.0 20314.0 77714.0 10 2025
93 2025-01-24 57400.0 20416.0 77816.0 10 2025
94 2025-01-31 57400.0 20178.0 77578.0 10 2025
95 2025-02-07 57400.0 21214.0 78614.0 10 2025
96 2025-02-14 63600.0 16530.0 80130.0 10 2025
97 2025-02-21 65000.0 14804.0 79804.0 10 2025
98 2025-02-28 62400.0 16588.0 78988.0 10 2025
99 2025-03-07 62400.0 16784.0 79184.0 10 2025
100 2025-03-14 62400.0 17590.0 79990.0 10 2025
101 2025-03-21 62400.0 16742.0 79142.0 10 2025
102 2025-03-28 62400.0 16480.0 78880.0 10 2025
103 2025-04-04 59800.0 18538.0 78338.0 10 2025
104 2025-04-11 55200.0 22474.0 77674.0 10 2025
105 2025-04-18 58600.0 19992.0 78592.0 10 2025
106 2025-04-25 58800.0 20036.0 78836.0 10 2025
107 2025-05-02 57200.0 21798.0 78998.0 10 2025
108 2025-05-09 59000.0 20540.0 79540.0 10 2025
109 2025-05-16 59000.0 20316.0 79316.0 10 2025
110 2025-05-23 60800.0 18556.0 79356.0 10 2025
111 2025-05-30 60800.0 18834.0 79634.0 10 2025
112 2025-06-06 60800.0 19254.0 80054.0 10 2025
113 2025-06-13 62000.0 18528.0 80528.0 10 2025
114 2025-06-20 63200.0 16976.0 80176.0 10 2025
115 2025-06-27 63200.0 17596.0 80796.0 10 2025
116 2025-07-04 63200.0 17758.0 80958.0 10 2025
117 2025-07-11 63200.0 18198.0 81398.0 10 2025
118 2025-07-18 64800.0 16488.0 81288.0 10 2025
119 2025-07-25 64800.0 16934.0 81734.0 10 2025
120 2025-08-01 66800.0 15168.0 81968.0 10 2025
121 2025-08-08 66800.0 14908.0 81708.0 10 2025
122 2025-08-15 66800.0 14766.0 81566.0 10 2025
123 2025-08-22 71200.0 11406.0 82606.0 10 2025
124 2025-08-29 71200.0 11004.0 82204.0 10 2025
125 2025-09-05 68400.0 13804.0 82204.0 10 2025
126 2025-09-12 70200.0 12362.0 82562.0 10 2025
127 2025-09-19 68400.0 13864.0 82264.0 10 2025
128 2025-09-26 67400.0 14434.0 81834.0 10 2025
129 2025-10-03 67400.0 14374.0 81774.0 10 2025
130 2025-10-10 65800.0 15922.0 81722.0 10 2025
131 2025-10-17 65800.0 15226.0 81026.0 10 2025
132 2025-10-24 67800.0 13844.0 81644.0 10 2025
133 2025-10-31 67800.0 13968.0 81768.0 10 2025
134 2025-11-07 67800.0 14122.0 81922.0 10 2025
135 2025-11-14 67800.0 14130.0 81930.0 10 2025
136 2025-11-21 66000.0 15954.0 81954.0 10 2025
137 2025-11-28 66000.0 16312.0 82312.0 10 2025
138 2025-12-05 66000.0 15682.0 81682.0 10 2025
139 2025-12-12 66000.0 15122.0 81122.0 10 2025
140 2025-12-19 66000.0 15268.0 81268.0 10 2025
141 2025-12-26 66000.0 15350.0 81350.0 10 2025
142 2026-01-02 67400.0 14250.0 81650.0 10 2026
143 2026-01-09 69200.0 13998.0 83198.0 10 2026
144 2026-01-16 73400.0 10432.0 83832.0 10 2026
145 2026-01-23 73400.0 10660.0 84060.0 10 2026
146 2026-01-30 72400.0 11408.0 83808.0 10 2026
147 2026-02-06 71000.0 12648.0 83648.0 10 2026
148 2026-02-13 71000.0 12800.0 83800.0 10 2026
149 2026-02-20 71000.0 12800.0 83800.0 10 2026
150 2026-02-27 69800.0 13764.0 83564.0 10 2026
151 2026-03-06 68200.0 14498.0 82698.0 10 2026
152 2026-03-13 68200.0 14434.0 82634.0 10 2026
153 2026-03-20 66600.0 15290.0 81890.0 10 2026
154 2026-03-27 63000.0 18856.0 81856.0 10 2026
155 2026-04-03 62200.0 18540.0 80740.0 10 2026
156 2026-04-10 60600.0 21340.0 81940.0 10 2026
157 2026-04-17 60600.0 21260.0 81860.0 10 2026
158 2026-04-24 62200.0 19876.0 82076.0 10 2026
BIN
View File
Binary file not shown.
-53
View File
@@ -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
1 feature importance importance_pct
2 dist_to_grid_upper 1319 4.38
3 dist_to_grid_lower 1282 4.26
4 price_cv 1231 4.09
5 amount_mean_20d 1198 3.98
6 range_compression_20d 1047 3.48
7 rebound_from_low_60d 1019 3.38
8 drawdown_60d 981 3.26
9 ma20_deviation_pct 915 3.04
10 grid_touch_count_60d 907 3.01
11 volume_ratio 903 3.0
12 price_entropy 877 2.91
13 avg_daily_amp 857 2.85
14 atr_pct 781 2.59
15 wick_ratio_20d 780 2.59
16 cross_freq_x_bb 743 2.47
17 trend_slope_60d 743 2.47
18 amount_trend_20d 741 2.46
19 range_position_60d 735 2.44
20 obv_slope 731 2.43
21 volatility_20d 707 2.35
22 volume_cv_20d 697 2.32
23 amplitude_cv 673 2.24
24 intraday_trend_strength 669 2.22
25 amp_cv_x_entropy 661 2.2
26 ma60_deviation_pct 655 2.18
27 ma20_ma60_gap_pct 650 2.16
28 grid_room_balance 630 2.09
29 bb_width 629 2.09
30 amount_cv_20d 598 1.99
31 amp_x_grid_vol 588 1.95
32 trend_slope_20d 568 1.89
33 amp_x_grid 499 1.66
34 trend_abs_slope_20d 448 1.49
35 close_reversal_count_20d 395 1.31
36 near_upper_boundary_risk 366 1.22
37 grid_touch_count_20d 335 1.11
38 near_grid_line_ratio_20d 293 0.97
39 low_volume_days_20d 274 0.91
40 turnover_proxy_20d 250 0.83
41 rolling_grid_ratio_20d 248 0.82
42 cross_freq_20d 206 0.68
43 mv_vol_interact 190 0.63
44 down_days_20d 180 0.6
45 high_amp_days 172 0.57
46 up_days_20d 165 0.55
47 small_cap_premium 164 0.54
48 ln_float_mv 162 0.54
49 near_lower_boundary_risk 114 0.38
50 trend_consistency_20d 103 0.34
51 usable_grid_count_lower 13 0.04
52 usable_grid_count_upper 12 0.04
53 grid_cross_density_20d 0 0.0
Binary file not shown.
-55
View File
@@ -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
1 feature importance importance_pct
2 rank_predicted_rounds 1252 20.87
3 top_elite_prob 1163 19.38
4 ma20_deviation_pct 321 5.35
5 atr_pct 296 4.93
6 dist_to_grid_lower 225 3.75
7 price_cv 190 3.17
8 amount_mean_20d 183 3.05
9 drawdown_60d 158 2.63
10 obv_slope 145 2.42
11 dist_to_grid_upper 142 2.37
12 ma20_ma60_gap_pct 141 2.35
13 range_position_60d 124 2.07
14 trend_slope_60d 123 2.05
15 rebound_from_low_60d 114 1.9
16 intraday_trend_strength 98 1.63
17 volume_ratio 98 1.63
18 volume_cv_20d 86 1.43
19 ma60_deviation_pct 81 1.35
20 grid_room_balance 69 1.15
21 range_compression_20d 69 1.15
22 volatility_20d 64 1.07
23 amount_trend_20d 56 0.93
24 near_upper_boundary_risk 54 0.9
25 up_days_20d 50 0.83
26 avg_daily_amp 48 0.8
27 amplitude_cv 48 0.8
28 amount_cv_20d 44 0.73
29 small_cap_premium 42 0.7
30 near_lower_boundary_risk 42 0.7
31 high_amp_days 40 0.67
32 cross_freq_x_bb 37 0.62
33 amp_x_grid 37 0.62
34 bb_width 36 0.6
35 amp_x_grid_vol 36 0.6
36 ln_float_mv 33 0.55
37 price_entropy 28 0.47
38 turnover_proxy_20d 26 0.43
39 trend_slope_20d 25 0.42
40 mv_vol_interact 25 0.42
41 wick_ratio_20d 22 0.37
42 grid_touch_count_60d 21 0.35
43 amp_cv_x_entropy 21 0.35
44 grid_touch_count_20d 20 0.33
45 rolling_grid_ratio_20d 14 0.23
46 near_grid_line_ratio_20d 12 0.2
47 usable_grid_count_upper 9 0.15
48 down_days_20d 9 0.15
49 trend_abs_slope_20d 7 0.12
50 cross_freq_20d 6 0.1
51 usable_grid_count_lower 5 0.08
52 low_volume_days_20d 3 0.05
53 close_reversal_count_20d 1 0.02
54 trend_consistency_20d 1 0.02
55 grid_cross_density_20d 0 0.0
BIN
View File
Binary file not shown.
-54
View File
@@ -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 feature importance importance_pct
2 rank_predicted_rounds 2126 6.86
3 amount_mean_20d 1476 4.76
4 price_cv 1376 4.44
5 range_compression_20d 1165 3.76
6 atr_pct 1105 3.56
7 drawdown_60d 1090 3.52
8 volume_ratio 1030 3.32
9 amount_trend_20d 998 3.22
10 price_entropy 888 2.86
11 rebound_from_low_60d 872 2.81
12 ma20_deviation_pct 868 2.8
13 obv_slope 842 2.72
14 trend_slope_60d 827 2.67
15 wick_ratio_20d 800 2.58
16 ma20_ma60_gap_pct 768 2.48
17 amplitude_cv 767 2.47
18 dist_to_grid_lower 757 2.44
19 range_position_60d 747 2.41
20 amp_cv_x_entropy 714 2.3
21 bb_width 699 2.25
22 ma60_deviation_pct 691 2.23
23 volatility_20d 680 2.19
24 volume_cv_20d 657 2.12
25 intraday_trend_strength 650 2.1
26 avg_daily_amp 643 2.07
27 dist_to_grid_upper 629 2.03
28 trend_slope_20d 626 2.02
29 amp_x_grid_vol 551 1.78
30 amount_cv_20d 550 1.77
31 grid_room_balance 537 1.73
32 trend_abs_slope_20d 504 1.63
33 grid_touch_count_60d 480 1.55
34 cross_freq_x_bb 399 1.29
35 amp_x_grid 383 1.24
36 near_grid_line_ratio_20d 277 0.89
37 close_reversal_count_20d 267 0.86
38 high_amp_days 250 0.81
39 turnover_proxy_20d 243 0.78
40 ln_float_mv 235 0.76
41 low_volume_days_20d 228 0.74
42 mv_vol_interact 223 0.72
43 small_cap_premium 218 0.7
44 grid_touch_count_20d 218 0.7
45 up_days_20d 218 0.7
46 down_days_20d 209 0.67
47 near_upper_boundary_risk 165 0.53
48 trend_consistency_20d 105 0.34
49 near_lower_boundary_risk 87 0.28
50 cross_freq_20d 86 0.28
51 rolling_grid_ratio_20d 64 0.21
52 usable_grid_count_lower 6 0.02
53 usable_grid_count_upper 6 0.02
54 grid_cross_density_20d 0 0.0
-46
View File
@@ -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
View File
@@ -1,69 +1,12 @@
# coding:utf-8 # coding:utf-8
""" import tkinter as tk
启动入口 — 默认使用 Flet2 UI。 from core.main_entry import MainEntry
使用 --tk 参数切换到 Tkinter UI。
"""
import sys
import os
import subprocess
import ssl
import traceback
# 修复 Windows 上 flet_desktop 子进程弹出控制台窗口的问题 # 这是应用的启动入口程序,负责初始化并启动主窗口。
# 原始 Popen 不带 CREATE_NO_WINDOW 标志,会为每个子进程创建控制台窗口 # 它创建一个Tkinter根窗口,实例化主窗口类MainBoardWindow
_original_popen = subprocess.Popen # 并调用其run方法启动主事件循环。
if __name__ == "__main__":
class Popen(_original_popen): import tkinter as tk
def __init__(self, *args, **kwargs): root = tk.Tk()
if sys.platform == "win32" and "creationflags" not in kwargs: app = MainEntry(root)
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW app.run()
super().__init__(*args, **kwargs)
subprocess.Popen = Popen
# PyInstaller 打包后,设置 FLET_VIEW_PATH 指向打包内的 Flet 客户端
# 避免从 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()
+4 -4
View File
@@ -4,7 +4,7 @@ a = Analysis(
['starter.py'], ['starter.py'],
pathex=[], pathex=[],
binaries=[], 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'], hiddenimports=['brotli', 'brotli.encoding'],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
@@ -24,8 +24,8 @@ exe = EXE(
name='神之一手', name='神之一手',
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=True, # 去除调试符号
upx=False, upx=True,
upx_exclude=[], upx_exclude=[],
runtime_tmpdir=None, runtime_tmpdir=None,
console=False, console=False,
@@ -34,5 +34,5 @@ exe = EXE(
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
icon='logo.ico', icon='logo.png' # 添加图标文件
) )